From 7f0066aba301cc749e48493db613b2447d01001a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 31 Oct 2026 18:33:38 -0700 Subject: [PATCH 01/16] feat(computer): an app's own server serves its data plane and its skill - An app declares the paths it keeps its data in under the session user's home (@linux(paths=...)). With no data hooks, they serve its data plane: data/add writes files under them, data/get returns them as a file tree (declared on its card), data/reset puts back the copy its server keeps before it first serves on a boot, the app as installed. - @add_data, @get_data and @reset_data hooks override paths. The base class serves either around the app's contract: a load and a reset stop the app it launched (its pid kept in the app's folder) and relaunch it after, a load that fails is followed by a reset, and each call runs within data_timeout_s. - An app serves its skill file through skill/v1, unless it writes its skill itself. - computer-app/v1's params carry the app's paths. Co-Authored-By: Claude Opus 5.5 --- .../src/agentenv_protocol/computer/app.py | 205 +++++++++++++++-- .../agentenv_protocol/computer/app_data.py | 76 ++++++ .../agentenv_protocol/computer/app_runtime.py | 23 +- .../agentenv_protocol/computer/dataplane.py | 42 ++-- .../agentenv_protocol/computer/serve_app.py | 6 +- .../src/agentenv_protocol/computer/session.py | 34 ++- .../tests/test_computer_app_data.py | 217 ++++++++++++++++++ 7 files changed, 556 insertions(+), 47 deletions(-) create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py create mode 100644 packages/agentenv-protocol/tests/test_computer_app_data.py diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py index 25308532f..e613c1c45 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py @@ -1,24 +1,33 @@ """ComputerAppEnvironment: software on a computer, an AgentEnvEnvironment whose class also declares what it is for a computer: the platforms it supports and the permissions it needs (``@computer_app``); for each OS, how it installs and launches there and what differs there (``@linux``, or ``@install`` for any OS type); its install steps and ready checks, -each on every OS or on those it names; its skill, its typed ``Settings`` and its ``BuildSettings``. A ``computer_app`` -env's put reads that declaration from the class; its data hooks are the base class's. Its name is its env's -``environment_name``. On the computer the class runs as the app's own server (``serve_app``), which launches the app, -adds its launcher and says whether it's ready (``computer-app/v1``), as declared for the OS it runs on.""" +each on every OS or on those it names; its skill, its typed ``Settings`` and its ``BuildSettings``; and its data, the +``paths`` it keeps it in under the session user's home or its own ``@add_data``, ``@get_data`` and ``@reset_data`` +hooks. A ``computer_app`` env's put reads that declaration from the class. Its name is its env's ``environment_name``. +On the computer the class runs as the app's own server (``serve_app``), which launches the app, adds its launcher, says +whether it's ready (``computer-app/v1``), serves its skill, and serves its data plane, stopping the app around a load +and a reset, as declared for the OS it runs on.""" from __future__ import annotations +import asyncio import functools import inspect import json +import logging import os from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import Annotated, Any, Callable, ClassVar, Literal, Optional, Union -from pydantic import AfterValidator, BaseModel, ConfigDict, Field, create_model, field_validator, model_validator +from pydantic import (AfterValidator, BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, create_model, + field_validator, model_validator) -from ..agent_env_environment import _EXT_ATTR, _OP_ATTR, AgentEnvEnvironment, extension -from ..types import METHOD_ADD, METHOD_GET, METHOD_RESET, SKILL_EXTENSION_URI +from ..agent_env_environment import (_EXT_ATTR, _OP_ATTR, OP_ADD_DATA, OP_GET_DATA, OP_RESET_DATA, AgentEnvEnvironment, + add_data, agent_skill, extension, get_data, reset_data) +from ..transfers import RelativePath, WriteNamespaceGrant +from ..types import (METHOD_ADD, METHOD_GET, METHOD_RESET, SKILL_EXTENSION_URI, EnvironmentExtension, IntakeDeclaration, + IntakeFormat, intake_extension) +from . import app_data from .app_runtime import AppRuntime from .errors import InvalidArgument from .platform import detect @@ -27,7 +36,9 @@ APP_SETTINGS_ENV = "AGENTENV_APP_SETTINGS" _INSTALL_STEP_ATTR = "_aee_install_step" _READY_CHECK_ATTR = "_aee_ready_check" -_DATA_METHODS = {"reset_data": METHOD_RESET, "add_data": METHOD_ADD, "get_data": METHOD_GET} +_DATA_METHODS = {OP_RESET_DATA: METHOD_RESET, OP_ADD_DATA: METHOD_ADD, OP_GET_DATA: METHOD_GET} +_PATHS = TypeAdapter(list[RelativePath]) +logger = logging.getLogger(__name__) #: What a launch's window can be matched by, as the session lists each window. WINDOW_KEYS = frozenset({"class", "instance", "title"}) #: The framework extension an app's own server serves: its launch as params, and its ``launch`` and ``ready`` methods. @@ -207,13 +218,15 @@ class AppReadyCheck(BaseModel): class AppOs(BaseModel): """What an app declares for one OS type: ``install``, how that OS installs it, as the OS type's install model gives - it; its ``launch`` there; and its ``skill``, ``settings`` and ``build_settings`` there when they differ from the - class's, the schemas with the class's fields added.""" + it; its ``launch`` there; the ``paths`` it keeps its data in there, relative to the session user's home; and its + ``skill``, ``settings`` and ``build_settings`` there when they differ from the class's, the schemas with the class's + fields added.""" model_config = ConfigDict(extra="forbid") install: dict[str, Any] launch: Optional[Launch] = None + paths: Optional[list[str]] = None skill: Optional[AppSkill] = None settings: Optional[dict[str, Any]] = None build_settings: Optional[dict[str, Any]] = None @@ -258,6 +271,7 @@ class _OsDeclared: install: BaseModel launch: Optional[Launch] + paths: tuple[str, ...] skill: Optional[str] settings: Optional[type[BaseModel]] build_settings: Optional[type[BaseModel]] @@ -274,17 +288,23 @@ def deco(cls: type) -> type: return deco -def install(os_type: str, spec: BaseModel, *, launch: Optional[Launch] = None, skill: Optional[str] = None, - settings: Optional[type[BaseModel]] = None, +def install(os_type: str, spec: BaseModel, *, launch: Optional[Launch] = None, paths: Optional[list[str]] = None, + skill: Optional[str] = None, settings: Optional[type[BaseModel]] = None, build_settings: Optional[type[BaseModel]] = None) -> Callable: """Declare how a computer of ``os_type`` installs the app, ``spec`` being that OS type's install model, and what is - the app's own there: its ``launch``; its ``skill``, a file of its folder, instead of the class's; and ``settings`` - and ``build_settings``, models whose fields add to the class's ``Settings`` and ``BuildSettings`` there. ``@linux`` - is this for Linux; an OS type a plugin adds is declared with it.""" + the app's own there: its ``launch``; the ``paths`` it keeps its data in, relative to the session user's home, which + serve its data plane when it has no data hooks; its ``skill``, a file of its folder, instead of the class's; and + ``settings`` and ``build_settings``, models whose fields add to the class's ``Settings`` and ``BuildSettings`` there. + ``@linux`` is this for Linux; an OS type a plugin adds is declared with it.""" + try: + owned = tuple(_PATHS.validate_python(list(paths or []))) + except ValidationError: + raise ValueError(f"paths are normalized paths relative to the session user's home, not {paths!r}") from None + def deco(cls: type) -> type: if not (isinstance(cls, type) and issubclass(cls, ComputerAppEnvironment)): raise TypeError("@install decorates a ComputerAppEnvironment subclass") - cls._installs = {**cls._installs, os_type: _OsDeclared(spec, launch, skill, settings, build_settings)} + cls._installs = {**cls._installs, os_type: _OsDeclared(spec, launch, owned, skill, settings, build_settings)} return cls return deco @@ -292,12 +312,13 @@ def deco(cls: type) -> type: def linux(*, files: str = ".", packages: Optional[Packages] = None, python: Optional[str] = None, sources: Optional[list[AptSource]] = None, downloads: Optional[list[Download]] = None, run: Optional[list[RunScript]] = None, services: Optional[list[Service]] = None, - ports: Optional[list[int]] = None, launch: Optional[Launch] = None, skill: Optional[str] = None, - settings: Optional[type[BaseModel]] = None, build_settings: Optional[type[BaseModel]] = None) -> Callable: + ports: Optional[list[int]] = None, launch: Optional[Launch] = None, paths: Optional[list[str]] = None, + skill: Optional[str] = None, settings: Optional[type[BaseModel]] = None, + build_settings: Optional[type[BaseModel]] = None) -> Callable: """``@install`` for Linux: how a Linux computer installs the app, ``Linux``'s fields, and what is its own there.""" return install("linux", Linux(files=files, packages=packages, python=python, sources=sources, downloads=downloads, run=run, services=services or [], ports=ports), - launch=launch, skill=skill, settings=settings, build_settings=build_settings) + launch=launch, paths=paths, skill=skill, settings=settings, build_settings=build_settings) def install_step(scope: Literal["system", "session"] = "system", *, @@ -328,15 +349,39 @@ def _named(os_types: Union[str, list[str], None]) -> Optional[list[str]]: class ComputerAppEnvironment(AgentEnvEnvironment): """Subclass it, declare it with ``@computer_app`` and an OS decorator for each OS (``@linux``), and set ``skill`` as a class attribute; a deploy's settings arrive as ``self.settings``, those its env fixes at build as - ``self.build_settings``, and its launch as ``self.launch``, each as declared for the OS it runs on.""" + ``self.build_settings``, and its launch and paths as ``self.launch`` and ``self.paths``, each as declared for the OS + it runs on. + + Its data plane is its ``@add_data``, ``@get_data`` and ``@reset_data`` hooks, or, with none, defaults on its + ``paths`` (``app_data``). The base class serves them around its contract: a load and a reset stop the app first and + relaunch it after, a load that fails is followed by a reset, and each call runs within ``data_timeout_s``.""" supports: ClassVar[list] = [] permissions: ClassVar[list[str]] = [] _installs: ClassVar[dict[str, _OsDeclared]] = {} + #: Its data hooks' method names, by the operation each serves. + _data_hooks: ClassVar[dict[str, str]] = {} _computer_app: Optional[AppRuntime] = None launch: Optional[Launch] = None + paths: list[str] = [] #: A file of the app's folder holding its skill for agents, when no ``@agent_skill`` method writes it. skill: ClassVar[Optional[str]] = None + #: How long one of its data calls may take, a hook or its paths' default, before it fails. + data_timeout_s: ClassVar[float] = 300.0 + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Take the class's data hooks over, for the base class's handlers to serve around its contract; and serve its + skill file unless it writes its skill itself.""" + super().__init_subclass__(**kwargs) + hooks = dict(cls._data_hooks) + for name, member in vars(cls).items(): + if (op := getattr(member, _OP_ATTR, None)) in _DATA_METHODS: + hooks[op] = name + delattr(member, _OP_ATTR) + cls._data_hooks = hooks + if any(getattr(getattr(member, _EXT_ATTR, None), "uri", None) == SKILL_EXTENSION_URI + for member in vars(cls).values()): + cls._computer_app_skill = None class Settings(BaseModel): """The app's settings: an app declares its own; a ``SecretStr`` field is a secret.""" @@ -350,7 +395,9 @@ def __init__(self) -> None: values = json.loads(os.environ.get(APP_SETTINGS_ENV) or "{}").get(os.environ.get("ENVIRONMENT_NAME"), {}) self.settings = cls.settings_on(on).model_validate(values) self.build_settings = _built_settings(cls, on) - self.launch = declared.launch if (declared := cls._installs.get(on)) is not None else None + declared = cls._installs.get(on) + self.launch = declared.launch if declared is not None else None + self.paths = list(declared.paths) if declared is not None else [] ready = self.launch.ready if self.launch is not None else None self._computer_app = AppRuntime( self.launch, {fn.__name__: getattr(self, fn.__name__) for fn in _methods(cls) @@ -388,9 +435,101 @@ def _computer_app_runtime(self) -> AppRuntime: def extension_params(self, uri: str) -> dict: if uri == COMPUTER_APP: - return {"launch": self.launch.model_dump(mode="json", exclude_none=True) if self.launch else None} + return {"launch": self.launch.model_dump(mode="json", exclude_none=True) if self.launch else None, + "paths": self.paths} return super().extension_params(uri) + def served_operations(self, operations: list[str]) -> list[str]: + """Its hooks' operations, or with none, all three when it has paths.""" + served = ({_DATA_METHODS[op] for op in self._data_hooks} if self._data_hooks + else set(_DATA_METHODS.values()) if self.paths else set()) + return [op for op in operations if op in served] + + def served_extensions(self, extensions: list[EnvironmentExtension]) -> list[EnvironmentExtension]: + """Without its skill file on this OS, no skill, unless it writes one itself.""" + own_skill = getattr(type(self), "_computer_app_skill", None) is not None + if own_skill and self._computer_app_skill_file() is None: + extensions = [extension for extension in extensions if extension.uri != SKILL_EXTENSION_URI] + return super().served_extensions(extensions) + + def declared_extensions(self) -> list[EnvironmentExtension]: + """With its paths serving its data, a file tree: ``data/get`` returns a part per file.""" + declared = super().declared_extensions() + if self.paths and not self._data_hooks: + tree = IntakeDeclaration(get=[IntakeFormat(part="file", format="file-tree")], + add=[IntakeFormat(part="file", format="file-tree", load="additive")]) + declared.append(intake_extension(tree, f"data/add writes files under the app's paths, " + f"{', '.join(self.paths)}; data/get returns them; data/reset puts " + "them back as installed.")) + return declared + + @add_data + async def _computer_app_add_data(self, parts: list) -> None: + await self._computer_app_data(OP_ADD_DATA, parts) + + @get_data + async def _computer_app_get_data(self, write_namespace: Optional[WriteNamespaceGrant] = None) -> list: + return await self._computer_app_data(OP_GET_DATA, write_namespace) + + @reset_data + async def _computer_app_reset_data(self) -> None: + await self._computer_app_data(OP_RESET_DATA) + + @agent_skill + def _computer_app_skill(self) -> tuple[str, str]: + return _skill_parts(Path(self._computer_app_skill_file()).read_text(), os.environ.get("ENVIRONMENT_NAME", "")) + + async def _computer_app_data(self, op: str, *args: Any) -> Any: + """``op``, by its hook or its paths' default: a load and a reset stop the app first and relaunch it after, and a + load that fails is followed by a reset.""" + call = self._computer_app_data_call(op) + if op == OP_GET_DATA: + return await self._computer_app_within(op, call(*args)) + runtime = self._computer_app_runtime() + running = await runtime.stop_app() + try: + try: + return await self._computer_app_within(op, call(*args)) + except Exception: + if op == OP_ADD_DATA and (reset := self._computer_app_data_call(OP_RESET_DATA)) is not None: + try: + await self._computer_app_within(OP_RESET_DATA, reset()) + except Exception as e: + logger.warning(f"the reset after a failed data/add failed too: {type(e).__name__}: {e}") + raise + finally: + if running: + await runtime.launch_app() + + def _computer_app_data_call(self, op: str) -> Optional[Callable]: + """What serves ``op``: the class's hook, or its paths' default; None when nothing does.""" + if (name := self._data_hooks.get(op)) is not None: + hook = getattr(self, name) + if op == OP_GET_DATA and "write_namespace" not in inspect.signature(hook).parameters: + return lambda write_namespace=None: hook() + return hook + if self._data_hooks or not self.paths: + return None + return {OP_ADD_DATA: lambda parts: app_data.add(parts, self.paths), + OP_GET_DATA: lambda write_namespace: app_data.get(self.paths, write_namespace), + OP_RESET_DATA: lambda: app_data.reset(self.paths)}[op] + + async def _computer_app_within(self, op: str, result: Any) -> Any: + try: + return await asyncio.wait_for(_awaited(result), self.data_timeout_s) + except TimeoutError: + raise TimeoutError(f"{op} ran past the app's data_timeout_s, {self.data_timeout_s:g} s") from None + + def _computer_app_keep_initial(self) -> None: + """Keep the app's paths as installed, before anything changes them, when they serve its data.""" + if self.paths and not self._data_hooks: + app_data.keep_initial(self.paths) + + def _computer_app_skill_file(self) -> Optional[str]: + """The file of the app's folder holding its skill on this OS, or the class's.""" + declared = type(self)._installs.get(detect().os) + return declared.skill if declared is not None and declared.skill is not None else type(self).skill + @classmethod def declaration(cls) -> AppDeclaration: """What the class declares. Raises ValueError for a skill declared twice, a build setting that's a secret, or @@ -404,7 +543,8 @@ def declaration(cls) -> AppDeclaration: "it in Settings") methods = _methods(cls) skill_methods = [fn.__name__ for fn in methods - if getattr(getattr(fn, _EXT_ATTR, None), "uri", None) == SKILL_EXTENSION_URI] + if getattr(getattr(fn, _EXT_ATTR, None), "uri", None) == SKILL_EXTENSION_URI + and not fn.__name__.startswith("_computer_app_")] if cls.skill is not None and skill_methods: raise ValueError(f"{cls.__name__} declares its skill twice: skill = {cls.skill!r} and @agent_skill " f"{skill_methods[0]}") @@ -425,6 +565,7 @@ def declaration(cls) -> AppDeclaration: supports=cls.supports, permissions=cls.permissions, os={os_type: AppOs( install=declared.install.model_dump(mode="json", exclude_none=True), launch=declared.launch, + paths=list(declared.paths) or None, skill=AppSkill(file=declared.skill) if declared.skill is not None else None, settings=cls.settings_on(os_type).model_json_schema() if declared.settings is not None else None, build_settings=(cls.build_settings_on(os_type).model_json_schema() @@ -433,11 +574,27 @@ def declaration(cls) -> AppDeclaration: settings=cls.Settings.model_json_schema(), build_settings=build if build.get("properties") else None, skill=AppSkill(file=cls.skill) if cls.skill is not None else AppSkill(method=skill_methods[0]) if skill_methods else None, - data=sorted({_DATA_METHODS[op] for fn in methods if (op := getattr(fn, _OP_ATTR, None)) in _DATA_METHODS}), + data=sorted(_DATA_METHODS[op] for op in cls._data_hooks), install_steps=steps, ready_checks=checks, ) +async def _awaited(result: Any) -> Any: + return await result if inspect.isawaitable(result) else result + + +def _skill_parts(text: str, name: str) -> tuple[str, str]: + """A skill file's description and body: from its frontmatter's ``description`` when it has one.""" + if text.startswith("---\n") and (end := text.find("\n---", 4)) != -1: + front, body = text[4:end], text[end + 4:].lstrip("\n") + description = next((line.partition(":")[2].strip().strip("'\"") for line in front.splitlines() + if line.startswith("description:")), "") + if description: + return description, body + text = body + return f"How to use {name} on this computer.", text + + def _secret_at(schema: dict, path: str = "") -> Optional[str]: """Where in ``schema``, a JSON schema, a secret is, a ``SecretStr``'s ``writeOnly``: in it or any of its subschemas, a field's, a list's items or a nested model's definition; None when it holds none. A default or a field's name diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py new file mode 100644 index 000000000..754007b4b --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py @@ -0,0 +1,76 @@ +"""The data of an app that declares only the paths it keeps it in, under the session user's home: a load writes the files +it's given under them, an export returns the files there, and a reset puts back the copy its server kept when it first +started on this boot, which is the app as installed, since every boot starts from the computer's image.""" +from __future__ import annotations + +import asyncio +import shutil +from pathlib import Path, PurePosixPath +from typing import Optional + +from ..transfers import WriteNamespaceGrant +from ..types import FilePart, uploaded_object_path +from .dataplane import file_parts, part_bytes + +#: Where an app's server keeps its paths as installed, in the app's folder. +INITIAL = Path(".agentenv/initial") + + +def keep_initial(paths: list[str]) -> None: + """Copy what's under each of ``paths`` of the home into ``INITIAL``, unless this boot kept a copy already.""" + if INITIAL.exists(): + return + staging = INITIAL.with_name(f"{INITIAL.name}.partial") + shutil.rmtree(staging, ignore_errors=True) + staging.mkdir(parents=True) + for path in paths: + _copy(Path.home() / path, staging / path) + staging.rename(INITIAL) + + +async def add(parts: list, paths: list[str]) -> None: + """Write each file part at the path it names, which must be under one of ``paths``.""" + files = {} + for part in parts: + path = uploaded_object_path(part) if isinstance(part, FilePart) else None + if path is None: + raise ValueError(f"data/add on this app takes file parts naming their path, got a {part.kind} part") + if not any(PurePosixPath(path).is_relative_to(owned) for owned in paths): + raise ValueError(f"{path!r} is under none of this app's paths: {', '.join(paths)}") + files[path] = await part_bytes(part) + await asyncio.to_thread(_write, Path.home(), files) + + +async def get(paths: list[str], write_namespace: Optional[WriteNamespaceGrant]) -> list: + """A part for each file under ``paths``.""" + return await file_parts(Path.home(), paths, write_namespace) + + +async def reset(paths: list[str]) -> None: + """Put each of ``paths`` back as ``keep_initial`` kept it, or remove it where it wasn't there then.""" + await asyncio.to_thread(_restore, Path.home(), paths) + + +def _write(home: Path, files: dict[str, bytes]) -> None: + for path, data in files.items(): + target = home / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + + +def _restore(home: Path, paths: list[str]) -> None: + for path in paths: + target = home / path + if target.is_dir() and not target.is_symlink(): + shutil.rmtree(target) + else: + target.unlink(missing_ok=True) + _copy(INITIAL / path, target) + + +def _copy(source: Path, target: Path) -> None: + if source.is_dir() and not source.is_symlink(): + shutil.copytree(source, target, symlinks=True) + elif source.exists() or source.is_symlink(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target, follow_symlinks=False) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py index d5f27370f..6e48505bc 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py @@ -1,6 +1,6 @@ """A computer app's own server at work: it launches the app into the desktop session, marking each launch for the rest -of the boot, and says whether the app is ready, its checks run at once, each within ``CHECK_TIMEOUT_S`` -(``AppReadiness``). ``ComputerAppEnvironment`` keeps one as ``self._computer_app``.""" +of the boot, stops the app it launched, and says whether the app is ready, its checks run at once, each within +``CHECK_TIMEOUT_S`` (``AppReadiness``). ``ComputerAppEnvironment`` keeps one as ``self._computer_app``.""" from __future__ import annotations import asyncio @@ -23,6 +23,11 @@ LAUNCHED = Path("/run/agentenv/apps") #: How long one readiness check may take; an app's checks run at once. CHECK_TIMEOUT_S = 10.0 +#: Where an app's server keeps the pid of the app it launched last, in the app's folder, so a server that restarts can +#: still stop it. +PID = Path(".agentenv/app.pid") +#: How long a stopped app may take to exit before it's killed. +STOP_TIMEOUT_S = 10.0 class AppReadiness(BaseModel): @@ -68,10 +73,24 @@ async def launch_app(self) -> int: """Start the app from its folder, marked as launched; its pid.""" pid = await session.launch(self.launch.command, Path.cwd(), {"ENVIRONMENT_NAME": os.environ["ENVIRONMENT_NAME"]}) self.launch_failed = None + PID.parent.mkdir(parents=True, exist_ok=True) + PID.write_text(str(pid)) _mark().parent.mkdir(parents=True, exist_ok=True) _mark().touch() return pid + async def stop_app(self) -> bool: + """Stop the app this server launched last, with everything it started; whether it was running.""" + try: + pid = int(PID.read_text()) + except (FileNotFoundError, ValueError): + return False + PID.unlink() + if not session.alive(pid): + return False + await session.stop(pid, STOP_TIMEOUT_S) + return True + async def readiness(self) -> AppReadiness: ready = self.launch.ready if self.launch is not None else None checks: list[tuple[str, Awaitable[Optional[str]]]] = [] diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/dataplane.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/dataplane.py index ba9f8dcb8..ec9926765 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/dataplane.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/dataplane.py @@ -47,7 +47,7 @@ async def _computer_data_add(self, parts: list) -> None: if path is None: raise ValueError(f"data/add on a computer takes file parts naming their path under " f"{DATA_OBJECTS_EXTENSION_URI}, got a {part.kind} part") - files[path] = await _fetch(part) + files[path] = await part_bytes(part) await self._computer.call("write_files", files) @get_data @@ -57,23 +57,29 @@ async def _computer_data_get(self, write_namespace: Optional[WriteNamespaceGrant named = _PATHS.validate_python(paths) if paths is not None else list(DEFAULT_DATA_PATHS) except ValidationError: raise ValueError(f"data/get on a computer takes paths relative to the home, not {paths!r}") from None - files = _data_files(Path.home(), named) - if write_namespace is not None: - uploader = NamespaceUploader(write_namespace) - parts = [] - for relative, source in files: - await uploader.upload(relative, source) - parts.append(uploaded_file_part(relative, name=source.name, mime_type=_mime(source))) - return parts - return [ - FilePart(file=FileWithBytes(bytes=base64.b64encode(source.read_bytes()).decode("ascii"), - name=source.name, mimeType=_mime(source)), - metadata={DATA_OBJECTS_EXTENSION_URI: {"path": relative}}) - for relative, source in files - ] - - -async def _fetch(part: FilePart) -> bytes: + return await file_parts(Path.home(), named, write_namespace) + + +async def file_parts(home: Path, paths: list[str], write_namespace: Optional[WriteNamespaceGrant]) -> list: + """A part for each file under ``paths`` of ``home``, naming its path relative to ``home``: uploaded under + ``write_namespace`` when there is one, else inline.""" + files = _data_files(home, paths) + if write_namespace is not None: + uploader = NamespaceUploader(write_namespace) + parts = [] + for relative, source in files: + await uploader.upload(relative, source) + parts.append(uploaded_file_part(relative, name=source.name, mime_type=_mime(source))) + return parts + return [ + FilePart(file=FileWithBytes(bytes=base64.b64encode(source.read_bytes()).decode("ascii"), + name=source.name, mimeType=_mime(source)), + metadata={DATA_OBJECTS_EXTENSION_URI: {"path": relative}}) + for relative, source in files + ] + + +async def part_bytes(part: FilePart) -> bytes: """A part's bytes: inline, from a read grant under the marker, or from an https URL.""" marker = part.metadata[DATA_OBJECTS_EXTENSION_URI] if "read" in marker: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/serve_app.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/serve_app.py index e7367c42c..e617819cf 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/serve_app.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/serve_app.py @@ -1,7 +1,8 @@ """Serve a computer app's class from its file, as its computer's program starts it: ``python -m agentenv_protocol.computer.serve_app :``, run in the app's folder, serves the class's card and -``computer-app/v1`` on the Unix socket ``.agentenv/server.sock`` there. Once it serves, the app's server adds the app's -launcher and launches a boot app (``AppRuntime.start``). A server that can't start, or whose start fails once it serves, +``computer-app/v1`` on the Unix socket ``.agentenv/server.sock`` there. Before it serves, it keeps a copy of the app's +data paths as installed, which a reset puts back; once it serves, it adds the app's launcher and launches a boot app +(``AppRuntime.start``). A server that can't start, or whose start fails once it serves, writes why to ``.agentenv/server.failure``, which its computer's driver reports, and exits. Needs the ``app`` extra.""" from __future__ import annotations @@ -36,6 +37,7 @@ def _serve(argv: list[str]) -> None: file, _, name = argv[0].partition(":") app = load_class(Path(file), name, ComputerAppEnvironment)() runtime = app._computer_app_runtime() + app._computer_app_keep_initial() asgi = app.create_app().streamable_http_app() server = uvicorn.Server(uvicorn.Config(asgi, uds=str(SOCKET), log_level="warning")) serving, failed = asgi.router.lifespan_context, [] diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/session.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/session.py index 3102ff8a5..9f08d21f5 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/session.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/session.py @@ -1,10 +1,12 @@ """What a computer app's own server does in the desktop session, the Linux way: wait for the session, launch the app -into it, add its menu launcher and list the session's windows. Another OS type brings its own.""" +into it and stop it, add its menu launcher and list the session's windows. Another OS type brings its own.""" from __future__ import annotations import asyncio +import contextlib import os import re +import signal from pathlib import Path #: How often a starting session is looked for. @@ -32,6 +34,36 @@ async def launch(argv: list[str], cwd: Path, env: dict[str, str]) -> int: return process.pid +async def stop(pid: int, timeout_s: float) -> None: + """End the session ``launch`` started as ``pid``, every process in it: asked to exit, then killed after + ``timeout_s``.""" + try: + os.killpg(pid, signal.SIGTERM) + except ProcessLookupError: + return + try: + await asyncio.wait_for(_exited(pid), timeout_s) + except TimeoutError: + with contextlib.suppress(ProcessLookupError): + os.killpg(pid, signal.SIGKILL) + + +def alive(pid: int) -> bool: + """Whether the process ``pid`` is running.""" + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +async def _exited(pid: int) -> None: + while alive(pid): + await asyncio.sleep(POLL_S) + + def add_launcher(name: str, folder: Path) -> Path: """The desktop entry that launches the app ``name`` in ``folder`` through its own server, written where the session user's menu finds it.""" diff --git a/packages/agentenv-protocol/tests/test_computer_app_data.py b/packages/agentenv-protocol/tests/test_computer_app_data.py new file mode 100644 index 000000000..f327ce20c --- /dev/null +++ b/packages/agentenv-protocol/tests/test_computer_app_data.py @@ -0,0 +1,217 @@ +"""A computer app's data plane, served by its own server. An app that declares only paths gets the defaults: a load writes +files under them, an export returns them, and a reset puts back the copy its server kept before it served; its card +declares a file tree. Hooks override paths. Around a load and a reset the app is stopped and relaunched, a failed load +is followed by a reset, and a call past data_timeout_s fails. The app serves its skill file.""" +from __future__ import annotations + +import asyncio +import base64 +import contextlib + +import pytest + +pytest.importorskip("mcp.server.fastmcp") + +import httpx # noqa: E402 + +from agentenv_protocol import add_data, client, reset_data # noqa: E402 +from agentenv_protocol.computer import ComputerAppEnvironment, Launch, linux # noqa: E402 +from agentenv_protocol.computer import app, app_runtime, session # noqa: E402 +from agentenv_protocol.computer.app import COMPUTER_APP # noqa: E402 +from agentenv_protocol.computer.platform import Platform # noqa: E402 +from agentenv_protocol.types import (DATA_OBJECTS_EXTENSION_URI, INTAKE_EXTENSION_URI, RPC_PATH, # noqa: E402 + SKILL_EXTENSION_URI, WELL_KNOWN_PATH) + +MAIL = ["Mail", ".config/mail/accounts.json"] + + +@linux(paths=MAIL, launch=Launch(command=["mail"])) +class PathsMail(ComputerAppEnvironment): + skill = "SKILL.md" + + +@linux(paths=MAIL, launch=Launch(command=["mail"])) +class HookedMail(ComputerAppEnvironment): + loaded: list + resets = 0 + + @add_data + async def load(self, parts: list) -> None: + if any(part["metadata"][DATA_OBJECTS_EXTENSION_URI]["path"] == "bad" for part in map(dict, parts)): + raise RuntimeError("the seed is bad") + self.loaded = parts + + @reset_data + def wipe(self) -> None: + type(self).resets += 1 + + +@pytest.fixture +def home(tmp_path, monkeypatch): + """The session user's home, the app's folder as the current directory, on Linux, the app named mail.""" + monkeypatch.setenv("HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir() + (tmp_path / "folder").mkdir() + monkeypatch.chdir(tmp_path / "folder") + monkeypatch.setattr(app, "detect", lambda: Platform(os="linux")) + monkeypatch.setenv("ENVIRONMENT_NAME", "mail") + monkeypatch.setattr(app_runtime, "LAUNCHED", tmp_path / "launched") + return tmp_path / "home" + + +@pytest.fixture +def desktop(monkeypatch): + """Each launch a new pid, alive until stopped; what's launched and stopped, in order.""" + events, running = [], set() + + async def launch(argv, cwd, env): + pid = 4242 + len(events) + running.add(pid) + events.append(("launch", pid)) + return pid + + async def stop(pid, timeout_s): + running.discard(pid) + events.append(("stop", pid)) + + monkeypatch.setattr(session, "launch", launch) + monkeypatch.setattr(session, "stop", stop) + monkeypatch.setattr(session, "alive", lambda pid: pid in running) + return events + + +@pytest.mark.asyncio +async def test_an_app_with_only_paths_loads_exports_and_resets_them_as_installed(home): + (home / "Mail").mkdir() + (home / "Mail/inbox.txt").write_text("installed") + mail = PathsMail() + mail._computer_app_keep_initial() + + async with _http(mail) as http: + card = (await http.get(WELL_KNOWN_PATH)).json() + await _rpc(http, "data/add", {"parts": [_inline("Mail/new.txt", b"new"), _inline("Mail/inbox.txt", b"loaded"), + _inline(".config/mail/accounts.json", b"{}")]}) + loaded = _files(await _rpc(http, "data/get", {})) + await _rpc(http, "data/reset", {}) + reset = _files(await _rpc(http, "data/get", {})) + + assert sorted(card["capabilities"]["operations"]) == ["data/add", "data/get", "data/reset"] + assert client.extension_params(card, COMPUTER_APP)["paths"] == MAIL + assert client.find_extension(card, INTAKE_EXTENSION_URI) is not None + assert loaded == {"Mail/inbox.txt": b"loaded", "Mail/new.txt": b"new", ".config/mail/accounts.json": b"{}"} + assert reset == {"Mail/inbox.txt": b"installed"} + + +@pytest.mark.asyncio +async def test_a_file_under_none_of_its_paths_is_refused_and_nothing_is_written(home): + async with _http(PathsMail()) as http: + answer = await http.post(RPC_PATH, json=_call("data/add", {"parts": [_inline("Mail/a.txt", b"a"), + _inline("Documents/b.txt", b"b")]})) + + assert "'Documents/b.txt' is under none of this app's paths" in answer.json()["error"]["message"] + assert not (home / "Mail").exists() + + +@pytest.mark.asyncio +async def test_hooks_override_its_paths(home): + async with _http(HookedMail()) as http: + card = (await http.get(WELL_KNOWN_PATH)).json() + + assert sorted(card["capabilities"]["operations"]) == ["data/add", "data/reset"] + assert client.find_extension(card, INTAKE_EXTENSION_URI) is None + assert HookedMail.declaration().data == ["data/add", "data/reset"] + + +@pytest.mark.asyncio +async def test_a_load_and_a_reset_stop_the_running_app_and_relaunch_it_and_an_export_leaves_it_be(home, desktop): + mail = PathsMail() + mail._computer_app_keep_initial() + await mail._computer_app.launch_app() + + async with _http(mail) as http: + await _rpc(http, "data/add", {"parts": [_inline("Mail/a.txt", b"a")]}) + await _rpc(http, "data/get", {}) + await _rpc(http, "data/reset", {}) + + assert desktop == [("launch", 4242), ("stop", 4242), ("launch", 4244), ("stop", 4244), ("launch", 4246)] + + +@pytest.mark.asyncio +async def test_an_app_that_isnt_running_is_left_stopped(home, desktop): + mail = PathsMail() + mail._computer_app_keep_initial() + + async with _http(mail) as http: + await _rpc(http, "data/add", {"parts": [_inline("Mail/a.txt", b"a")]}) + + assert desktop == [] + + +@pytest.mark.asyncio +async def test_a_failed_load_is_followed_by_a_reset_and_the_app_is_relaunched(home, desktop): + mail, HookedMail.resets = HookedMail(), 0 + await mail._computer_app.launch_app() + + async with _http(mail) as http: + answer = await http.post(RPC_PATH, json=_call("data/add", {"parts": [_inline("bad", b"x")]})) + + assert answer.json()["error"]["message"] == "the seed is bad" + assert HookedMail.resets == 1 and desktop == [("launch", 4242), ("stop", 4242), ("launch", 4244)] + + +@pytest.mark.asyncio +async def test_a_call_past_its_timeout_fails_saying_so(home, desktop): + @linux(launch=Launch(command=["slow"])) + class Slow(ComputerAppEnvironment): + data_timeout_s = 0.2 + + @add_data + async def load(self, parts: list) -> None: + await asyncio.sleep(5) + + async with _http(Slow()) as http: + answer = await http.post(RPC_PATH, json=_call("data/add", {"parts": [_inline("a.txt", b"a")]})) + + assert answer.json()["error"]["message"] == "add_data ran past the app's data_timeout_s, 0.2 s" + + +@pytest.mark.asyncio +async def test_it_serves_its_skill_file_and_no_skill_without_one(home, tmp_path): + (tmp_path / "folder" / "SKILL.md").write_text("---\ndescription: Reading and sending mail.\n---\n\n# Mail\nUse it.\n") + + async with _http(PathsMail()) as http: + card = (await http.get(WELL_KNOWN_PATH)).json() + skill = (await http.get(client.find_extension_method(card, SKILL_EXTENSION_URI, "get")["endpoint"], + params={"name": "mail"})).json() + async with _http(HookedMail()) as http: + unskilled = (await http.get(WELL_KNOWN_PATH)).json() + + assert skill["description"] == "Reading and sending mail." and skill["skill_md"].endswith("# Mail\nUse it.\n") + assert client.find_extension(unskilled, SKILL_EXTENSION_URI) is None + + +@contextlib.asynccontextmanager +async def _http(served: ComputerAppEnvironment): + asgi = served.create_app().streamable_http_app() + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi), base_url="http://t") as http: + yield http + + +def _call(method: str, params: dict) -> dict: + return {"jsonrpc": "2.0", "id": 1, "method": method, "params": params} + + +async def _rpc(http: httpx.AsyncClient, method: str, params: dict) -> dict: + answer = (await http.post(RPC_PATH, json=_call(method, params))).json() + assert "error" not in answer, answer + return answer["result"] + + +def _inline(path: str, data: bytes) -> dict: + return {"kind": "file", "file": {"bytes": base64.b64encode(data).decode(), "name": path.rsplit("/", 1)[-1]}, + "metadata": {DATA_OBJECTS_EXTENSION_URI: {"path": path}}} + + +def _files(result: dict) -> dict[str, bytes]: + return {part["metadata"][DATA_OBJECTS_EXTENSION_URI]["path"]: base64.b64decode(part["file"]["bytes"]) + for part in result["parts"]} From a4d69eaf9ae9a5e8d70d360a76e7f7295e22be32 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 31 Oct 2026 18:37:46 -0700 Subject: [PATCH 02/16] feat(computer): the driver fronts its computer's apps as child environments - The driver's card lists each app composed onto the computer as a child environment, named for its folder: its own server's card with every path under /svc//, or its name and route alone while its server isn't answering. A new AgentEnvEnvironment hook, children_environments(), lists them each time the card is served. - The driver forwards every request under /svc// to that app's server on its socket, with no timeout of its own; a name it has no app for is a 404. - The driver's own data/get leaves out the files under any path an app keeps its data in, as the apps' cards say. Co-Authored-By: Claude Opus 5.5 --- .../agent_env_environment.py | 13 +++- .../src/agentenv_protocol/computer/apps.py | 72 +++++++++++++++++-- .../agentenv_protocol/computer/dataplane.py | 19 ++--- .../src/agentenv_protocol/computer/driver.py | 34 +++++++-- .../src/agentenv_protocol/computer/runtime.py | 4 ++ .../tests/test_computer_app_server.py | 43 ++++++++++- 6 files changed, 165 insertions(+), 20 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/agent_env_environment.py b/packages/agentenv-protocol/src/agentenv_protocol/agent_env_environment.py index f2b3b4676..c88634844 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/agent_env_environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/agent_env_environment.py @@ -14,7 +14,8 @@ import types import uuid from abc import ABC, abstractmethod -from typing import Annotated, Any, Callable, ClassVar, Literal, NamedTuple, Union, get_args, get_origin, get_type_hints +from typing import (Annotated, Any, Callable, ClassVar, Literal, NamedTuple, Optional, Union, get_args, get_origin, + get_type_hints) from pydantic import TypeAdapter, ValidationError from pydantic.fields import FieldInfo @@ -376,7 +377,10 @@ def _card_for(self, app: Any) -> EnvironmentCard: return self.environment_card async def _serve_card(self, request: Request) -> Response: - return _json_response(self.environment_card.model_dump()) + card = self.environment_card + if isinstance(self.handler, AgentEnvEnvironment) and (children := await self.handler.children_environments()): + card = card.model_copy(update={"children_environments": children}) + return _json_response(card.model_dump()) async def update_tool(self, name: str, *, description: str) -> None: """Change a served tool's description after the mount: on the card and in tools/list alike.""" @@ -662,6 +666,11 @@ def declared_extensions(self) -> list[EnvironmentExtension]: """Declaration-only extensions to advertise, like ``intake/v1``: a promise with params and no handler.""" return [] + async def children_environments(self) -> Optional[list[EnvironmentCard]]: + """The environments this one fronts, as each serves its card with its paths under this one's, listed on the card + each time it's served; None for none.""" + return None + def served_extensions(self, extensions: list[EnvironmentExtension]) -> list[EnvironmentExtension]: """The ``@extension`` handlers' extensions to serve, chosen at mount: one left out is not served, and of one served with ``op``, only the methods its returned descriptor still lists.""" diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/apps.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/apps.py index 56c41b567..9de569ed5 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/apps.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/apps.py @@ -1,15 +1,20 @@ -"""The apps composed onto a computer, as its driver finds them, and whether each is ready. Each app's own server -answers on its folder's socket (``serve_app``): its card says how the app launches, and its ``computer-app/v1`` -``ready`` whether it's ready. Every app's server must answer before the computer is ready; an app that starts with the -computer must also be ready, and a ``demand`` app is checked once something launches it.""" +"""The apps composed onto a computer, as its driver finds them, whether each is ready, and how the driver fronts them. +Each app's own server answers on its folder's socket (``serve_app``): its card says how the app launches and which +paths it owns, and its ``computer-app/v1`` ``ready`` whether it's ready. Every app's server must answer before the +computer is ready; an app that starts with the computer must also be ready, and a ``demand`` app is checked once +something launches it. The driver lists each app as a child environment named for its folder, its card's paths under +``/svc//``, and forwards each request there to the app's server.""" from __future__ import annotations +import asyncio +import copy from pathlib import Path +from typing import Optional import httpx from .. import client -from ..types import WELL_KNOWN_PATH +from ..types import RPC_PATH, WELL_KNOWN_PATH, EnvironmentCard from .app import COMPUTER_APP, FAILURE, SOCKET from .app_runtime import CHECK_TIMEOUT_S, AppReadiness from .operations import ReadyCheck @@ -46,11 +51,68 @@ async def check(folder: Path) -> ReadyCheck: return ReadyCheck(f"app {name}", False, f"{when}: {'; '.join(answer.problems)}") +async def children(folders: list[Path]) -> list[EnvironmentCard]: + """Each app in ``folders`` as a child environment of its computer: its server's card, rooted at ``/svc//``, + or, while its server isn't answering, its name and route alone.""" + cards = await asyncio.gather(*(_card(folder) for folder in folders)) + return [EnvironmentCard.model_validate(child_card(folder.name, card)) if card is not None + else EnvironmentCard(name=folder.name, url=f"/svc/{folder.name}{RPC_PATH}") + for folder, card in zip(folders, cards)] + + +def child_card(name: str, card: dict) -> dict: + """``card`` with every path it names, its ``url`` and each extension's and method's endpoint, under + ``/svc/``, so it resolves against the computer's address; and none of its interfaces, which agents reach + through the computer.""" + card = copy.deepcopy(card) + + def rooted(path: object) -> object: + return f"/svc/{name}{path}" if isinstance(path, str) and path.startswith("/") else path + + card["url"] = rooted(card.get("url") or RPC_PATH) + card["additionalInterfaces"], card["children_environments"] = [], None + for extension in (card.get("capabilities") or {}).get("extensions") or []: + params = extension.get("params") + if not isinstance(params, dict): + continue + if "endpoint" in params: + params["endpoint"] = rooted(params["endpoint"]) + for method in (params.get("methods") or {}).values(): + if isinstance(method, dict) and "endpoint" in method: + method["endpoint"] = rooted(method["endpoint"]) + return card + + +async def owned_paths(folders: list[Path]) -> list[str]: + """The paths under the session user's home that the apps in ``folders`` keep their data in, as their cards say.""" + cards = await asyncio.gather(*(_card(folder) for folder in folders)) + return [path for card in cards if card is not None + for path in client.extension_params(card, COMPUTER_APP).get("paths") or []] + + +async def forward(folder: Path, method: str, path: str, query: str, body: bytes, + content_type: Optional[str]) -> httpx.Response: + """The app's server's answer to the request, sent on as it came; it takes as long as the app does.""" + async with httpx.AsyncClient(transport=httpx.AsyncHTTPTransport(uds=str(folder / SOCKET)), base_url="http://app", + timeout=None) as http: + return await http.request(method, f"/{path}" + (f"?{query}" if query else ""), content=body, + headers={"content-type": content_type} if content_type else None) + + def endpoint(card: dict, method: str) -> str: """The path of a ``computer-app/v1`` method, as the app's card advertises it.""" return client.find_extension_method(card, COMPUTER_APP, method)["endpoint"] +async def _card(folder: Path) -> Optional[dict]: + """The card the app in ``folder`` serves, or None while its server isn't answering.""" + try: + async with _client(folder / SOCKET) as http: + return (await http.get(WELL_KNOWN_PATH)).raise_for_status().json() + except (httpx.HTTPError, OSError, ValueError): + return None + + def _unanswered(folder: Path) -> str: try: return f"its server failed to start: {(folder / FAILURE).read_text()}" diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/dataplane.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/dataplane.py index ec9926765..01f704429 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/dataplane.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/dataplane.py @@ -1,13 +1,14 @@ """The data plane on a computer: ``data/add`` writes files under the session user's home, ``data/get`` returns the -files under the paths its caller names, or under ``DEFAULT_DATA_PATHS`` when it names none. Each file part names its -path, relative to the home, under the ``data-objects/v1`` marker.""" +files under the paths its caller names, or under ``DEFAULT_DATA_PATHS`` when it names none, leaving out those under a +path one of the computer's apps keeps its data in. Each file part names its path, relative to the home, under the +``data-objects/v1`` marker.""" from __future__ import annotations import base64 import binascii import mimetypes import tempfile -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Optional import httpx @@ -57,13 +58,15 @@ async def _computer_data_get(self, write_namespace: Optional[WriteNamespaceGrant named = _PATHS.validate_python(paths) if paths is not None else list(DEFAULT_DATA_PATHS) except ValidationError: raise ValueError(f"data/get on a computer takes paths relative to the home, not {paths!r}") from None - return await file_parts(Path.home(), named, write_namespace) + return await file_parts(Path.home(), named, write_namespace, leaving_out=await self._computer.app_paths()) -async def file_parts(home: Path, paths: list[str], write_namespace: Optional[WriteNamespaceGrant]) -> list: - """A part for each file under ``paths`` of ``home``, naming its path relative to ``home``: uploaded under - ``write_namespace`` when there is one, else inline.""" - files = _data_files(home, paths) +async def file_parts(home: Path, paths: list[str], write_namespace: Optional[WriteNamespaceGrant], + leaving_out: list[str] = ()) -> list: + """A part for each file under ``paths`` of ``home`` but under none of ``leaving_out``, naming its path relative to + ``home``: uploaded under ``write_namespace`` when there is one, else inline.""" + files = [(relative, source) for relative, source in _data_files(home, paths) + if not any(PurePosixPath(relative).is_relative_to(owned) for owned in leaving_out)] if write_namespace is not None: uploader = NamespaceUploader(write_namespace) parts = [] diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/driver.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/driver.py index 17067b566..ddc16a3e4 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/driver.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/driver.py @@ -1,16 +1,20 @@ """ComputerDriverEnvironment: a driver writes screen operations, and the base class serves the computer contract -from them: the tools agents call, the framework extensions agentenv calls, the data plane and the card.""" +from them: the tools agents call, the framework extensions agentenv calls, the data plane and the card, which lists the +computer's apps as child environments, each reached at ``/svc//`` through the driver.""" from __future__ import annotations import asyncio from typing import Any, Callable, ClassVar +import httpx from pydantic import BaseModel, ConfigDict +from starlette.requests import Request +from starlette.responses import JSONResponse, Response from ..agent_env_environment import _CARD_CONFIG_ATTR, AgentEnvEnvironment, AgentEnvFastMCPApplication, environment_card from ..tool_context import ToolContext from ..types import EnvironmentCard, EnvironmentExtension, EnvironmentTool -from . import _linux, card +from . import _linux, apps, card from .checks import check_features, check_names from .dataplane import DataPlane from .extensions import Extensions @@ -118,11 +122,33 @@ async def bundled_skill(self) -> tuple[str, str, dict[str, str]] | None: def create_app(self) -> Any: self._computer_require_init() - return super().create_app() + return self._computer_front_apps(super().create_app()) def mount(self, app: Any) -> Any: self._computer_require_init() - return super().mount(app) + return self._computer_front_apps(super().mount(app)) + + async def children_environments(self) -> list[EnvironmentCard] | None: + """The computer's apps, from the folders it composed them into.""" + return await apps.children(self._computer.apps) if self._computer.apps else None + + def _computer_front_apps(self, app: Any) -> Any: + app.custom_route("/svc/{name}/{path:path}", methods=["GET", "POST"])(self._computer_forward) + return app + + async def _computer_forward(self, request: Request) -> Response: + """A request under ``/svc//``, sent on to the app named so: its own server answers it.""" + name = request.path_params["name"] + folder = next((folder for folder in self._computer.apps if folder.name == name), None) + if folder is None: + return JSONResponse({"error": f"this computer has no app named {name!r}"}, status_code=404) + try: + answer = await apps.forward(folder, request.method, request.path_params["path"], request.url.query, + await request.body(), request.headers.get("content-type")) + except (httpx.HTTPError, OSError) as e: + return JSONResponse({"error": f"app {name}'s server isn't answering: {type(e).__name__}: {e}"}, + status_code=502) + return Response(answer.content, status_code=answer.status_code, media_type=answer.headers.get("content-type")) # The card: what the driver's operations support. diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/runtime.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/runtime.py index 2546679f0..51888f16a 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/runtime.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/runtime.py @@ -43,6 +43,10 @@ def __init__(self, driver: ComputerDriverEnvironment) -> None: self.recordings = Recordings(self) self.apps = apps.expected() + async def app_paths(self) -> list[str]: + """The paths under the session user's home that the computer's apps keep their data in.""" + return await apps.owned_paths(self.apps) if self.apps else [] + def has(self, capability: str) -> bool: if capability in GROUPS: return all(op in self.operations for op in GROUPS[capability]) diff --git a/packages/agentenv-protocol/tests/test_computer_app_server.py b/packages/agentenv-protocol/tests/test_computer_app_server.py index 752afbbdf..7bab92747 100644 --- a/packages/agentenv-protocol/tests/test_computer_app_server.py +++ b/packages/agentenv-protocol/tests/test_computer_app_server.py @@ -1,5 +1,6 @@ """A computer app's own server: it adds the app's launcher, launches a boot app once per boot and says whether the app -is ready (``computer-app/v1``); its computer's driver finds it on the socket in the app's folder and asks it.""" +is ready (``computer-app/v1``); its computer's driver finds it on the socket in the app's folder and asks it, lists it +as a child environment and forwards its requests to it, and leaves the paths it owns out of its own data/get.""" from __future__ import annotations import asyncio @@ -19,6 +20,8 @@ pytest.importorskip("mcp.server.fastmcp") uvicorn = pytest.importorskip("uvicorn") +import base64 # noqa: E402 + import httpx # noqa: E402 import agentenv_protocol.computer.launch as launcher # noqa: E402 @@ -28,6 +31,7 @@ from agentenv_protocol.computer import app, app_runtime, apps, session # noqa: E402 from agentenv_protocol.computer.app import COMPUTER_APP, FAILURE, SOCKET # noqa: E402 from agentenv_protocol.computer.platform import Platform # noqa: E402 +from agentenv_protocol.types import DATA_OBJECTS_EXTENSION_URI, WELL_KNOWN_PATH # noqa: E402 @@ -268,6 +272,43 @@ async def settled(self): assert status["ready"] is True and took < 2.5 +@linux(paths=["Mail"]) +class Inbox(ComputerAppEnvironment): + pass + + +@pytest.mark.asyncio +async def test_the_driver_lists_its_apps_as_children_forwards_each_ones_requests_and_leaves_their_paths_out( + tmp_path, monkeypatch): + from computer_fake import FakeDriver, http_client + + monkeypatch.setenv("HOME", str(tmp_path)) + for path in ("Mail/inbox.txt", "Documents/note.txt"): + (tmp_path / path).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / path).write_text(path) + get = {"jsonrpc": "2.0", "id": 1, "method": "data/get", "params": {}} + with tempfile.TemporaryDirectory(dir="/tmp", prefix="apps-") as root: + monkeypatch.setattr(apps, "APPS", Path(root)) + (Path(root) / "music").mkdir() + async with _serving(Inbox(), Path(root) / "mail", monkeypatch): + async with http_client(FakeDriver()) as http: + card = (await http.get(WELL_KNOWN_PATH)).json() + mail = client.find_child(card, "mail") + forwarded = (await http.post(mail["url"], json=get)).json()["result"]["parts"] + unknown = await http.post("/svc/notes/agentenv", json=get) + own = (await http.post("/agentenv", json={**get, "params": {"paths": ["Documents", "Mail"]}})).json() + + assert [(child["name"], child["url"]) for child in card["children_environments"]] == [ + ("mail", "/svc/mail/agentenv"), ("music", "/svc/music/agentenv")] + assert client.extension_params(mail, COMPUTER_APP)["paths"] == ["Mail"] + assert client.find_extension_method(mail, COMPUTER_APP, "ready")["endpoint"].startswith("/svc/mail/") + assert [(part["metadata"][DATA_OBJECTS_EXTENSION_URI]["path"], base64.b64decode(part["file"]["bytes"])) + for part in forwarded] == [("Mail/inbox.txt", b"Mail/inbox.txt")] + assert unknown.status_code == 404 + assert [part["metadata"][DATA_OBJECTS_EXTENSION_URI]["path"] for part in own["result"]["parts"]] == [ + "Documents/note.txt"] + + @pytest.mark.asyncio async def test_the_session_launches_an_app_in_a_session_of_its_own_from_its_folder(tmp_path, monkeypatch): monkeypatch.setenv("DISPLAY", ":99") From a8d35f0422fe163aa85dfda8a9616e2fa1c004b6 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 31 Oct 2026 19:12:56 -0700 Subject: [PATCH 03/16] feat(computer): loads, snapshots and skills reach each of a computer's apps by name A load sends each environment to the data plane its name reaches: the computer's own to the driver, an app's to the app's route. A computer's snapshot keeps its files beside each app's export, with data/get params given per environment, and leaves out an app that keeps nothing yet. add_skills installs the computer's skill and each app's. Gate 1 refuses apps whose paths overlap and an app named like its computer; gate 3's probe asks each app for its export once; boot records the card read after ready, with every app on it. Co-Authored-By: Claude Opus 5.5 --- .../computer/conformance/checks.py | 16 ++++- .../computer/conformance/client.py | 5 +- .../tests/test_computer_conformance.py | 25 ++++++- src/agent_env/bundle/plan.py | 6 +- src/agent_env/env/env.py | 12 ++-- src/agent_env/env/envs/computer/app_env.py | 3 +- src/agent_env/env/envs/computer/env.py | 68 ++++++++++++------ src/agent_env/env/envs/computer/utils/boot.py | 7 +- src/agent_env/env/envs/computer/utils/gate.py | 36 +++++++--- .../env/envs/computer/utils/inside.py | 14 ++-- .../task_step/task_steps/add_skills.py | 18 ++--- .../computer/snapshot_computer_env.py | 9 +-- .../task_step/task_steps/snapshot_env.py | 44 ++++++++---- .../app/artifacts/desk_seed/Desktop/todo.txt | 1 + .../artifacts/desk_seed/Documents/seed.txt | 1 + .../app/artifacts/desk_seed/artifact.toml | 2 + .../app/artifacts/notes_seed/Notes/ideas.md | 1 + .../app/artifacts/notes_seed/Notes/today.md | 1 + .../app/artifacts/notes_seed/artifact.toml | 2 + .../computer/app/artifacts/reset_apps/run.py | 17 +++++ .../computer/app/artifacts/seed/artifact.toml | 2 + .../app/artifacts/seed/clock/alarms.json | 1 + .../computer/app/envs/clock/app.py | 22 +++++- .../computer/app/envs/notes/app.py | 9 ++- tst/integration/computer/app/tasks/data.json | 9 +++ tst/integration/computer/computer_app_test.py | 40 ++++++++++- tst/unit/bundle/computer_env_folder_test.py | 21 ++++++ tst/unit/computer_home.py | 5 +- tst/unit/env/envs/_computer.py | 11 +-- tst/unit/env/envs/test_computer_apps.py | 8 ++- tst/unit/env/envs/test_computer_env.py | 71 ++++++++++++++----- tst/unit/env/test_env.py | 7 +- tst/unit/task_step/add_skills_env_test.py | 21 +++++- .../task_step/snapshot_computer_env_test.py | 36 ++++++++-- .../snapshot_env_without_gateway_test.py | 30 ++++++-- 35 files changed, 453 insertions(+), 128 deletions(-) create mode 100644 tst/integration/computer/app/artifacts/desk_seed/Desktop/todo.txt create mode 100644 tst/integration/computer/app/artifacts/desk_seed/Documents/seed.txt create mode 100644 tst/integration/computer/app/artifacts/desk_seed/artifact.toml create mode 100644 tst/integration/computer/app/artifacts/notes_seed/Notes/ideas.md create mode 100644 tst/integration/computer/app/artifacts/notes_seed/Notes/today.md create mode 100644 tst/integration/computer/app/artifacts/notes_seed/artifact.toml create mode 100644 tst/integration/computer/app/artifacts/reset_apps/run.py create mode 100644 tst/integration/computer/app/artifacts/seed/artifact.toml create mode 100644 tst/integration/computer/app/artifacts/seed/clock/alarms.json create mode 100644 tst/integration/computer/app/tasks/data.json diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py index a13bff5e9..bf9f4b50d 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py @@ -87,8 +87,8 @@ def __init__(self, driver: Driver, session: ClientSession, ready_timeout_s: floa async def basic(self) -> None: for check, section in (("P0", self._ready), ("P1", self._card), ("P2", self._frame), ("P5", self._refusals), ("X", self._display), ("X", self._spaces), ("X", self._keyboard_settings), - ("X", self._exec), ("X", self._data_get), ("X", self._tool_access), - ("X", self._pointer), ("X", self._skill)): + ("X", self._exec), ("X", self._data_get), ("X", self._app_exports), + ("X", self._tool_access), ("X", self._pointer), ("X", self._skill)): await self._guarded(check, section) self._grants() @@ -410,6 +410,18 @@ async def get() -> str | None: await self.check("X", "data/get answers its parts", get, *DATA_PLANE) + async def _app_exports(self) -> None: + for app in self.driver.card.get("children_environments") or []: + if "data/get" not in ((app.get("capabilities") or {}).get("operations") or []): + continue + + async def export(app: dict = app) -> str | None: + got = await self.driver.rpc("data/get", card=app) + return None if isinstance((got.get("result") or {}).get("parts"), list) else f"it answered {got}" + + await self.check("X", f"app {app.get('name')}'s data/get answers its parts through the driver", export, + *DATA_PLANE) + async def _tool_access(self) -> None: if not (self.driver.declares(DISABLE_TOOL_EXTENSION_URI) and self.driver.declares(ENABLE_TOOL_EXTENSION_URI)): return diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/client.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/client.py index 4a71ebd8b..a941e4f52 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/client.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/client.py @@ -61,9 +61,10 @@ async def invoke(self, uri: str, method: str, params: dict | None = None) -> Any return await protocol.invoke_extension(self.url, self.card, uri, params or {}, method=method, timeout=int(self.timeout_s)) - async def rpc(self, method: str, params: dict | None = None) -> dict: + async def rpc(self, method: str, params: dict | None = None, card: dict | None = None) -> dict: + """``method``'s answer from the data plane of ``card``, the driver's own or a child's it fronts.""" async with httpx.AsyncClient(timeout=self.timeout_s) as http: - response = await http.post(self.url + (self.card.get("url") or "/agentenv"), + response = await http.post(self.url + ((card or self.card).get("url") or "/agentenv"), json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}) return response.json() diff --git a/packages/agentenv-protocol/tests/test_computer_conformance.py b/packages/agentenv-protocol/tests/test_computer_conformance.py index 5f14c3cdd..154738a95 100644 --- a/packages/agentenv-protocol/tests/test_computer_conformance.py +++ b/packages/agentenv-protocol/tests/test_computer_conformance.py @@ -1,6 +1,6 @@ """The conformance probe against fake drivers on loopback: the fake driver with the fake fixture passes every check; a -driver without what full needs gets those checks unverified; a driver broken one way fails the check for that way; -the report's verdicts and the CLI.""" +driver without what full needs gets those checks unverified; a driver broken one way fails the check for that way; each +app a driver fronts that exports is asked for its export once; the report's verdicts and the CLI.""" from __future__ import annotations import json @@ -34,6 +34,7 @@ from agentenv_protocol.computer.conformance.client import Driver # noqa: E402 from agentenv_protocol.computer.conformance.__main__ import main # noqa: E402 from agentenv_protocol.tool_access import DISABLE_TOOL_EXTENSION_URI # noqa: E402 +from agentenv_protocol.types import EnvironmentCard # noqa: E402 @pytest.fixture(autouse=True) @@ -97,6 +98,26 @@ async def test_basic_needs_no_fixture(home): assert not driver.desk.started and not (home / ".agentenv-probe").exists() +class FrontsApps(FixtureDriver): + """Fronts mail, whose card routes to the driver's own data plane, notes, which no app answers, and quiet, which + doesn't export.""" + + async def children_environments(self): + return [EnvironmentCard(name=name, url=url, capabilities={"operations": operations}) + for name, url, operations in (("mail", "/agentenv", ["data/get"]), + ("notes", "/svc/notes/agentenv", ["data/get"]), + ("quiet", "/svc/quiet/agentenv", ["data/add"]))] + + +@pytest.mark.asyncio +async def test_each_app_a_driver_fronts_that_exports_is_asked_for_its_export_once(home): + report = await _probe(FrontsApps(), "basic") + + assert [(c.name, c.status) for c in report.checks if c.name.startswith("app ")] == [ + ("app mail's data/get answers its parts through the driver", "passed"), + ("app notes's data/get answers its parts through the driver", "failed")] + + @pytest.mark.asyncio async def test_a_driver_without_exec_or_data_add_passes_basic_and_leaves_full_unverified(): report = await _probe(MinimalDriver(FakeScreen(640, 400), platform=Platform(os="macos"))) diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 0a8d867f3..a2a833574 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -968,8 +968,8 @@ def _check_loaded_environments(self, closure: set[_Key], "seeds, or leave it out") def _server_names(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int] | None) -> set[str] | None: - """The environment_names of the servers the env ``ref`` names runs: an MCP server's or a website's own, or - each of a multi's envs'. None when they aren't all known.""" + """The environment_names of the servers the env ``ref`` names runs: an MCP server's or a website's own, each + of a multi's envs', or a computer's own and each of its apps'. None when they aren't all known.""" if isinstance(ref.local, BundleEntry): cls = get_env_registry().get(ref.local.type) if cls is not None and issubclass(cls, MultiEnv): @@ -987,6 +987,8 @@ def _server_names(self, ref: Reference, store_latest: dict[tuple[EntityKind, str return None # reported by the store ref check if isinstance(env, MultiEnv): return {child.environment_name for child in [*env.mcp_server_envs, *env.website_envs]} + if isinstance(env, ComputerEnv): + return {env.environment_name, *(app.environment_name for app in env.composed_apps())} name = getattr(env, "environment_name", None) return {name} if name else None diff --git a/src/agent_env/env/env.py b/src/agent_env/env/env.py index c92199f7f..059607c87 100644 --- a/src/agent_env/env/env.py +++ b/src/agent_env/env/env.py @@ -119,11 +119,15 @@ def require(self, uri: str, method: str) -> None: raise EnvCapabilityUnsupported(uri, method, self.env_id, self.instance_id) def get_child_env_card(self, environment_name: str) -> Optional[dict]: - """The stored card's child env named `environment_name`; a leaf card is its own only child env.""" + """The stored card's child env named `environment_name`; a leaf card is its own only child env, and a composed + card serving a data plane of its own, as a computer's driver fronting its apps does, is the one its own name + reaches.""" card = self.environment_card or {} - if not isinstance(card.get("children_environments"), list): - return card if card.get("name") == environment_name else None - return protocol_v1.find_child(card, environment_name) + composed = isinstance(card.get("children_environments"), list) + own = not composed or (card.get("capabilities") or {}).get("operations") + if own and card.get("name") == environment_name: + return card + return protocol_v1.find_child(card, environment_name) if composed else None async def invoke(self, uri: str, method: str, params: Optional[dict] = None, *, environment_name: Optional[str] = None, timeout: int = _EXTENSION_CALL_TIMEOUT_S) -> Any: diff --git a/src/agent_env/env/envs/computer/app_env.py b/src/agent_env/env/envs/computer/app_env.py index f9d9035fe..05499e0b8 100644 --- a/src/agent_env/env/envs/computer/app_env.py +++ b/src/agent_env/env/envs/computer/app_env.py @@ -114,7 +114,8 @@ def put(cls, *, id: str, environment_name: str, module: str, files: FileArtifact if (computer is None) != (driver is None): problems.append("a computer app deploys alone on a computer and a driver; name both or neither") elif computer is not None and not problems: - _, refused = gate.check(computer, driver, apps=[], display=None, network=None, computer_settings={}) + _, refused = gate.check(computer, driver, environment_name=environment_name, apps=[], display=None, + network=None, computer_settings={}) problems += refused + gate.check_app(computer, driver, app) if problems: raise ValueError(f"computer app env {id!r}: " + "; ".join(problems)) diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index 9270f1655..6c33edf2c 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -8,6 +8,8 @@ import posixpath from typing import TYPE_CHECKING, Any, ClassVar, Optional, Self +from agentenv_protocol import METHOD_ADD, RPC_PATH + from agent_env.artifact.artifacts.computer import ComputerArtifact, SettingValue from agent_env.attribution import Attribution from agent_env.entity.refs import EntityRef @@ -95,8 +97,8 @@ def put(cls, *, id: str, computer: ComputerArtifact, driver: ComputerDriverEnv, order, once gate 1 passes, then compose it as ``composing`` says unless ``compose`` is False. Refused, naming every mismatch, before anything is stored.""" apps = list(apps) - values, problems = gate.check(computer, driver, apps=apps, display=display, network=network, - computer_settings=computer_settings or {}) + values, problems = gate.check(computer, driver, environment_name=environment_name, apps=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( @@ -208,32 +210,25 @@ async def load_file_artifact_universe(self, file_artifact_universe: Any, return LoadFileArtifactUniverseResult(destination_path=destination, files=paths) async def load_environment_artifact(self, environment_artifact: EnvironmentArtifact) -> None: - """Write the files of ``environment_artifact``, the computer's own, at their paths relative to the session - user's home, through the driver's ``data/add``, as ``snapshot_computer_env`` kept them. Other files there - stay.""" - if environment_artifact.environment_name != self.environment_name: - raise ValueError(f"EnvironmentArtifact environment_name '{environment_artifact.environment_name}' does not " - f"match env environment_name '{self.environment_name}'") - files = environment_artifact.get_file_artifacts() - for path in files: - validate_relative_filename(path) - await inside.add_artifacts(self._deployed, files) - logger.info(f"Loaded {len(files)} file(s) of '{environment_artifact.id}' into {self.id}'s home") + """Write the files of ``environment_artifact`` at their paths through the data plane its name reaches: the + computer's own relative to the session user's home, through the driver's ``data/add``, as + ``snapshot_computer_env`` kept them; an app's through the app's, which the driver fronts. Other files stay.""" + await self._load([environment_artifact]) async def load_environment_universe_artifact(self, environment_universe_artifact: EnvironmentUniverseArtifact) -> None: - """Load the universe's environment named after the computer, ``load_environment_artifact``; any other is left - out, with a warning.""" + """Load the universe's environment named after the computer, then each named after one of its apps, so what an + app keeps ends as its own export has it; any other is left out, with a warning.""" environments = environment_universe_artifact.get_environment_artifacts() - own = [environment for environment in environments if environment.environment_name == self.environment_name] - if not own: + names = self._environment_names() + if not (known := [environment for environment in environments if environment.environment_name in names]): raise RuntimeError(f"computer env '{self.id}' can't load '{environment_universe_artifact.id}': it has no " - f"environment named '{self.environment_name}' (it has " + f"environment named any of {names} (it has " f"{[environment.environment_name for environment in environments]})") if others := [environment.environment_name for environment in environments - if environment.environment_name != self.environment_name]: - logger.warning(f"[{self.id}] loading only '{self.environment_name}' from '{environment_universe_artifact.id}'; " - f"leaving out {others}") - await self.load_environment_artifact(own[0]) + if environment.environment_name not in names]: + logger.warning(f"[{self.id}] loading only {[environment.environment_name for environment in known]} from " + f"'{environment_universe_artifact.id}'; leaving out {others}") + await self._load(sorted(known, key=lambda environment: environment.environment_name != self.environment_name)) async def reset(self, deployed: DeployedEnv) -> None: """Return the computer to boot state: its recordings ended, its container replaced with a fresh one of the same @@ -258,6 +253,35 @@ async def close(self) -> None: logger.warning(f"Failed to terminate sandbox {self._sandbox.sandbox_id}: {e}") self._sandbox = None + async def _load(self, environments: list[EnvironmentArtifact]) -> None: + """Write each environment's files through the data plane its name reaches, every one checked before any is + sent.""" + loads = [(environment, self._data_plane(environment.environment_name), environment.get_file_artifacts()) + for environment in environments] + for _, _, files in loads: + for path in files: + validate_relative_filename(path) + for environment, url, files in loads: + await inside.add_artifacts(self._deployed, files, url) + logger.info(f"Loaded {len(files)} file(s) of '{environment.id}' into {self.id}'s " + f"'{environment.environment_name}'") + + def _data_plane(self, environment_name: str) -> str: + """The address of the data plane ``environment_name`` reaches in the deployed computer, as its card says: the + driver's for the computer's own, an app's route for an app's.""" + card = self._deployed.get_child_env_card(environment_name) + if card is None: + raise ValueError(f"computer env '{self.id}' has no environment named '{environment_name}': it has " + f"{self._environment_names()}") + if METHOD_ADD not in ((card.get("capabilities") or {}).get("operations") or []): + raise ValueError(f"'{environment_name}' in computer env '{self.id}' serves no data/add, so nothing loads " + "into it") + return self._deployed.environment_url + card.get("url", RPC_PATH).removesuffix(RPC_PATH) + + def _environment_names(self) -> list[str]: + """The environments a load reaches by name: the computer's own, then its apps'.""" + return list(dict.fromkeys([self.environment_name, *(app.environment_name for app in self.composed_apps())])) + def _app(env: Env) -> ComputerAppEnv: if refusal := _not_an_app(env): diff --git a/src/agent_env/env/envs/computer/utils/boot.py b/src/agent_env/env/envs/computer/utils/boot.py index 5e5b2bbe0..70b4ea55c 100644 --- a/src/agent_env/env/envs/computer/utils/boot.py +++ b/src/agent_env/env/envs/computer/utils/boot.py @@ -125,8 +125,9 @@ def ready_timeout(apps: list[ComputerAppEnv], os_type: str) -> float: async def ready(url: str, timeout: float) -> dict: - """The card of the driver at ``url`` once its ``computer-ready`` status says it's ready; raises TimeoutError, naming - what it last saw, when it isn't within ``timeout`` seconds.""" + """The card of the driver at ``url``, read once its ``computer-ready`` status says it's ready, so each app it fronts + is on it as the app's own server serves it; raises TimeoutError, naming what it last saw, when it isn't within + ``timeout`` seconds.""" last = "nothing answered" try: async with asyncio.timeout(timeout): @@ -136,7 +137,7 @@ async def ready(url: str, timeout: float) -> dict: status = await protocol_v1.invoke_extension(url, card, COMPUTER_READY, None, _STATUS_TIMEOUT_S, method="status") if status.get("ready"): - return card + return await protocol_v1.get_card(url, timeout=5) last = "not ready: " + "; ".join(f"{check['name']}: {check.get('detail')}" for check in status.get("checks", []) if not check.get("ok")) except Exception as e: diff --git a/src/agent_env/env/envs/computer/utils/gate.py b/src/agent_env/env/envs/computer/utils/gate.py index 5ec5cf8f2..b0b0a3126 100644 --- a/src/agent_env/env/envs/computer/utils/gate.py +++ b/src/agent_env/env/envs/computer/utils/gate.py @@ -3,11 +3,12 @@ from __future__ import annotations +import itertools +import re from collections.abc import Sequence +from pathlib import PurePosixPath from typing import TYPE_CHECKING, Any -import re - from agentenv_protocol.computer.apps import APPS from agentenv_protocol.computer.platform import Platform, supported from jsonschema import Draft202012Validator @@ -25,11 +26,11 @@ MAX_SIDE = 32767 -def check(base: ComputerArtifact, driver: ComputerDriverEnv, *, apps: list, display: Any, network: Any, - computer_settings: Any) -> tuple[dict[str, SettingValue], list[str]]: - """The env's values of the base's settings, and every way ``base``, ``driver`` and what the env declares don't - compose.""" - problems = _platform(base, driver) + _apps(base, driver, apps) +def check(base: ComputerArtifact, driver: ComputerDriverEnv, *, environment_name: str, apps: list, display: Any, + network: Any, computer_settings: Any) -> tuple[dict[str, SettingValue], list[str]]: + """The env's values of the base's settings, and every way ``base``, ``driver`` and what the env declares, named + ``environment_name``, don't compose.""" + problems = _platform(base, driver) + _apps(base, driver, apps, environment_name) problems += _image(base) problems += _display(base, display, computer_settings) problems += _network(network) @@ -80,16 +81,20 @@ def app_folder(app: ComputerAppEnv) -> str: return folder_name(app.environment_name, "app") -def _apps(base: ComputerArtifact, driver: ComputerDriverEnv, apps: list[ComputerAppEnv]) -> list[str]: - """Every way ``apps`` don't fit together on ``base`` and ``driver``: each one's own (``check_app``), no architecture - they all run, one listed twice, and two under one environment name, in one folder, with one program name or - listening on one port, each pair of apps named for the first they'd share.""" +def _apps(base: ComputerArtifact, driver: ComputerDriverEnv, apps: list[ComputerAppEnv], + environment_name: str) -> list[str]: + """Every way ``apps`` don't fit together on ``base`` and ``driver`` in a computer env named ``environment_name``: + each one's own (``check_app``), no architecture they all run, one listed twice, one named as the computer is, two + under one environment name, in one folder, with one program name or listening on one port, each pair of apps named + for the first they'd share, and two keeping their data in one path or one inside the other.""" problems = [problem for i, app in enumerate(apps) for problem in check_app(base, driver, app, f"apps[{i}]")] if not problems and apps and not arches(base, driver, apps): problems.append(f"apps: no architecture {base.id}, {driver.id} and every app all run") ids = [app.id for app in apps] problems += [f"apps: {app_id} is listed twice" for app_id in sorted({i for i in ids if ids.count(i) > 1})] distinct, named = list({app.id: app for app in apps}.values()), set() + problems += [f"apps: {app.id} is named {environment_name!r}, as the computer env is, which names the computer's " + "own files" for app in distinct if app.environment_name == environment_name] for kind, keys in (("environment name", lambda app: [app.environment_name]), ("folder", lambda app: [f"{APPS}/{app_folder(app)}"]), ("program", lambda app: programs(app, base.os.type)), @@ -102,6 +107,10 @@ def _apps(base: ComputerArtifact, driver: ComputerDriverEnv, apps: list[Computer if len(owners) > 1 and frozenset(owners) not in named: named.add(frozenset(owners)) problems.append(f"apps: {' and '.join(owners)} would share the {kind} {key!r}") + owned = [(app, PurePosixPath(path)) for app in distinct for path in _paths(app, base.os.type)] + problems += [f"apps: {one.id} keeps its data in {str(a)!r} and {other.id} in {str(b)!r}, so they'd share it" + for (one, a), (other, b) in itertools.combinations(owned, 2) + if one is not other and (a.is_relative_to(b) or b.is_relative_to(a))] return problems @@ -112,6 +121,11 @@ def programs(app: ComputerAppEnv, os_type: str) -> list[str]: return [server, *(f"{server}-{service.name}" for service in (install.services if install is not None else []))] +def _paths(app: ComputerAppEnv, os_type: str) -> list[str]: + declared = app.declaration.os.get(os_type) + return list(declared.paths or []) if declared is not None else [] + + def _ports(app: ComputerAppEnv, os_type: str) -> list[int]: install = app.install.get(os_type) return list(install.ports or []) if install is not None else [] diff --git a/src/agent_env/env/envs/computer/utils/inside.py b/src/agent_env/env/envs/computer/utils/inside.py index f1033e23c..2d5eaa6ce 100644 --- a/src/agent_env/env/envs/computer/utils/inside.py +++ b/src/agent_env/env/envs/computer/utils/inside.py @@ -52,8 +52,10 @@ def loopback(deployed: DeployedSandboxEnv) -> dict: return {"id": deployed.env_id, "url": url, "mcp_url": url + deployed.mcp_url.removeprefix(deployed.environment_url)} -async def add_artifacts(deployed: DeployedSandboxEnv, files: dict[str, FileArtifact]) -> None: - """Write each file artifact at its path.""" +async def add_artifacts(deployed: DeployedSandboxEnv, files: dict[str, FileArtifact], + url: Optional[str] = None) -> None: + """Write each file artifact at its path, through the data plane at ``url``, the driver's or an app's it fronts; the + driver's by default.""" network = (deployed.metadata or {}).get("computer", {}).get("network") granted = not (network and NetworkPolicy.from_dict(network).restricts_egress) batch: list[FilePart] = [] @@ -61,12 +63,12 @@ async def add_artifacts(deployed: DeployedSandboxEnv, files: dict[str, FileArtif for path, artifact in files.items(): part, part_size = await asyncio.to_thread(_part, path, artifact, deployed.sandbox_type, granted) if batch and size + part_size > BATCH_BYTES: - await _add(deployed, batch, size) + await _add(deployed, batch, size, url) batch, size = [], 0 batch.append(part) size += part_size if batch: - await _add(deployed, batch, size) + await _add(deployed, batch, size, url) async def add_files(deployed: DeployedSandboxEnv, files: dict[str, bytes]) -> None: @@ -98,9 +100,9 @@ async def run(deployed: DeployedSandboxEnv, argv: list[str], *, timeout_s: float raise _broken(deployed, "computer-exec/v1 run", e) from e -async def _add(deployed: DeployedSandboxEnv, parts: list[FilePart], size: int) -> None: +async def _add(deployed: DeployedSandboxEnv, parts: list[FilePart], size: int, url: Optional[str] = None) -> None: try: - await protocol_v1.add_data(deployed.environment_url, parts, timeout=data_plane_load_timeout_s(size)) + await protocol_v1.add_data(url or deployed.environment_url, parts, timeout=data_plane_load_timeout_s(size)) except httpx.TransportError as e: raise _broken(deployed, "data/add, so some of the files it carried may be written, each whole", e) from e diff --git a/src/agent_env/task_step/task_steps/add_skills.py b/src/agent_env/task_step/task_steps/add_skills.py index ccb16b8a9..1c9505431 100644 --- a/src/agent_env/task_step/task_steps/add_skills.py +++ b/src/agent_env/task_step/task_steps/add_skills.py @@ -326,9 +326,9 @@ def _stored_skill(skill_md: str, files: dict[str, str]) -> str: async def _env_skills(context: TaskStepContext, agent: DeployedAgent, env_id: str) -> list[dict]: - """The skills the env ``env_id`` serves, each under its card's environment name: its own card's, or else each of its - child envs' (a gateway's servers); refused for an env this run didn't deploy or bind to ``agent``, or that serves - none.""" + """The skills the env ``env_id`` serves, each under its card's environment name: its own card's and each of its + child envs' (a gateway's servers, a computer's apps) its name reaches; refused for an env this run didn't deploy or + bind to ``agent``, or that serves none.""" deployed = next((d for d in context.deployed_envs if d.env_id == env_id), None) if deployed is None: raise RuntimeError(f"env '{env_id}' isn't deployed in this run") @@ -336,12 +336,12 @@ async def _env_skills(context: TaskStepContext, agent: DeployedAgent, env_id: st raise RuntimeError(f"env '{env_id}' isn't bound to agent '{agent.agent_name}': name it in the env_ids of its " "deploy_agent") card = deployed.environment_card or {} - if find_extension_method(card, SKILL_EXTENSION_URI, "get") is not None: - serving = [(None, _env_skill_name(card.get("name") or env_id, env_id))] - else: - serving = [(child["name"], _env_skill_name(child["name"], f"{env_id}/{child['name']}")) - for child in card.get("children_environments") or [] - if find_extension_method(child, SKILL_EXTENSION_URI, "get") is not None] + serving = [(None, _env_skill_name(card.get("name") or env_id, env_id))] if find_extension_method( + card, SKILL_EXTENSION_URI, "get") is not None else [] + serving += [(child["name"], _env_skill_name(child["name"], f"{env_id}/{child['name']}")) + for child in card.get("children_environments") or [] + if find_extension_method(child, SKILL_EXTENSION_URI, "get") is not None + and deployed.get_child_env_card(child["name"]) is child] if not serving: raise RuntimeError(f"env '{env_id}' serves no skill: neither its card nor a child env's declares " f"{SKILL_EXTENSION_URI}") diff --git a/src/agent_env/task_step/task_steps/computer/snapshot_computer_env.py b/src/agent_env/task_step/task_steps/computer/snapshot_computer_env.py index f28c08bb3..ffa6489c1 100644 --- a/src/agent_env/task_step/task_steps/computer/snapshot_computer_env.py +++ b/src/agent_env/task_step/task_steps/computer/snapshot_computer_env.py @@ -1,5 +1,5 @@ -"""snapshot_computer_env: a computer env's files, kept by their paths in its environment artifact, which load_artifact -loads back into a computer.""" +"""snapshot_computer_env: a computer env's files, kept by their paths in its environment artifact, and each of its apps' +exports in theirs, which load_artifact loads back into a computer.""" from __future__ import annotations @@ -24,7 +24,8 @@ class SnapshotComputerEnvTaskStep(SnapshotEnvTaskStep): """Snapshot a deployed computer env's files into a new environment universe, as ``snapshot_env`` snapshots an env, each file under its path relative to the session user's home in the computer's environment artifact: those under - ``file_paths``, by default the folders its card declares. ``load_artifact`` loads the snapshot back into a + ``file_paths``, by default the folders its card declares, leaving out each app's paths. Each app that exports is + kept beside it in an environment artifact under the app's name. ``load_artifact`` loads the snapshot back into a computer, through ``data/add``. Output: ``context.metadata["env_snapshotted_universes"][self.id]`` = ``{"id", "version"}``.""" @@ -97,7 +98,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: result = await self.snapshot_env_state( env=env, gateway_url=None, snapshot_id=snapshot_id, deployed=deployed, export_timeout_seconds=self.export_timeout_seconds, - data_get_params=None if self.file_paths is None else {"paths": self.file_paths}) + data_get_params=None if self.file_paths is None else {env.environment_name: {"paths": self.file_paths}}) context.metadata.setdefault("env_snapshotted_universes", {})[self.id] = { "id": result.environment_universe_artifact_id, "version": result.environment_universe_artifact_version} return context diff --git a/src/agent_env/task_step/task_steps/snapshot_env.py b/src/agent_env/task_step/task_steps/snapshot_env.py index 6bd8d2055..a1748adb7 100644 --- a/src/agent_env/task_step/task_steps/snapshot_env.py +++ b/src/agent_env/task_step/task_steps/snapshot_env.py @@ -18,7 +18,7 @@ import httpx -from agentenv_protocol import DATA_OBJECTS_EXTENSION_URI, FilePart, uploaded_object_path +from agentenv_protocol import DATA_OBJECTS_EXTENSION_URI, METHOD_GET, FilePart, uploaded_object_path from agentenv_protocol import client as protocol_v1 from agentenv_protocol.client import GetDataResponse, find_extension, find_extension_method, intake_declaration from agentenv_protocol.transfers import WriteNamespaceGrant @@ -169,8 +169,10 @@ class SnapshotEnvTaskStep(TaskStep): ``include_env_changes`` also stores who changed what (urn:agentenv:changes/v1) under ``metadata["env_changes"]``. An env deployed without a gateway is reached through the card its deployment recorded, its environments the - card's children, or itself. An environment whose data/get returns a file tree, a computer's, is kept file by file, - each by its path, in its environment artifact. + card's children, and itself first when it exports beside them, as a computer fronting its apps does, or itself + alone. An + environment whose data/get returns a file tree, a computer's, is kept file by file, each by its path, in its + environment artifact. """ type: ClassVar[str] = "snapshot_env" @@ -588,13 +590,14 @@ async def snapshot_env_state( original_universe_artifact_id: Optional[str] = None, export_timeout_seconds: int = DEFAULT_EXPORT_TIMEOUT_SECONDS, concurrency: int = DEFAULT_EXPORT_CONCURRENCY, - data_get_params: Optional[dict] = None, + data_get_params: Optional[dict[str, dict]] = None, ) -> EnvSnapshotResult: """Export all services via the gateway, or without one (``gateway_url`` None) through ``deployed``'s recorded card, into a new EnvironmentUniverseArtifact ``snapshot_id``, - handing each service's data/get ``data_get_params``. + handing each service's data/get its ``data_get_params``, by its name. All-or-nothing; the raised error carries public per-service summaries (verbose details - go to the log). + go to the log). A file tree with no files, an app that keeps nothing yet, is left out, + unless every environment's is. A classmethod so callers that have no step instance can reuse it.""" from agent_env.artifact import FileArtifact, EnvironmentArtifact, EnvironmentUniverseArtifact @@ -645,7 +648,7 @@ async def export_one(environment_name: str): try: result = await cls._export_environment_to_file( gateway, environment_name, tmp_path, export_timeout_seconds, - deployed=card_record, data_get_params=data_get_params, + deployed=card_record, data_get_params=(data_get_params or {}).get(environment_name), ) except httpx.HTTPStatusError as e: body_snippet = "" @@ -665,6 +668,8 @@ async def export_one(environment_name: str): try: files = await _file_tree_files(*result, environment_name, f"{snapshot_id}-{environment_name}", f"state snapshot of {environment_name} from env {env.id}") + if not files: + return None return await asyncio.to_thread( EnvironmentArtifact.put, id=f"{snapshot_id}-{environment_name}", environment_name=environment_name, file_artifacts=files) @@ -738,7 +743,7 @@ def _register_uploaded_bundle(): for name, result in zip(services, results): if isinstance(result, tuple): skipped[name] = result - else: + elif result is not None: environment_artifacts.append(result) if skipped: @@ -754,6 +759,10 @@ def _register_uploaded_bundle(): f"{len(skipped)}/{len(services)} services were not exported " f"(missing={sorted(skipped)}): {public_summary}" ) + if empty := [name for name, result in zip(services, results) if result is None]: + if len(empty) == len(services): + raise RuntimeError(f"{', '.join(map(repr, empty))} returned no files to snapshot") + logger.info(f"snapshot_env: {empty} returned no files to keep, so {snapshot_id} leaves them out") universe = await asyncio.to_thread( EnvironmentUniverseArtifact.put, @@ -816,10 +825,7 @@ async def written(part) -> tuple[str, FileArtifact]: return path, await asyncio.to_thread(registered_upload, upload.object_url(path), file_id, filename, content_type, about) - files = dict(await asyncio.gather(*(written(part) for part in resp.parts))) - if not files: - raise ValueError(f"'{environment_name}' returned no files to snapshot") - return files + return dict(await asyncio.gather(*(written(part) for part in resp.parts))) def registered_upload(object_url: str, file_id: str, filename: str, content_type: str, description: str): @@ -836,11 +842,19 @@ def registered_upload(object_url: str, file_id: str, filename: str, content_type def _card_environments(deployed) -> list[str]: - """The environments a deployment without a gateway serves, as the card it recorded names them: its children, or - the env itself.""" + """The environments a deployment without a gateway serves, as the card it recorded names them: the env itself when + it has no children; else each child its name reaches that exports, and the env too, first, when it exports beside + them, as a computer fronting its apps does.""" card = deployed.environment_card or {} children = card.get("children_environments") - names = [child.get("name") for child in children] if isinstance(children, list) else [card.get("name")] + if not isinstance(children, list): + names = [card.get("name")] + else: + names = [child.get("name") for child in children if deployed.get_child_env_card(child.get("name")) is child + and ((operations := (child.get("capabilities") or {}).get("operations")) is None + or METHOD_GET in operations)] + if METHOD_GET in ((card.get("capabilities") or {}).get("operations") or []): + names.insert(0, card.get("name")) if not all(names): raise RuntimeError(f"Deployed env '{deployed.env_id}' recorded a card naming no environment to snapshot") return names diff --git a/tst/integration/computer/app/artifacts/desk_seed/Desktop/todo.txt b/tst/integration/computer/app/artifacts/desk_seed/Desktop/todo.txt new file mode 100644 index 000000000..b9feddc5f --- /dev/null +++ b/tst/integration/computer/app/artifacts/desk_seed/Desktop/todo.txt @@ -0,0 +1 @@ +water the plants diff --git a/tst/integration/computer/app/artifacts/desk_seed/Documents/seed.txt b/tst/integration/computer/app/artifacts/desk_seed/Documents/seed.txt new file mode 100644 index 000000000..c19e887d6 --- /dev/null +++ b/tst/integration/computer/app/artifacts/desk_seed/Documents/seed.txt @@ -0,0 +1 @@ +seeded diff --git a/tst/integration/computer/app/artifacts/desk_seed/artifact.toml b/tst/integration/computer/app/artifacts/desk_seed/artifact.toml new file mode 100644 index 000000000..10e9b04f5 --- /dev/null +++ b/tst/integration/computer/app/artifacts/desk_seed/artifact.toml @@ -0,0 +1,2 @@ +type = "environment" +environment_name = "desk" diff --git a/tst/integration/computer/app/artifacts/notes_seed/Notes/ideas.md b/tst/integration/computer/app/artifacts/notes_seed/Notes/ideas.md new file mode 100644 index 000000000..a8707ac1e --- /dev/null +++ b/tst/integration/computer/app/artifacts/notes_seed/Notes/ideas.md @@ -0,0 +1 @@ +# Ideas diff --git a/tst/integration/computer/app/artifacts/notes_seed/Notes/today.md b/tst/integration/computer/app/artifacts/notes_seed/Notes/today.md new file mode 100644 index 000000000..6af58f06a --- /dev/null +++ b/tst/integration/computer/app/artifacts/notes_seed/Notes/today.md @@ -0,0 +1 @@ +# Today diff --git a/tst/integration/computer/app/artifacts/notes_seed/artifact.toml b/tst/integration/computer/app/artifacts/notes_seed/artifact.toml new file mode 100644 index 000000000..c2da269f8 --- /dev/null +++ b/tst/integration/computer/app/artifacts/notes_seed/artifact.toml @@ -0,0 +1,2 @@ +type = "environment" +environment_name = "notes" diff --git a/tst/integration/computer/app/artifacts/reset_apps/run.py b/tst/integration/computer/app/artifacts/reset_apps/run.py new file mode 100644 index 000000000..fedff288f --- /dev/null +++ b/tst/integration/computer/app/artifacts/reset_apps/run.py @@ -0,0 +1,17 @@ +"""Each app's data/reset, sent from inside the computer through the driver's route to it, and what the apps keep +after: the notes in ~/Notes, and whether the clock's alarms are there.""" +import json +import urllib.request +from pathlib import Path + + +def run(input): + answers = {} + for app in ("notes", "clock"): + request = urllib.request.Request(f"{input['env']['url']}/svc/{app}/agentenv", data=json.dumps( + {"jsonrpc": "2.0", "id": 1, "method": "data/reset", "params": {}}).encode(), + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(request, timeout=60) as response: + answers[app] = "error" not in json.load(response) + return {"reset": answers, "notes": sorted(p.name for p in (Path.home() / "Notes").iterdir()), + "alarms": (Path.home() / ".clock-alarms.json").exists()} diff --git a/tst/integration/computer/app/artifacts/seed/artifact.toml b/tst/integration/computer/app/artifacts/seed/artifact.toml new file mode 100644 index 000000000..dd144e242 --- /dev/null +++ b/tst/integration/computer/app/artifacts/seed/artifact.toml @@ -0,0 +1,2 @@ +type = "environment_universe" +environment_artifacts = ["desk_seed", "notes_seed"] diff --git a/tst/integration/computer/app/artifacts/seed/clock/alarms.json b/tst/integration/computer/app/artifacts/seed/clock/alarms.json new file mode 100644 index 000000000..aa196a675 --- /dev/null +++ b/tst/integration/computer/app/artifacts/seed/clock/alarms.json @@ -0,0 +1 @@ +{"alarms": ["07:00"]} diff --git a/tst/integration/computer/app/envs/clock/app.py b/tst/integration/computer/app/envs/clock/app.py index 0aec9d802..11bd4344c 100644 --- a/tst/integration/computer/app/envs/clock/app.py +++ b/tst/integration/computer/app/envs/clock/app.py @@ -1,11 +1,17 @@ """A clock app for the computer env tests, composed beside notes: its service answers on a port of its own with the time zone its settings name, and writes it to ~/.clock.json. It installs tzdata, which its run script lists the zones -of, and declares no window.""" +of, and declares no window. Its data plane is its own hooks over its alarms, kept in ~/.clock-alarms.json.""" +import json +from pathlib import Path + from pydantic import BaseModel +from agentenv_protocol import DataPart, add_data, get_data, reset_data from agentenv_protocol.computer import ComputerAppEnvironment, Packages, RunScript, Service, computer_app, linux +from agentenv_protocol.computer.dataplane import part_bytes PORT = 8137 +ALARMS = ".clock-alarms.json" @computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) @@ -14,3 +20,17 @@ class Clock(ComputerAppEnvironment): class Settings(BaseModel): zone: str = "UTC" + + @add_data + async def load(self, parts: list) -> None: + for part in parts: + (Path.home() / ALARMS).write_bytes(await part_bytes(part)) + + @get_data + def alarms(self) -> list: + path = Path.home() / ALARMS + return [DataPart(data=json.loads(path.read_text()) if path.exists() else {"alarms": []})] + + @reset_data + def clear(self) -> None: + (Path.home() / ALARMS).unlink(missing_ok=True) diff --git a/tst/integration/computer/app/envs/notes/app.py b/tst/integration/computer/app/envs/notes/app.py index 11933a1b2..67ab9e55a 100644 --- a/tst/integration/computer/app/envs/notes/app.py +++ b/tst/integration/computer/app/envs/notes/app.py @@ -2,8 +2,9 @@ own server launches its window once the desktop session is up; it's ready once both have happened. It requires no Python of its own, so its service and window run in the venv every app gets for its server. Its install takes an apt package from Ubuntu's archive and one only a Launchpad PPA has, which its run script uses, a download checked by sha256, -and an install step run as the session user on the build setting its env.toml fixes. On Linux its settings gain a font, -and its install step and ready check are Linux's.""" +and an install step run as the session user on the build setting its env.toml fixes, which also leaves a welcome note +in ~/Notes, where it keeps its data, so its server serves that folder as its data plane. On Linux its settings gain a +font, and its install step and ready check are Linux's.""" from pathlib import Path from pydantic import BaseModel, SecretStr @@ -28,7 +29,7 @@ class LinuxSettings(BaseModel): services=[Service(name="notes", command="venv/bin/python serve.py")], launch=Launch(at="boot", command=["venv/bin/python", "window.py"], window={"class": "Notes"}, ready=Ready(window=True)), - settings=LinuxSettings) + paths=["Notes"], settings=LinuxSettings) class Notes(ComputerAppEnvironment): skill = "skill.md" @@ -42,6 +43,8 @@ class BuildSettings(BaseModel): @install_step(scope="session", os="linux") def remember(self) -> None: (Path.home() / ".notes-installed").write_text(self.build_settings.edition) + (Path.home() / "Notes").mkdir(exist_ok=True) + (Path.home() / "Notes" / "welcome.md").write_text("# Welcome\n") @ready_check(os="linux") def written(self) -> bool: diff --git a/tst/integration/computer/app/tasks/data.json b/tst/integration/computer/app/tasks/data.json new file mode 100644 index 000000000..167a26bd3 --- /dev/null +++ b/tst/integration/computer/app/tasks/data.json @@ -0,0 +1,9 @@ +[ + {"id": "env", "type": "deploy_env", "env_id": "desk", + "env_params": {"app_settings": {"notes": {"signature": "-- Ada", "token": "env:NOTES_TOKEN"}}}}, + {"id": "load", "type": "load_artifact", "env_id": "desk", "artifact_id": "seed"}, + {"id": "loaded", "type": "snapshot_computer_env", "env_id": "desk", "file_paths": ["Documents", "Desktop", "Notes"]}, + {"id": "reset", "type": "run_code", "env_id": "desk", "script_artifact_id": "reset_apps"}, + {"id": "after_reset", "type": "snapshot_computer_env", "env_id": "desk", + "file_paths": ["Documents", "Desktop", "Notes"]} +] diff --git a/tst/integration/computer/computer_app_test.py b/tst/integration/computer/computer_app_test.py index 6f50fab65..fefb9fb0d 100644 --- a/tst/integration/computer/computer_app_test.py +++ b/tst/integration/computer/computer_app_test.py @@ -5,15 +5,21 @@ one from a PPA, its download, run script and install step on its build setting, fetched at put, installed offline. And two apps composed into one computer env, each running on its own settings from its own folder, the files their run scripts wrote compared before the push of this machine's arch, or the image of the same key reused, as one an earlier -run pushed is. Needs Docker and the local registry on localhost:5000.""" +run pushed is. And the two apps' data planes through the computer: a snapshot loaded into it reaches each app by its +name, notes's paths and clock's hooks, the computer's snapshot keeps each app's export beside its own files, leaving the +apps' paths out of its own, and each app's data/reset, sent through the driver's route to it, puts the app back as +installed. Needs Docker and the local registry on localhost:5000.""" +import json import platform from pathlib import Path import pytest from click.testing import CliRunner +from agent_env.artifact import EnvironmentUniverseArtifact from agent_env.cli import cli +from agent_env.store.routing import namespace_routing from tst.integration.computer.conftest import run_context pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -58,3 +64,35 @@ def test_two_apps_composed_into_one_computer_env_each_run_on_their_own_settings( said = [line for line in ran.output.splitlines() if line.startswith(f"envs/desk: container/{ARCH}: ")] assert any(line.endswith(("comparing the files its parts' run scripts and install steps wrote", "reusing the image of the same key, built earlier")) for line in said), said + + +def test_a_computers_apps_load_export_and_reset_each_through_its_own_data_plane(state, monkeypatch): + monkeypatch.setenv("NOTES_TOKEN", "t0ken") + + ran = CliRunner().invoke(cli, ["run", str(APP), "--task", "data"]) + + assert ran.exit_code == 0, ran.output + context = run_context(ran.output) + (deployed,) = context["deployed_envs"] + assert sorted(child["name"] for child in deployed["environment_card"]["children_environments"]) == [ + "clock", "notes"] + snapshots = context["metadata"]["env_snapshotted_universes"] + desk = {"Documents/seed.txt": b"seeded\n", "Desktop/todo.txt": b"water the plants\n"} + assert _kept(snapshots["loaded"]) == {"desk": desk, "notes": { + "Notes/welcome.md": b"# Welcome\n", "Notes/today.md": b"# Today\n", "Notes/ideas.md": b"# Ideas\n"}, + "clock": {"alarms": ["07:00"]}} + assert context["metadata"]["script_results"]["reset"] == {"reset": {"notes": True, "clock": True}, + "notes": ["welcome.md"], "alarms": False} + assert _kept(snapshots["after_reset"]) == {"desk": desk, "notes": {"Notes/welcome.md": b"# Welcome\n"}, + "clock": {"alarms": []}} + + +def _kept(snapshot: dict) -> dict: + """Each environment of a snapshot: the computer's and notes's files by their paths, and the clock's export.""" + with namespace_routing(): + universe = EnvironmentUniverseArtifact.get(snapshot["id"], version=snapshot["version"]) + kept = {environment.environment_name: {path: file.load() for path, file in + environment.get_file_artifacts().items()} + for environment in universe.get_environment_artifacts()} + (clock,) = kept.pop("clock").values() + return {**kept, "clock": json.loads(clock)} diff --git a/tst/unit/bundle/computer_env_folder_test.py b/tst/unit/bundle/computer_env_folder_test.py index d01249337..d685c5c6a 100644 --- a/tst/unit/bundle/computer_env_folder_test.py +++ b/tst/unit/bundle/computer_env_folder_test.py @@ -157,6 +157,27 @@ def test_a_computer_env_folder_names_its_apps_in_order_each_written_before_it(tm assert composed == [(env.id, UBUNTU, REFERENCE, None)] +def test_a_universe_loaded_into_a_stored_computer_env_seeds_its_files_and_each_of_its_apps_by_name(tmp_path, composed): + plan = _plan(_bundle(tmp_path / "desk", WITH_APPS, APPS)) + materialize(plan, build_here=local_builds(plan)) + world = {"artifacts/world/artifact.toml": 'type = "environment_universe"\n', + "artifacts/world/desktop/a.json": "{}", "artifacts/world/mail/a.json": "{}"} + + def loading(root, *extra): + return layout(root, {**world, **{f"artifacts/world/{name}/a.json": "{}" for name in extra}, "tasks/t.json": ( + json.dumps([{"id": "load", "type": "load_artifact", "env_id": f"{plan.bundle.bundle.id_root}/desktop", + "artifact_id": "world"}]))}) + + plan_bundle(resolve_bundle(parse_bundle(loading(tmp_path / "seeds")))) + with pytest.raises(BundleError) as refused: + plan_bundle(resolve_bundle(parse_bundle(loading(tmp_path / "typo", "mial")))) + + assert refused.value.problems == ( + f"tasks/t.json: step 'load': artifacts/world/mial seeds an environment named 'mial', and env " + f"'{plan.bundle.bundle.id_root}/desktop' has no server by that name ('desktop', 'mail', 'notes'); name it " + "after the server it seeds, or leave it out",) + + @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"'), diff --git a/tst/unit/computer_home.py b/tst/unit/computer_home.py index ae4348d5d..1d9d8e3dc 100644 --- a/tst/unit/computer_home.py +++ b/tst/unit/computer_home.py @@ -1,5 +1,6 @@ """A computer at its driver's boundary, for steps that reach inside one: its home a folder on this machine, ``data/add`` -writing there, ``data/get`` reading there, and computer-exec/v1 ``run`` running commands there; each call recorded. +writing there, ``data/get`` reading there, and computer-exec/v1 ``run`` running commands there; each call recorded, +and the address each ``data/add`` is sent to. ``pkill`` matches nothing, so one test never ends another's processes on this machine.""" from __future__ import annotations @@ -27,6 +28,7 @@ class Computer: def __init__(self, home: Path): self.home = home self.calls: list[tuple] = [] + self.added_at: list[str] = [] self.add_fails: Exception | None = None def deployed(self, env_id: str = "desk", card: dict | None = None, **computer) -> DeployedSandboxEnv: @@ -43,6 +45,7 @@ def install(self, monkeypatch) -> Computer: async def add_data(self, base_url, parts, timeout=120, verify=True): self.calls.append(("data/add", [p.metadata[DATA_OBJECTS_EXTENSION_URI] for p in parts], timeout)) + self.added_at.append(base_url) if self.add_fails is not None: raise self.add_fails for part in parts: diff --git a/tst/unit/env/envs/_computer.py b/tst/unit/env/envs/_computer.py index 06c305cad..374d76484 100644 --- a/tst/unit/env/envs/_computer.py +++ b/tst/unit/env/envs/_computer.py @@ -325,11 +325,11 @@ async def create_vm(self, **size): def _app(base, driver, folder, compose=True, supports=None, install=None, settings=MAIL_SETTINGS, launch=None, - extra=None, steps=(), build=MAIL_BUILD, linux_settings=None, name="mail", **changes): + extra=None, steps=(), build=MAIL_BUILD, linux_settings=None, name="mail", paths=None, **changes): """A mail app on ``base`` and ``driver``, which serves IMAP from a venv of its requirements; ``extra`` files beside them, by name, ``steps``, its install steps by name, scope and the OS types they run on, ``build``, the schema of - its build settings, ``linux_settings``, that of its settings on Linux, and ``name``, its id's last part and its - environment name.""" + its build settings, ``linux_settings``, that of its settings on Linux, ``name``, its id's last part and its + environment name, and ``paths``, where it keeps its data on Linux.""" folder.mkdir(parents=True, exist_ok=True) (folder / "app.py").write_text("class Mail: ...\n") (folder / "requirements.txt").write_text("agentenv-framework-protocol\n") @@ -342,8 +342,9 @@ def _app(base, driver, folder, compose=True, supports=None, install=None, settin "supports": supports or [{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}], "permissions": [], "settings": settings, "build_settings": build, "skill": None, "data": [], "install_steps": [{"name": name, "scope": scope, "os": os or None} for name, scope, *os in steps], - "ready_checks": [], "os": {"linux": {"launch": launch, "settings": linux_settings, "install": install or { - "python": "requirements.txt", "services": [{"name": "imap", "command": "venv/bin/python imap.py"}]}}}}) + "ready_checks": [], "os": {"linux": { + "launch": launch, "settings": linux_settings, "paths": paths, "install": install or { + "python": "requirements.txt", "services": [{"name": "imap", "command": "venv/bin/python imap.py"}]}}}}) return ComputerAppEnv.put(**{"id": f"@local/~/kit/{name}", "environment_name": name, "module": "app.py:Mail", "files": files, "declaration": declaration, "computer": base, "driver": driver, "compose": compose, **changes}) diff --git a/tst/unit/env/envs/test_computer_apps.py b/tst/unit/env/envs/test_computer_apps.py index 4306f6f6a..247573789 100644 --- a/tst/unit/env/envs/test_computer_apps.py +++ b/tst/unit/env/envs/test_computer_apps.py @@ -59,7 +59,13 @@ def test_a_computer_env_builds_its_apps_after_its_driver_in_the_order_it_lists_t (lambda mail, other: [other(name="mail", install={"services": [{"name": "sync", "command": "venv/bin/sync"}]}), other(name="mail-sync", install={"services": []})], "apps: @local/~/kit/mail and @local/~/kit/mail-sync would share the program 'app-mail-sync'"), -], ids=["twice", "environment-name", "folder", "app-port", "driver-port", "arch", "no-shared-arch", "program"]) + (lambda mail, other: [mail, other(name="desk-app", environment_name="desktop")], + "apps: @local/~/kit/desk-app is named 'desktop', as the computer env is, which names the computer's own files"), + (lambda mail, other: [other(name="a", paths=["Mail", ".config/a"]), other(name="b", paths=["Mail/inbox"]), + other(name="c", paths=[".config/c"])], + "apps: @local/~/kit/a keeps its data in 'Mail' and @local/~/kit/b in 'Mail/inbox', so they'd share it"), +], ids=["twice", "environment-name", "folder", "app-port", "driver-port", "arch", "no-shared-arch", "program", + "computer-name", "paths"]) def test_apps_that_dont_fit_together_or_on_the_computer_are_refused_before_anything_is_stored(base, driver, docker, tmp_path, apps, problem): def other(name="other", **changes): diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 36c807f52..d1095a050 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -54,7 +54,7 @@ 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.store.object_store.local.store import LocalFilesystemObjectStore -from tst.unit.computer_home import Computer +from tst.unit.computer_home import CARD as HOME_CARD, Computer from tst.unit.env.envs._computer import (CONFIG, SERVE, CARD_EXTENSION, CARD, MAIL_SETTINGS, MAIL_BUILD, MAIL_SERVER, _app, _base, _driver, _put, FakeVmProvider) @@ -673,6 +673,25 @@ async def test_a_boot_that_never_gets_ready_takes_its_sandbox_and_work_folder_do assert local_sandbox.LocalSandbox.find_work_dir("local-unready") is None +@pytest.mark.asyncio +async def test_a_boot_is_ready_with_the_card_read_once_its_status_says_so_each_app_on_it(monkeypatch): + cards = iter([{"name": "desk"}, {"name": "desk"}, {"name": "desk", "children_environments": [{"name": "mail"}]}]) + statuses = iter([{"ready": False, "checks": [{"name": "app mail", "ok": False, "detail": "starting"}]}, + {"ready": True}]) + + async def card(url, timeout): + return next(cards) + + async def status(url, card, uri, params, timeout, method): + return next(statuses) + + monkeypatch.setattr(boot.protocol_v1, "get_card", card) + monkeypatch.setattr(boot.protocol_v1, "invoke_extension", status) + monkeypatch.setattr(boot, "_POLL_S", 0) + + assert await boot.ready("http://desk", 5) == {"name": "desk", "children_environments": [{"name": "mail"}]} + + def test_a_boot_mints_a_generated_setting_resolves_a_secret_its_record_withholds_both_and_a_secret_is_a_reference( base, monkeypatch): monkeypatch.setenv("DESK_KEY", "s3cret") @@ -718,12 +737,20 @@ def computer(tmp_path, monkeypatch): return Computer(tmp_path / "home").install(monkeypatch) -def _deployed_env(base, driver, computer, **record): - env = _put(base, driver, compose=False) - env._deployed = computer.deployed(env_id=env.id, **record) +def _deployed_env(base, driver, computer, apps=(), children=(), **record): + """The env of ``apps``, deployed on ``computer``, its card named after it and listing ``children``.""" + env = _put(base, driver, compose=False, apps=apps) + card = {**HOME_CARD, "name": env.environment_name, + **({"children_environments": list(children)} if children else {})} + env._deployed = computer.deployed(env_id=env.id, card=card, **record) return env +def _child(name: str, *operations: str) -> dict: + """An app's card on its computer's, as the driver fronts it.""" + return {"name": name, "url": f"/svc/{name}/agentenv", "capabilities": {"operations": list(operations)}} + + def _files(tmp_path, **contents): for name, data in contents.items(): (tmp_path / "files" / name).parent.mkdir(parents=True, exist_ok=True) @@ -793,33 +820,41 @@ def _snapshot(environment_name: str, contents: dict[str, bytes]) -> EnvironmentA @pytest.mark.asyncio -async def test_a_snapshot_of_its_files_loads_back_at_their_paths_leaving_out_other_environments( +async def test_a_snapshot_loads_back_its_files_then_each_apps_through_the_apps_route_leaving_out_other_environments( base, driver, docker, computer, tmp_path, monkeypatch, caplog): monkeypatch.setattr(inside, "issues_grants_to", lambda store, sandbox_type: False) - env = _deployed_env(base, driver, computer) + mail = _app(base, driver, tmp_path / "mail", compose=False) + env = _deployed_env(base, driver, computer, apps=[mail], children=[_child("mail", "data/add", "data/get")]) contents = {"Desktop/a.txt": b"a", "Documents/r/b.txt": b"b"} universe = EnvironmentUniverseArtifact.put("snap", environment_artifacts=[ - _snapshot(env.environment_name, contents), _snapshot("mail", {"inbox/1.json": b"{}"})]) + _snapshot("mail", {"Mail/inbox.txt": b"m"}), _snapshot(env.environment_name, contents), + _snapshot("notes", {"inbox/1.json": b"{}"})]) with caplog.at_level(logging.WARNING): await env.load_environment_universe_artifact(universe) assert {path: (computer.home / path).read_bytes() for path in contents} == contents + assert computer.added_at == ["http://127.0.0.1:20001", "http://127.0.0.1:20001/svc/mail"] + assert [markers for _, markers, _ in computer.calls][1] == [{"path": "Mail/inbox.txt"}] assert not (computer.home / "inbox").exists() - assert "leaving out ['mail']" in caplog.text + assert "leaving out ['notes']" in caplog.text @pytest.mark.asyncio -async def test_an_environment_not_named_after_the_computer_is_refused_before_anything_is_sent(base, driver, docker, - computer): - env = _deployed_env(base, driver, computer) - mail = _snapshot("mail", {"inbox/1.json": b"{}"}) - - with pytest.raises(ValueError, match="does not match env environment_name"): - await env.load_environment_artifact(mail) - with pytest.raises(RuntimeError, match=f"has no environment named '{env.environment_name}'"): - await env.load_environment_universe_artifact(EnvironmentUniverseArtifact.put("mail-only", - environment_artifacts=[mail])) +async def test_an_environment_named_after_neither_the_computer_nor_an_app_that_loads_is_refused_before_anything_is_sent( + base, driver, docker, computer, tmp_path): + mail = _app(base, driver, tmp_path / "mail", compose=False) + env = _deployed_env(base, driver, computer, apps=[mail], children=[_child("mail", "data/get")]) + notes, mailbox = _snapshot("notes", {"inbox/1.json": b"{}"}), _snapshot("mail", {"Mail/inbox.txt": b"m"}) + + with pytest.raises(ValueError, match="has no environment named 'notes': it has \\['desktop', 'mail'\\]"): + await env.load_environment_artifact(notes) + with pytest.raises(ValueError, match="'mail' in computer env '@local/~/kit/desktop' serves no data/add"): + await env.load_environment_universe_artifact(EnvironmentUniverseArtifact.put("both", environment_artifacts=[ + _snapshot(env.environment_name, {"Desktop/a.txt": b"a"}), mailbox])) + with pytest.raises(RuntimeError, match="has no environment named any of \\['desktop', 'mail'\\]"): + await env.load_environment_universe_artifact(EnvironmentUniverseArtifact.put("notes-only", + environment_artifacts=[notes])) assert computer.calls == [] diff --git a/tst/unit/env/test_env.py b/tst/unit/env/test_env.py index 57d8982aa..0f40fe177 100644 --- a/tst/unit/env/test_env.py +++ b/tst/unit/env/test_env.py @@ -120,9 +120,14 @@ def test_child_env_cards_come_from_the_card_by_name(composed): assert _record({**composed, "name": "slack", "children_environments": []}).get_child_env_card("slack") is None # composed, not a leaf -def test_a_leaf_card_is_its_own_only_child_env(): +def test_a_leaf_card_is_its_own_only_child_env_and_a_composed_one_with_a_data_plane_is_the_one_its_name_reaches(): assert _record(_LEAF).get_child_env_card("notes") is _LEAF assert _record(_LEAF).get_child_env_card("slack") is None + mail = {"name": "mail", "url": "/svc/mail/agentenv"} + desk = {"name": "desk", "capabilities": {"operations": ["data/get"]}, "children_environments": [mail]} + assert _record(desk).get_child_env_card("desk") is desk and _record(desk).get_child_env_card("mail") is mail + alone = {**desk, "children_environments": [{**mail, "name": "desk"}]} + assert _record(alone).get_child_env_card("desk") is alone @pytest.mark.asyncio diff --git a/tst/unit/task_step/add_skills_env_test.py b/tst/unit/task_step/add_skills_env_test.py index 8f059dd32..3b8001ea5 100644 --- a/tst/unit/task_step/add_skills_env_test.py +++ b/tst/unit/task_step/add_skills_env_test.py @@ -1,6 +1,7 @@ -"""``add_skills`` ``env_ids``: each env's skill, or each of its child envs' behind a gateway, written under that card's -environment name, installed on the agent and recorded in the run, a skill with files beside its SKILL.md sent as a -stored bundle; refused for an env the run didn't deploy or bind to the agent, or that serves no skill.""" +"""``add_skills`` ``env_ids``: each env's skill and each of its child envs' (a gateway's servers, a computer's apps) its +name reaches, written under that card's environment name, installed on the agent and recorded in the run, a skill with +files beside its SKILL.md sent as a stored bundle; refused for an env the run didn't deploy or bind to the agent, or +that serves no skill.""" import hashlib from types import SimpleNamespace @@ -107,6 +108,20 @@ async def test_an_env_behind_a_gateway_installs_each_child_envs_skill_under_the_ assert set(context.metadata[ENV_SKILLS_KEY]["solver"]["office"]) == {"mail", f"chat-room-{digest}"} +@pytest.mark.asyncio +async def test_a_computer_installs_its_own_skill_then_each_apps_and_an_app_alone_the_computers_its_name_reaches(agent): + desk = _env("desk", name="desktop", children=(("mail", (_SKILL,)), ("notes", ()))) + alone = _env("mail-alone", name="mail", children=(("mail", (_SKILL,)),)) + for computer in (desk, alone): + computer.environment_card["capabilities"]["operations"] = ["data/add", "data/get"] + + context = await _step("desk", "mail-alone").execute(_context(desk, alone, bound=("desk", "mail-alone"))) + + assert [(base_url, card) for base_url, card, *_ in agent.calls] == [ + ("http://desk", "desktop"), ("http://desk", "mail"), ("http://mail-alone", "mail")] + assert set(context.metadata[ENV_SKILLS_KEY]["solver"]["mail-alone"]) == {"mail"} + + @pytest.mark.asyncio async def test_envs_whose_names_come_out_alike_get_skills_of_their_own(agent): await _step("shop", "other").execute(_context(_env(name="my-shop"), _env("other", name="My_Shop"), diff --git a/tst/unit/task_step/snapshot_computer_env_test.py b/tst/unit/task_step/snapshot_computer_env_test.py index 0fb423c77..6c6085cd0 100644 --- a/tst/unit/task_step/snapshot_computer_env_test.py +++ b/tst/unit/task_step/snapshot_computer_env_test.py @@ -1,7 +1,8 @@ """snapshot_computer_env: a computer env's files kept by their paths in its environment artifact, in a new environment -universe, returned inline or uploaded under the grant and written where they were put, the computer asked again without -the grant when it fails under it, and file_paths sent to its data/get; refused for an env that isn't a computer, for -paths that aren't relative, and with no files to keep.""" +universe beside each of its apps' exports, returned inline or uploaded under the grant and written where they were put, +the computer asked again without the grant when it fails under it, file_paths sent to its data/get alone, and an app +that keeps nothing yet left out; refused for an env that isn't a computer, for paths that aren't relative, and with no +files to keep.""" from __future__ import annotations @@ -15,8 +16,8 @@ from agent_env.env.envs.computer.env import ComputerEnv from agent_env.task_step.registry import get_task_step_registry from agent_env.task_step.task_steps.computer.snapshot_computer_env import SnapshotComputerEnvTaskStep -from tst.unit.task_step.snapshot_env_without_gateway_test import (DATA_OBJECTS, UNREACHABLE, _computer, _inline, _record, - _serve) +from tst.unit.task_step.snapshot_env_without_gateway_test import (DATA_OBJECTS, FILE_TREE, UNREACHABLE, _computer, + _inline, _record, _serve) from tst.unit.task_step.test_snapshot_env import _ctx from tst.util.granting_object_store import GrantingObjectStore @@ -31,7 +32,6 @@ def __init__(self): class _Mail: id, version, type, environment_name = "mail", 1, "mcp_server", "mail" - async def _snapshot(monkeypatch, card: dict, env=None, **fields) -> EnvironmentArtifact: """The computer's environment artifact in the universe the step snapshots it into.""" monkeypatch.setattr("agent_env.env.env.Env.get", classmethod(lambda cls, id, version=None: env or _Desk())) @@ -78,6 +78,30 @@ def test_an_env_that_isnt_a_computer_is_refused_before_a_run(monkeypatch, stored if refused else []) +@pytest.mark.asyncio +async def test_a_computer_with_apps_keeps_its_files_beside_each_apps_export_sending_file_paths_to_itself_alone( + local_stores, monkeypatch): + card = {**_computer(), "children_environments": [ + {"name": name, "url": f"/svc/{name}/agentenv", "capabilities": { + "operations": ["data/add", "data/get"], "extensions": [FILE_TREE]}} for name in ("mail", "notes")]} + card["capabilities"] = {**card["capabilities"], "operations": ["data/add", "data/get"]} + sent = _serve(monkeypatch, {"/agentenv": lambda params: [_inline("Documents/a.txt", b"a")], + "/svc/mail/agentenv": lambda params: [_inline("Mail/inbox.txt", b"m")], + "/svc/notes/agentenv": lambda params: []}) + monkeypatch.setattr("agent_env.env.env.Env.get", classmethod(lambda cls, id, version=None: _Desk())) + context = _ctx(_record(card)) + + await SnapshotComputerEnvTaskStep(id="snap", version=None, env_id="desk", file_paths=["Documents"]).execute(context) + + snapshotted = context.metadata["env_snapshotted_universes"]["snap"] + universe = EnvironmentUniverseArtifact.get(snapshotted["id"], version=snapshotted["version"]) + assert {environment.environment_name: _kept(environment) for environment in universe.get_environment_artifacts() + } == {"desktop": {"Documents/a.txt": b"a"}, "mail": {"Mail/inbox.txt": b"m"}} + assert sorted((path, sorted(params)) for path, params in sent) == [("/agentenv", ["paths"]), + ("/svc/mail/agentenv", []), + ("/svc/notes/agentenv", [])] + + @pytest.mark.asyncio @pytest.mark.parametrize("file_paths, params", [(["Documents"], {"paths": ["Documents"]}), (None, {})], ids=["file-paths", "the-cards-folders"]) diff --git a/tst/unit/task_step/snapshot_env_without_gateway_test.py b/tst/unit/task_step/snapshot_env_without_gateway_test.py index d201c1114..a262d6407 100644 --- a/tst/unit/task_step/snapshot_env_without_gateway_test.py +++ b/tst/unit/task_step/snapshot_env_without_gateway_test.py @@ -1,7 +1,8 @@ """snapshot_env on an env deployed without a gateway: reached through the card its deployment recorded, its -environments the card's children or the env itself, each kept as one file as through a gateway; an environment whose -data/get returns a file tree, a computer's, kept file by file by its paths under a grant sized for a file tree; and an -env that fails under the upload grant asked again without it.""" +environments the card's children its names reach that export, and the env itself when it exports beside them, or the +env alone, each kept as one file as through a gateway and sent its own data/get params; an environment +whose data/get returns a file tree, a computer's, kept file by file by its paths under a grant sized for a file tree; +and an env that fails under the upload grant asked again without it.""" from __future__ import annotations @@ -69,9 +70,9 @@ def handle(request: httpx.Request) -> httpx.Response: return sent -async def _snapshot(card: dict) -> EnvironmentUniverseArtifact: +async def _snapshot(card: dict, **params) -> EnvironmentUniverseArtifact: result = await SnapshotEnvTaskStep.snapshot_env_state(env=_Desk(), gateway_url=None, snapshot_id="snap-d", - deployed=_record(card)) + deployed=_record(card), **params) return EnvironmentUniverseArtifact.get(result.environment_universe_artifact_id) @@ -94,6 +95,25 @@ async def test_each_child_on_the_recorded_card_is_reached_and_kept_as_one_file(l assert sorted(path for path, _ in sent) == ["/svc/mail/agentenv", "/svc/notes/agentenv"] +@pytest.mark.asyncio +async def test_a_card_exporting_beside_its_children_is_kept_first_with_its_own_params_leaving_out_what_doesnt_export( + local_stores, monkeypatch): + card = {"name": "desktop", "capabilities": {"operations": ["data/add", "data/get"], "extensions": []}, + "children_environments": [ + {"name": "mail", "url": "/svc/mail/agentenv", "capabilities": {"operations": ["data/get"]}}, + {"name": "notes", "url": "/svc/notes/agentenv", "capabilities": {"operations": ["data/add"]}}]} + sent = _serve(monkeypatch, {"/agentenv": lambda params: [{"kind": "data", "data": {"home": 1}}], + "/svc/mail/agentenv": lambda params: [{"kind": "data", "data": {"inbox": 2}}]}) + + universe = await _snapshot(card, data_get_params={"desktop": {"paths": ["Desktop"]}}) + alone = await _snapshot({**card, "children_environments": [ + {"name": "desktop", "url": "/svc/desktop/agentenv", "capabilities": {"operations": ["data/get"]}}]}) + + assert _kept(universe) == {"desktop": b'{"home": 1}', "mail": b'{"inbox": 2}'} + assert _kept(alone) == {"desktop": b'{"home": 1}'} + assert sent == [("/agentenv", {"paths": ["Desktop"]}), ("/svc/mail/agentenv", {}), ("/agentenv", {})] + + @pytest.mark.asyncio async def test_the_env_itself_is_reached_when_its_card_has_no_children(local_stores, monkeypatch): sent = _serve(monkeypatch, {"/agentenv": lambda params: [{"kind": "data", "data": {"rows": 1}}]}) From 0fd57bdf640758e8abf2f4d078e0999644d90545 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 31 Oct 2026 19:46:41 -0700 Subject: [PATCH 04/16] fix(computer): an app's data calls take turns and outlast their timeout safely Data calls and launches take one turn at a time, so two loads never overlap and a launch never starts the app mid-load. A sync hook runs in a thread, timed from the start; a call past data_timeout_s fails then, and its reset and relaunch, and the next call, wait for its work to end. Stopping an app ends its whole process group, though its first process exited. An app's paths may not overlap, and a load may not write through a link out of them. Co-Authored-By: Claude Opus 5.5 --- .../src/agentenv_protocol/computer/app.py | 100 +++++++++++++----- .../agentenv_protocol/computer/app_data.py | 10 +- .../agentenv_protocol/computer/app_runtime.py | 7 +- .../src/agentenv_protocol/computer/session.py | 4 +- .../tests/test_computer_app_data.py | 75 +++++++++++-- .../tests/test_computer_app_server.py | 12 +++ 6 files changed, 167 insertions(+), 41 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py index e613c1c45..8b8bdeadb 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py @@ -10,8 +10,10 @@ from __future__ import annotations import asyncio +import contextlib import functools import inspect +import itertools import json import logging import os @@ -300,6 +302,9 @@ def install(os_type: str, spec: BaseModel, *, launch: Optional[Launch] = None, p owned = tuple(_PATHS.validate_python(list(paths or []))) except ValidationError: raise ValueError(f"paths are normalized paths relative to the session user's home, not {paths!r}") from None + if overlapping := [f"{a!r} and {b!r}" for a, b in itertools.combinations(owned, 2) + if PurePosixPath(a).is_relative_to(b) or PurePosixPath(b).is_relative_to(a)]: + raise ValueError(f"an app's paths each hold files of their own, but {'; '.join(overlapping)} overlap") def deco(cls: type) -> type: if not (isinstance(cls, type) and issubclass(cls, ComputerAppEnvironment)): @@ -422,7 +427,12 @@ def build_settings_on(cls, os_type: Optional[str]) -> type[BaseModel]: async def _computer_app_launch(self) -> dict: if self.launch is None: raise InvalidArgument(f"{type(self).__name__} declares no launch") - return {"pid": await self._computer_app_runtime().launch_app()} + runtime = self._computer_app_runtime() + await self._computer_app_wait_turn("launch") + try: + return {"pid": await runtime.launch_app()} + finally: + runtime.turn.release() @extension(COMPUTER_APP, op="ready", method="GET") async def _computer_app_ready(self) -> dict: @@ -480,26 +490,56 @@ def _computer_app_skill(self) -> tuple[str, str]: return _skill_parts(Path(self._computer_app_skill_file()).read_text(), os.environ.get("ENVIRONMENT_NAME", "")) async def _computer_app_data(self, op: str, *args: Any) -> Any: - """``op``, by its hook or its paths' default: a load and a reset stop the app first and relaunch it after, and a - load that fails is followed by a reset.""" - call = self._computer_app_data_call(op) - if op == OP_GET_DATA: - return await self._computer_app_within(op, call(*args)) + """``op``, by its hook or its paths' default, one data call or launch at a time: a load and a reset stop the app + first and relaunch it after, and a load that fails is followed by a reset. A call fails once it runs past + ``data_timeout_s``, but a thread can't be stopped, so the reset and relaunch after it wait for it to end, and so + does the next call.""" + call, runtime = self._computer_app_data_call(op), self._computer_app_runtime() + await self._computer_app_wait_turn(op) + answer = asyncio.get_running_loop().create_future() + change = asyncio.ensure_future(self._computer_app_change(op, call, args, answer)) + runtime.changing.add(change) + change.add_done_callback(runtime.changing.discard) + change.add_done_callback(lambda _: runtime.turn.release()) + return await answer + + async def _computer_app_change(self, op: str, call: Callable, args: tuple, answer: asyncio.Future) -> None: + """``op`` between the app's stop and its relaunch, answered on ``answer``: as it ends, or once it runs past + ``data_timeout_s``.""" runtime = self._computer_app_runtime() - running = await runtime.stop_app() try: + running = op != OP_GET_DATA and await runtime.stop_app() try: - return await self._computer_app_within(op, call(*args)) + result = await self._computer_app_within(op, call, args, answer) except Exception: - if op == OP_ADD_DATA and (reset := self._computer_app_data_call(OP_RESET_DATA)) is not None: - try: - await self._computer_app_within(OP_RESET_DATA, reset()) - except Exception as e: - logger.warning(f"the reset after a failed data/add failed too: {type(e).__name__}: {e}") + if op == OP_ADD_DATA: + await self._computer_app_undo(answer) raise - finally: - if running: - await runtime.launch_app() + finally: + if running: + await runtime.launch_app() + except Exception as e: + if not answer.done(): + answer.set_exception(e) + else: + answer.set_result(result) + + async def _computer_app_undo(self, answer: asyncio.Future) -> None: + """Reset the app's data after a load that failed.""" + if (reset := self._computer_app_data_call(OP_RESET_DATA)) is None: + return + try: + await self._computer_app_within(OP_RESET_DATA, reset, (), answer) + except Exception as e: + logger.warning(f"the reset after a failed data/add failed too: {type(e).__name__}: {e}") + + async def _computer_app_wait_turn(self, op: str) -> None: + """Wait, within ``data_timeout_s``, for the data call before to end, its reset and relaunch included.""" + try: + await asyncio.wait_for(self._computer_app_runtime().turn.acquire(), self.data_timeout_s) + except TimeoutError: + raise TimeoutError(f"{op} waited past the app's data_timeout_s, {self.data_timeout_s:g} s, for the data " + "call before it to end") from None def _computer_app_data_call(self, op: str) -> Optional[Callable]: """What serves ``op``: the class's hook, or its paths' default; None when nothing does.""" @@ -510,15 +550,23 @@ def _computer_app_data_call(self, op: str) -> Optional[Callable]: return hook if self._data_hooks or not self.paths: return None - return {OP_ADD_DATA: lambda parts: app_data.add(parts, self.paths), - OP_GET_DATA: lambda write_namespace: app_data.get(self.paths, write_namespace), - OP_RESET_DATA: lambda: app_data.reset(self.paths)}[op] - - async def _computer_app_within(self, op: str, result: Any) -> Any: + return {OP_ADD_DATA: functools.partial(app_data.add, paths=self.paths), + OP_GET_DATA: functools.partial(app_data.get, self.paths), + OP_RESET_DATA: functools.partial(app_data.reset, self.paths)}[op] + + async def _computer_app_within(self, op: str, call: Callable, args: tuple, answer: asyncio.Future) -> Any: + """``call``'s result within ``data_timeout_s``; past it, ``answer`` fails at once, and this waits for the call + to end before it raises the same.""" + work = asyncio.ensure_future(_called(call, *args)) try: - return await asyncio.wait_for(_awaited(result), self.data_timeout_s) + return await asyncio.wait_for(asyncio.shield(work), self.data_timeout_s) except TimeoutError: - raise TimeoutError(f"{op} ran past the app's data_timeout_s, {self.data_timeout_s:g} s") from None + late = TimeoutError(f"{op} ran past the app's data_timeout_s, {self.data_timeout_s:g} s") + if not answer.done(): + answer.set_exception(late) + with contextlib.suppress(Exception): + await work + raise late from None def _computer_app_keep_initial(self) -> None: """Keep the app's paths as installed, before anything changes them, when they serve its data.""" @@ -579,7 +627,11 @@ def declaration(cls) -> AppDeclaration: ) -async def _awaited(result: Any) -> Any: +async def _called(fn: Callable, *args: Any) -> Any: + """``fn``'s result, a sync one's from a thread, so it can't hold up the server.""" + if inspect.iscoroutinefunction(fn): + return await fn(*args) + result = await asyncio.to_thread(fn, *args) return await result if inspect.isawaitable(result) else result diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py index 754007b4b..e859e1dc9 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py @@ -29,7 +29,8 @@ def keep_initial(paths: list[str]) -> None: async def add(parts: list, paths: list[str]) -> None: - """Write each file part at the path it names, which must be under one of ``paths``.""" + """Write each file part at the path it names, which must be under one of ``paths``, through no link that leads + out of them; nothing is written when one isn't.""" files = {} for part in parts: path = uploaded_object_path(part) if isinstance(part, FilePart) else None @@ -38,7 +39,7 @@ async def add(parts: list, paths: list[str]) -> None: if not any(PurePosixPath(path).is_relative_to(owned) for owned in paths): raise ValueError(f"{path!r} is under none of this app's paths: {', '.join(paths)}") files[path] = await part_bytes(part) - await asyncio.to_thread(_write, Path.home(), files) + await asyncio.to_thread(_write, Path.home(), files, paths) async def get(paths: list[str], write_namespace: Optional[WriteNamespaceGrant]) -> list: @@ -51,7 +52,10 @@ async def reset(paths: list[str]) -> None: await asyncio.to_thread(_restore, Path.home(), paths) -def _write(home: Path, files: dict[str, bytes]) -> None: +def _write(home: Path, files: dict[str, bytes], paths: list[str]) -> None: + owned = [(home / path).resolve() for path in paths] + if linked := [path for path in files if not any((home / path).resolve().is_relative_to(o) for o in owned)]: + raise ValueError(f"{', '.join(map(repr, linked))} lead out of this app's paths through a link") for path, data in files.items(): target = home / path target.parent.mkdir(parents=True, exist_ok=True) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py index 6e48505bc..fb29a9907 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py @@ -46,7 +46,8 @@ class AppReadiness(BaseModel): class AppRuntime: """An app's server's state: its ``launch``, its ``@ready_check`` methods by name, bound to the app, how long it may - take to be ready, why its last launch failed, and each sync check still running in its thread.""" + take to be ready, why its last launch failed, each sync check still running in its thread, the ``turn`` its data + calls and launches take one at a time, and each data call still running.""" def __init__(self, launch: Optional[Launch], ready_checks: dict[str, Callable[[], Any]], timeout_s: float) -> None: self.launch = launch @@ -55,6 +56,8 @@ def __init__(self, launch: Optional[Launch], ready_checks: dict[str, Callable[[] self.started = time.time() self.launch_failed: Optional[str] = None self.checking: dict[str, asyncio.Future] = {} + self.turn = asyncio.Lock() + self.changing: set[asyncio.Task] = set() async def start(self) -> None: """Once the desktop session is up, add the app's menu launcher and launch a boot app, unless the computer has @@ -80,7 +83,7 @@ async def launch_app(self) -> int: return pid async def stop_app(self) -> bool: - """Stop the app this server launched last, with everything it started; whether it was running.""" + """Stop the app this server launched last, with everything it started; whether any of it was running.""" try: pid = int(PID.read_text()) except (FileNotFoundError, ValueError): diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/session.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/session.py index 9f08d21f5..1c8cdb726 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/session.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/session.py @@ -49,9 +49,9 @@ async def stop(pid: int, timeout_s: float) -> None: def alive(pid: int) -> bool: - """Whether the process ``pid`` is running.""" + """Whether a process of the session ``launch`` started as ``pid`` is running, its first or one it started.""" try: - os.kill(pid, 0) + os.killpg(pid, 0) except ProcessLookupError: return False except PermissionError: diff --git a/packages/agentenv-protocol/tests/test_computer_app_data.py b/packages/agentenv-protocol/tests/test_computer_app_data.py index f327ce20c..ee63ac1cc 100644 --- a/packages/agentenv-protocol/tests/test_computer_app_data.py +++ b/packages/agentenv-protocol/tests/test_computer_app_data.py @@ -1,12 +1,16 @@ -"""A computer app's data plane, served by its own server. An app that declares only paths gets the defaults: a load writes -files under them, an export returns them, and a reset puts back the copy its server kept before it served; its card -declares a file tree. Hooks override paths. Around a load and a reset the app is stopped and relaunched, a failed load -is followed by a reset, and a call past data_timeout_s fails. The app serves its skill file.""" +"""A computer app's data plane, served by its own server. An app that declares only paths, none inside another, gets +the defaults: a load writes files under them, through no link out of them, an export returns them, and a reset puts back +the copy its server kept before it served; its card declares a file tree. Hooks override paths. Around a load and a +reset the app is stopped and relaunched, a failed load is followed by a reset, and data calls take turns. A call past +data_timeout_s fails then, a sync hook running in a thread, and its reset and relaunch, and the next call, wait for it +to end. The app serves its skill file.""" from __future__ import annotations import asyncio import base64 import contextlib +import os +import time import pytest @@ -103,13 +107,32 @@ async def test_an_app_with_only_paths_loads_exports_and_resets_them_as_installed @pytest.mark.asyncio -async def test_a_file_under_none_of_its_paths_is_refused_and_nothing_is_written(home): - async with _http(PathsMail()) as http: - answer = await http.post(RPC_PATH, json=_call("data/add", {"parts": [_inline("Mail/a.txt", b"a"), - _inline("Documents/b.txt", b"b")]})) +async def test_a_file_under_none_of_its_paths_or_through_a_link_out_of_them_is_refused_and_nothing_is_written(home): + (home / "Documents").mkdir() + (home / "Mail").mkdir() + os.symlink(home / "Documents", home / "Mail" / "shared") + mail = PathsMail() + mail._computer_app_keep_initial() + + async with _http(mail) as http: + outside = await http.post(RPC_PATH, json=_call("data/add", {"parts": [_inline("Mail/a.txt", b"a"), + _inline("Documents/b.txt", b"b")]})) + linked = await http.post(RPC_PATH, json=_call("data/add", {"parts": [_inline("Mail/a.txt", b"a"), + _inline("Mail/shared/b.txt", b"b")]})) + + assert "'Documents/b.txt' is under none of this app's paths" in outside.json()["error"]["message"] + assert "'Mail/shared/b.txt' lead out of this app's paths through a link" in linked.json()["error"]["message"] + assert sorted(p.name for p in (home / "Mail").iterdir()) == ["shared"] and not any((home / "Documents").iterdir()) + - assert "'Documents/b.txt' is under none of this app's paths" in answer.json()["error"]["message"] - assert not (home / "Mail").exists() +@pytest.mark.parametrize("paths, problem", [ + (["/etc/mail"], "paths are normalized paths relative to the session user's home"), + (["Mail", "Mail/inbox"], "an app's paths each hold files of their own, but 'Mail' and 'Mail/inbox' overlap"), + (["Mail", "Mail"], "but 'Mail' and 'Mail' overlap"), +], ids=["absolute", "nested", "twice"]) +def test_paths_that_arent_relative_or_overlap_are_refused_where_theyre_declared(paths, problem): + with pytest.raises(ValueError, match=problem): + linux(paths=paths) @pytest.mark.asyncio @@ -175,6 +198,38 @@ async def load(self, parts: list) -> None: assert answer.json()["error"]["message"] == "add_data ran past the app's data_timeout_s, 0.2 s" +@pytest.mark.asyncio +async def test_calls_take_turns_and_one_past_its_timeout_fails_then_while_what_follows_waits_for_its_thread( + home, desktop): + @linux(launch=Launch(command=["slow"])) + class Slow(ComputerAppEnvironment): + data_timeout_s = 1.0 + + @add_data + def load(self, parts: list) -> None: + desktop.append(("load", len(parts))) + time.sleep(1.4 if len(parts) == 2 else 0) + desktop.append(("loaded", len(parts))) + + @reset_data + def wipe(self) -> None: + desktop.append(("reset",)) + + slow = Slow() + await slow._computer_app.launch_app() + + async with _http(slow) as http: + late = await http.post(RPC_PATH, json=_call("data/add", {"parts": [_inline("a", b"a"), _inline("b", b"b")]})) + desktop.append(("answered",)) + card = await http.get(WELL_KNOWN_PATH) + await _rpc(http, "data/add", {"parts": [_inline("c", b"c")]}) + + assert late.json()["error"]["message"] == "add_data ran past the app's data_timeout_s, 1 s" + assert card.status_code == 200 + assert desktop == [("launch", 4242), ("stop", 4242), ("load", 2), ("answered",), ("loaded", 2), ("reset",), + ("launch", 4248), ("stop", 4248), ("load", 1), ("loaded", 1), ("launch", 4252)] + + @pytest.mark.asyncio async def test_it_serves_its_skill_file_and_no_skill_without_one(home, tmp_path): (tmp_path / "folder" / "SKILL.md").write_text("---\ndescription: Reading and sending mail.\n---\n\n# Mail\nUse it.\n") diff --git a/packages/agentenv-protocol/tests/test_computer_app_server.py b/packages/agentenv-protocol/tests/test_computer_app_server.py index 7bab92747..7bd53b0b9 100644 --- a/packages/agentenv-protocol/tests/test_computer_app_server.py +++ b/packages/agentenv-protocol/tests/test_computer_app_server.py @@ -321,6 +321,18 @@ async def test_the_session_launches_an_app_in_a_session_of_its_own_from_its_fold assert (tmp_path / "seen").read_text() == f"{os.path.realpath(tmp_path)} notes :99 True" +@pytest.mark.asyncio +async def test_a_stop_ends_what_the_app_started_though_its_first_process_exited(tmp_path): + sleeper = f"{sys.executable} -c 'import time; time.sleep(60)' & exit 0" + pid = await session.launch(["sh", "-c", sleeper], tmp_path, {}) + await asyncio.gather(*session._running) + + running = session.alive(pid) + await session.stop(pid, 5) + + assert running and not session.alive(pid) + + def test_the_launcher_runs_the_app_through_its_own_server(tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) folder = Path("/opt/agentenv/apps/notes") From 80f5d301b7eddd23feabb2b2670f4812152ede93 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 31 Oct 2026 20:06:35 -0700 Subject: [PATCH 05/16] fix(computer): cancel an app's data call past its timeout, still waiting for its threads A shielded async hook that never ended held the app's turn for good, so every later data call and launch timed out. A call past data_timeout_s is now cancelled: what awaits stops at once, and a step run in a thread, a sync hook's or the defaults' disk work, is still waited for to its end. Co-Authored-By: Claude Opus 5.5 --- .../src/agentenv_protocol/computer/app.py | 13 +++++++------ .../src/agentenv_protocol/computer/app_data.py | 18 +++++++++++++++--- .../tests/test_computer_app_data.py | 14 +++++++++----- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py index 8b8bdeadb..f935eeae6 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py @@ -10,7 +10,6 @@ from __future__ import annotations import asyncio -import contextlib import functools import inspect import itertools @@ -555,8 +554,8 @@ def _computer_app_data_call(self, op: str) -> Optional[Callable]: OP_RESET_DATA: functools.partial(app_data.reset, self.paths)}[op] async def _computer_app_within(self, op: str, call: Callable, args: tuple, answer: asyncio.Future) -> Any: - """``call``'s result within ``data_timeout_s``; past it, ``answer`` fails at once, and this waits for the call - to end before it raises the same.""" + """``call``'s result within ``data_timeout_s``; past it, ``answer`` fails at once and the call is cancelled, + and this raises the same once it has ended: at once for what awaits, after any thread it runs.""" work = asyncio.ensure_future(_called(call, *args)) try: return await asyncio.wait_for(asyncio.shield(work), self.data_timeout_s) @@ -564,8 +563,10 @@ async def _computer_app_within(self, op: str, call: Callable, args: tuple, answe late = TimeoutError(f"{op} ran past the app's data_timeout_s, {self.data_timeout_s:g} s") if not answer.done(): answer.set_exception(late) - with contextlib.suppress(Exception): - await work + work.cancel() + await asyncio.wait([work]) + if not work.cancelled(): + work.exception() raise late from None def _computer_app_keep_initial(self) -> None: @@ -631,7 +632,7 @@ async def _called(fn: Callable, *args: Any) -> Any: """``fn``'s result, a sync one's from a thread, so it can't hold up the server.""" if inspect.iscoroutinefunction(fn): return await fn(*args) - result = await asyncio.to_thread(fn, *args) + result = await app_data.in_thread(fn, *args) return await result if inspect.isawaitable(result) else result diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py index e859e1dc9..9035754c0 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py @@ -5,8 +5,9 @@ import asyncio import shutil +from collections.abc import Callable from pathlib import Path, PurePosixPath -from typing import Optional +from typing import Any, Optional from ..transfers import WriteNamespaceGrant from ..types import FilePart, uploaded_object_path @@ -39,7 +40,7 @@ async def add(parts: list, paths: list[str]) -> None: if not any(PurePosixPath(path).is_relative_to(owned) for owned in paths): raise ValueError(f"{path!r} is under none of this app's paths: {', '.join(paths)}") files[path] = await part_bytes(part) - await asyncio.to_thread(_write, Path.home(), files, paths) + await in_thread(_write, Path.home(), files, paths) async def get(paths: list[str], write_namespace: Optional[WriteNamespaceGrant]) -> list: @@ -49,7 +50,18 @@ async def get(paths: list[str], write_namespace: Optional[WriteNamespaceGrant]) async def reset(paths: list[str]) -> None: """Put each of ``paths`` back as ``keep_initial`` kept it, or remove it where it wasn't there then.""" - await asyncio.to_thread(_restore, Path.home(), paths) + await in_thread(_restore, Path.home(), paths) + + +async def in_thread(fn: Callable, *args: Any) -> Any: + """``fn``'s result from a thread, which a cancelled call still waits for to the end, since a thread can't be + stopped.""" + work = asyncio.ensure_future(asyncio.to_thread(fn, *args)) + try: + return await asyncio.shield(work) + except asyncio.CancelledError: + await asyncio.wait([work]) + raise def _write(home: Path, files: dict[str, bytes], paths: list[str]) -> None: diff --git a/packages/agentenv-protocol/tests/test_computer_app_data.py b/packages/agentenv-protocol/tests/test_computer_app_data.py index ee63ac1cc..cbba68f62 100644 --- a/packages/agentenv-protocol/tests/test_computer_app_data.py +++ b/packages/agentenv-protocol/tests/test_computer_app_data.py @@ -2,8 +2,8 @@ the defaults: a load writes files under them, through no link out of them, an export returns them, and a reset puts back the copy its server kept before it served; its card declares a file tree. Hooks override paths. Around a load and a reset the app is stopped and relaunched, a failed load is followed by a reset, and data calls take turns. A call past -data_timeout_s fails then, a sync hook running in a thread, and its reset and relaunch, and the next call, wait for it -to end. The app serves its skill file.""" +data_timeout_s fails then and is cancelled, but a sync hook runs in a thread, which its reset and relaunch, and the +next call, wait for. The app serves its skill file.""" from __future__ import annotations import asyncio @@ -183,17 +183,21 @@ async def test_a_failed_load_is_followed_by_a_reset_and_the_app_is_relaunched(ho @pytest.mark.asyncio -async def test_a_call_past_its_timeout_fails_saying_so(home, desktop): +async def test_an_async_call_past_its_timeout_fails_saying_so_and_is_cancelled_so_the_next_takes_its_turn( + home, desktop): @linux(launch=Launch(command=["slow"])) class Slow(ComputerAppEnvironment): data_timeout_s = 0.2 @add_data async def load(self, parts: list) -> None: - await asyncio.sleep(5) + if len(parts) == 2: + await asyncio.Event().wait() async with _http(Slow()) as http: - answer = await http.post(RPC_PATH, json=_call("data/add", {"parts": [_inline("a.txt", b"a")]})) + answer = await http.post(RPC_PATH, json=_call("data/add", {"parts": [_inline("a.txt", b"a"), + _inline("b.txt", b"b")]})) + await _rpc(http, "data/add", {"parts": [_inline("c.txt", b"c")]}) assert answer.json()["error"]["message"] == "add_data ran past the app's data_timeout_s, 0.2 s" From e6297ad6286eacf40683cfe35b78c90eeb5518d5 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 31 Oct 2026 23:56:06 -0700 Subject: [PATCH 06/16] feat(computer): publish part_bytes for an app's data hooks, and document the app data plane An app's @add_data hook gets its parts as they were sent, inline or as a read grant; agentenv_protocol.computer.part_bytes returns a part's bytes either way, as the paths defaults read them. The protocol README now says how an app's server serves its data plane and skill and how the driver fronts its apps. Co-Authored-By: Claude Opus 5.5 --- packages/agentenv-protocol/README.md | 22 +++++++++++++---- .../agentenv_protocol/computer/__init__.py | 3 ++- .../tests/test_computer_app_data.py | 24 +++++++++++-------- .../computer/app/envs/clock/app.py | 4 ++-- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index fe1176eda..8cc9fb639 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -505,8 +505,8 @@ driver has them, through the `served_tools`, `served_extensions` and `served_ope or a `transfers.ReadObject` read grant under the marker's `read`, and writes them atomically. `data/get` returns the files under the `paths` it's given, relative to the home, or under the card's `default_data_paths` (`Desktop`, `Documents`, `Downloads`) when it's given none, inline or - uploaded under a `write_namespace` grant. An environment whose `@get_data` hook takes no `paths` - refuses them. + uploaded under a `write_namespace` grant, leaving out the paths its apps keep their data in. An + environment whose `@get_data` hook takes no `paths` refuses them. - **The card** (the name from `ENVIRONMENT_NAME`, then `@computer_driver`'s), and per-role tool access (`tool_access` is on). @@ -674,8 +674,10 @@ class MailApp(ComputerAppEnvironment): naming one the app declares no install for is refused when the class is read. A step reads `self.build_settings`; a deploy's `self.settings` aren't known at build, and reading one raises `AttributeError` (`python -m agentenv_protocol.computer.run_install_step : `). -- **Its skill** is a file of its folder (`skill = "skill.md"`) or an `@agent_skill` method, not both; - its data hooks are the usual `@add_data`, `@get_data` and `@reset_data`. +- **Its skill** is a file of its folder (`skill = "skill.md"`) or an `@agent_skill` method, not both. +- **Its data** is under the `paths` its OS's decorator names, relative to the session user's home, + none inside another (`@linux(..., paths=["Mail", ".config/mail/accounts.json"])`), or what its own + `@add_data`, `@get_data` and `@reset_data` hooks serve instead (below). - **`Settings`** is typed; a computer hands each app the values a deploy gives it, as one JSON value in `APP_SETTINGS_ENV` (`AGENTENV_APP_SETTINGS`, the apps' values by name), and the base class reads its own, by `ENVIRONMENT_NAME`, as `self.settings`. A `SecretStr` field is a secret. @@ -701,6 +703,18 @@ folder: the computer is ready once every app's server answers and each `boot` ap no launch, says it's ready; a `demand` app counts once something launches it. What the server does in the session (`agentenv_protocol.computer.session`) is Linux's. +The server serves the app's data plane and its skill. With `paths` alone, `data/add` writes the files +it's given under them and refuses any other path, or one a link leads out of them; `data/get` +returns the files there, a file tree; and `data/reset` puts them back as the server found them when +it first started on this boot, which is the app as installed. Hooks serve what they mark instead. +A hook's `data/add` parts come as they were sent, inline or as a read grant, and +`part_bytes(part)` returns a part's bytes either way. Either way the base class takes data calls and +launches one at a time, runs a load and a reset with the app stopped and relaunches it after, +follows a failed load with a reset, and fails a call that runs past `data_timeout_s` (300 s); a sync +hook runs in a thread, which the reset and relaunch after it wait for. The driver fronts its apps: +its card lists each one's card as a child environment, rooted at `/svc//`, where it forwards +requests to the app's server. + `MailApp.declaration()` returns what the class declares, an `AppDeclaration`, which a `computer_app` env stores, and `python -m agentenv_protocol.computer._declare ` prints it as JSON, or a driver's `DriverDeclaration`, without the class's own dependencies installed. diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/__init__.py index 5ffb4ed01..586e962cc 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/__init__.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/__init__.py @@ -67,6 +67,7 @@ type_text, write_files, ) +from .dataplane import part_bytes from .keys import PRINTABLE_KEYS from .manifest import ManifestSegment, RecordingManifest from .platform import Platform @@ -107,7 +108,7 @@ def __getattr__(name: str) -> Any: "ComputerAppEnvironment", "computer_app", "install", "linux", "install_step", "ready_check", "Linux", "Service", "Launch", "Ready", "Packages", "AptSource", "Download", "RunScript", "AppDeclaration", "AppOs", - "APP_SETTINGS_ENV", + "APP_SETTINGS_ENV", "part_bytes", "Display", "Image", "Effect", "ExecResult", "ReadyCheck", "Platform", "ComputerSettings", "SETTINGS_ENV", "PRINTABLE_KEYS", "display", "set_display", "capture", "click", "drag", "scroll", "type_text", "press", diff --git a/packages/agentenv-protocol/tests/test_computer_app_data.py b/packages/agentenv-protocol/tests/test_computer_app_data.py index cbba68f62..d0ec10597 100644 --- a/packages/agentenv-protocol/tests/test_computer_app_data.py +++ b/packages/agentenv-protocol/tests/test_computer_app_data.py @@ -1,9 +1,9 @@ """A computer app's data plane, served by its own server. An app that declares only paths, none inside another, gets the defaults: a load writes files under them, through no link out of them, an export returns them, and a reset puts back -the copy its server kept before it served; its card declares a file tree. Hooks override paths. Around a load and a -reset the app is stopped and relaunched, a failed load is followed by a reset, and data calls take turns. A call past -data_timeout_s fails then and is cancelled, but a sync hook runs in a thread, which its reset and relaunch, and the -next call, wait for. The app serves its skill file.""" +the copy its server kept before it served; its card declares a file tree. Hooks override paths, and read the parts they +get with part_bytes. Around a load and a reset the app is stopped and relaunched, a failed load is followed by a reset, +and data calls take turns. A call past data_timeout_s fails then and is cancelled, but a sync hook runs in a thread, +which its reset and relaunch, and the next call, wait for. The app serves its skill file.""" from __future__ import annotations import asyncio @@ -19,7 +19,7 @@ import httpx # noqa: E402 from agentenv_protocol import add_data, client, reset_data # noqa: E402 -from agentenv_protocol.computer import ComputerAppEnvironment, Launch, linux # noqa: E402 +from agentenv_protocol.computer import ComputerAppEnvironment, Launch, linux, part_bytes # noqa: E402 from agentenv_protocol.computer import app, app_runtime, session # noqa: E402 from agentenv_protocol.computer.app import COMPUTER_APP # noqa: E402 from agentenv_protocol.computer.platform import Platform # noqa: E402 @@ -36,14 +36,14 @@ class PathsMail(ComputerAppEnvironment): @linux(paths=MAIL, launch=Launch(command=["mail"])) class HookedMail(ComputerAppEnvironment): - loaded: list + loaded: dict resets = 0 @add_data async def load(self, parts: list) -> None: - if any(part["metadata"][DATA_OBJECTS_EXTENSION_URI]["path"] == "bad" for part in map(dict, parts)): + if any(part.metadata[DATA_OBJECTS_EXTENSION_URI]["path"] == "bad" for part in parts): raise RuntimeError("the seed is bad") - self.loaded = parts + self.loaded = {part.metadata[DATA_OBJECTS_EXTENSION_URI]["path"]: await part_bytes(part) for part in parts} @reset_data def wipe(self) -> None: @@ -136,10 +136,14 @@ def test_paths_that_arent_relative_or_overlap_are_refused_where_theyre_declared( @pytest.mark.asyncio -async def test_hooks_override_its_paths(home): - async with _http(HookedMail()) as http: +async def test_hooks_override_its_paths_and_read_each_part_they_get_with_part_bytes(home): + mail = HookedMail() + + async with _http(mail) as http: card = (await http.get(WELL_KNOWN_PATH)).json() + await _rpc(http, "data/add", {"parts": [_inline("Mail/a.txt", b"a")]}) + assert mail.loaded == {"Mail/a.txt": b"a"} and not (home / "Mail").exists() assert sorted(card["capabilities"]["operations"]) == ["data/add", "data/reset"] assert client.find_extension(card, INTAKE_EXTENSION_URI) is None assert HookedMail.declaration().data == ["data/add", "data/reset"] diff --git a/tst/integration/computer/app/envs/clock/app.py b/tst/integration/computer/app/envs/clock/app.py index 11bd4344c..54289b02f 100644 --- a/tst/integration/computer/app/envs/clock/app.py +++ b/tst/integration/computer/app/envs/clock/app.py @@ -7,8 +7,8 @@ from pydantic import BaseModel from agentenv_protocol import DataPart, add_data, get_data, reset_data -from agentenv_protocol.computer import ComputerAppEnvironment, Packages, RunScript, Service, computer_app, linux -from agentenv_protocol.computer.dataplane import part_bytes +from agentenv_protocol.computer import (ComputerAppEnvironment, Packages, RunScript, Service, computer_app, linux, + part_bytes) PORT = 8137 ALARMS = ".clock-alarms.json" From c5cc76f6415a2bfcd6df0579756d8e8418447cb8 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 00:03:11 -0700 Subject: [PATCH 07/16] feat(bundle): check a seed against a bundle's own computer env and its apps A computer env now checks its env.toml before anything is written (accept_toml), and a computer env's and a computer app env's environment_name default to their folder's there too, so the bundle check knows a computer env's name and its apps' and refuses a seed named after none of them, as it does for an MCP server's. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/bundle/plan.py | 5 ++- src/agent_env/env/envs/computer/app_env.py | 13 ++++---- src/agent_env/env/envs/computer/env.py | 24 ++++++++----- tst/unit/bundle/computer_env_folder_test.py | 37 ++++++++++++--------- 4 files changed, 47 insertions(+), 32 deletions(-) diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index a2a833574..bdea42432 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -972,12 +972,15 @@ def _server_names(self, ref: Reference, store_latest: dict[tuple[EntityKind, str of a multi's envs', or a computer's own and each of its apps'. None when they aren't all known.""" if isinstance(ref.local, BundleEntry): cls = get_env_registry().get(ref.local.type) + children = self.nodes[_key(ref.local)].references if cls is not None and issubclass(cls, MultiEnv): - children = self.nodes[_key(ref.local)].references names = {self._environment_name(child, store_latest) for child in children if child.kind is EntityKind.ENV} else: names = {self.accepted.get(_key(ref.local), {}).get("environment_name")} + if cls is not None and issubclass(cls, ComputerEnv): + names |= {self._environment_name(child, store_latest) for child in children + if child.kind is EntityKind.ENV and child.artifact_type == ComputerAppEnv.type} return None if None in names else names if ref.local is not None or store_latest is None: return None diff --git a/src/agent_env/env/envs/computer/app_env.py b/src/agent_env/env/envs/computer/app_env.py index 05499e0b8..af60f796c 100644 --- a/src/agent_env/env/envs/computer/app_env.py +++ b/src/agent_env/env/envs/computer/app_env.py @@ -145,17 +145,18 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> ComputerAppEnv: driver = ctx.env(fields["driver"], ComputerDriverEnv) if "driver" in fields else None universe = FileArtifactUniverse.put_bundled(derive_id(ctx.id, "files"), files=files) try: - return cls.put(id=ctx.id, environment_name=fields.get("environment_name", ctx.name), - module=fields["module"], files=universe, declaration=declaration, computer=computer, - driver=driver, build_settings=fields.get("build_settings"), composing=ctx.composing) + return cls.put(id=ctx.id, environment_name=fields["environment_name"], module=fields["module"], + files=universe, declaration=declaration, computer=computer, driver=driver, + build_settings=fields.get("build_settings"), composing=ctx.composing) except ValueError as e: ctx.refuse([str(e)]) @classmethod def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: """The keys of ``data``, an env.toml, a computer app env takes: ``module = ":"``, naming a file of - its folder; ``environment_name``; the ``computer`` and ``driver`` it deploys alone on; and ``build_settings``, - the values of its build settings. Raises BundleError listing every problem.""" + its folder; ``environment_name``, the folder's when it sets none; the ``computer`` and ``driver`` it deploys + alone on; and ``build_settings``, the values of its build settings. Raises BundleError listing every + problem.""" fields, refused = ctx.accepted(data, environment_name=str, module=str, computer=object, driver=object, build_settings=dict) files, module, problems = ctx.files(), fields.get("module"), [] @@ -166,7 +167,7 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: f"{', '.join(sorted(files))}") if refused or problems: ctx.refuse([*refused, *map(ctx.config_problem, problems)]) - return fields + return {"environment_name": ctx.name, **fields} def deploy_refusal(self, *, sandbox_type: str | None = None, env_params: Optional[dict] = None, **options) -> str | None: diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index 6c33edf2c..93a749801 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -113,22 +113,28 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> ComputerEnv: """Write the computer env an env.toml declares, its base, driver and apps as the bundle wrote them or the ones they name, its environment_name the folder's when it sets none. An app's ``install`` is ``"build"``, the default: it's built into the computer.""" - fields, refused = ctx.accepted(data, environment_name=str, computer=object, driver=object, apps=list, - display=dict, network=dict, computer_settings=dict) - missing = [ctx.config_problem(f"{key} is required: the {what}, such as {example}") for key, what, example in ( - ("computer", "base computer", '"ubuntu-24.04"'), ("driver", "driver", '"reference"')) if key not in data] - if refused or missing: - ctx.refuse([*refused, *missing]) + fields = cls.accept_toml(data, ctx) base, driver = ctx.artifact(fields.pop("computer"), ComputerArtifact), ctx.env(fields.pop("driver"), ComputerDriverEnv) fields["apps"] = _listed_apps(fields.get("apps", []), ctx) try: - return cls.put(id=ctx.id, computer=base, driver=driver, - environment_name=fields.pop("environment_name", ctx.name), composing=ctx.composing, - **fields) + return cls.put(id=ctx.id, computer=base, driver=driver, composing=ctx.composing, **fields) except ValueError as e: ctx.refuse([str(e)]) + @classmethod + def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: + """The keys of ``data``, an env.toml, a computer env takes: the ``computer`` and ``driver`` it's composed of, + both required, its ``apps``, ``display``, ``network`` and ``computer_settings``, and ``environment_name``, the + folder's when it sets none. Raises BundleError listing every problem.""" + fields, refused = ctx.accepted(data, environment_name=str, computer=object, driver=object, apps=list, + display=dict, network=dict, computer_settings=dict) + missing = [ctx.config_problem(f"{key} is required: the {what}, such as {example}") for key, what, example in ( + ("computer", "base computer", '"ubuntu-24.04"'), ("driver", "driver", '"reference"')) if key not in data] + if refused or missing: + ctx.refuse([*refused, *missing]) + return {"environment_name": ctx.name, **fields} + def composed_apps(self) -> list[ComputerAppEnv]: """The computer app envs composed into the env's computer, each installed under its prefix.""" return self.apps diff --git a/tst/unit/bundle/computer_env_folder_test.py b/tst/unit/bundle/computer_env_folder_test.py index d685c5c6a..33efd2499 100644 --- a/tst/unit/bundle/computer_env_folder_test.py +++ b/tst/unit/bundle/computer_env_folder_test.py @@ -157,25 +157,30 @@ def test_a_computer_env_folder_names_its_apps_in_order_each_written_before_it(tm assert composed == [(env.id, UBUNTU, REFERENCE, None)] -def test_a_universe_loaded_into_a_stored_computer_env_seeds_its_files_and_each_of_its_apps_by_name(tmp_path, composed): - plan = _plan(_bundle(tmp_path / "desk", WITH_APPS, APPS)) - materialize(plan, build_here=local_builds(plan)) - world = {"artifacts/world/artifact.toml": 'type = "environment_universe"\n', - "artifacts/world/desktop/a.json": "{}", "artifacts/world/mail/a.json": "{}"} - - def loading(root, *extra): - return layout(root, {**world, **{f"artifacts/world/{name}/a.json": "{}" for name in extra}, "tasks/t.json": ( - json.dumps([{"id": "load", "type": "load_artifact", "env_id": f"{plan.bundle.bundle.id_root}/desktop", - "artifact_id": "world"}]))}) - - plan_bundle(resolve_bundle(parse_bundle(loading(tmp_path / "seeds")))) +@pytest.mark.parametrize("stored", [False, True], ids=["in-the-bundle", "in-the-store"]) +def test_a_universe_loaded_into_a_computer_env_seeds_its_files_and_each_of_its_apps_by_their_folders_names( + tmp_path, composed, stored): + if stored: + plan = _plan(_bundle(tmp_path / "desk", WITH_APPS, APPS)) + materialize(plan, build_here=local_builds(plan)) + env_id, folders = f"{plan.bundle.bundle.id_root}/desktop", {} + into = f"env '{env_id}'" + else: + env_id, folders, into = "desktop", {"envs/desktop/env.toml": WITH_APPS, **APPS}, "envs/desktop" + + def loading(root, *seeded): + return layout(root, {**folders, "artifacts/world/artifact.toml": 'type = "environment_universe"\n', + **{f"artifacts/world/{name}/a.json": "{}" for name in ("desktop", "mail", *seeded)}, + "tasks/t.json": json.dumps([{"id": "load", "type": "load_artifact", "env_id": env_id, + "artifact_id": "world"}])}) + + _plan(loading(tmp_path / "seeds")) with pytest.raises(BundleError) as refused: - plan_bundle(resolve_bundle(parse_bundle(loading(tmp_path / "typo", "mial")))) + _plan(loading(tmp_path / "typo", "mial")) assert refused.value.problems == ( - f"tasks/t.json: step 'load': artifacts/world/mial seeds an environment named 'mial', and env " - f"'{plan.bundle.bundle.id_root}/desktop' has no server by that name ('desktop', 'mail', 'notes'); name it " - "after the server it seeds, or leave it out",) + f"tasks/t.json: step 'load': artifacts/world/mial seeds an environment named 'mial', and {into} has no server " + "by that name ('desktop', 'mail', 'notes'); name it after the server it seeds, or leave it out",) @pytest.mark.parametrize("toml, problem", [ From c6adc7325b4db5a298596aeb27b7b66758cc0d33 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 00:57:44 -0700 Subject: [PATCH 08/16] feat(computer)!: only agent-env reaches a computer's framework-only routes agent-env boots each computer with a per-deploy control token and keeps it on the deployment record. With the token set, the SDK refuses a call that doesn't carry it on every route but the MCP endpoint and the card: the data plane, every extension route, tool access, and the driver's app routes. agent-env's own calls send it; env_outcome_verifier runs a task's verify() under a grant of it; a run_code script inside the computer gets it as input["env"]["headers"]; the conformance probe takes it and checks a framework-only route refuses a call without it. An env booted without a token is served as before. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- packages/agentenv-protocol/README.md | 21 ++- .../src/agentenv_protocol/__init__.py | 2 + .../agent_env_environment.py | 27 +++- .../src/agentenv_protocol/client.py | 70 +++++++-- .../computer/conformance/__init__.py | 8 +- .../computer/conformance/__main__.py | 10 +- .../computer/conformance/checks.py | 21 ++- .../computer/conformance/client.py | 12 +- .../src/agentenv_protocol/computer/driver.py | 5 +- .../agentenv_protocol/computer/serve_app.py | 12 +- .../src/agentenv_protocol/types.py | 3 + .../tests/test_computer_app_server.py | 29 +++- .../tests/test_computer_conformance.py | 18 ++- .../tests/test_computer_serve.py | 8 +- .../tests/test_control_token.py | 147 ++++++++++++++++++ src/agent_env/env/env.py | 11 +- src/agent_env/env/envs/computer/utils/boot.py | 24 ++- .../env/envs/computer/utils/composer.py | 12 +- .../env/envs/computer/utils/inside.py | 12 +- .../env_providers/env_computer_provider.py | 28 ++-- .../task_steps/apply_server_config.py | 2 +- .../task_step/task_steps/post_turn_hooks.py | 3 +- .../task_step/task_steps/run_code.py | 4 +- .../task_step/task_steps/snapshot_env.py | 20 ++- .../task_step/task_steps/sync_env_clock.py | 2 +- .../verifiers/env_outcome_verifier.py | 8 +- .../computer/app/artifacts/check_desk/run.py | 4 +- .../computer/app/artifacts/check_notes/run.py | 4 +- .../computer/app/artifacts/reset_apps/run.py | 6 +- tst/integration/computer/computer_env_test.py | 89 ++++++++++- tst/unit/computer_home.py | 17 +- tst/unit/env/envs/test_computer_env.py | 38 +++-- tst/unit/env/test_env.py | 13 ++ tst/unit/store/aws/test_shared_credentials.py | 8 +- tst/unit/task_step/add_skills_env_test.py | 6 +- .../snapshot_env_upload_grant_test.py | 2 +- .../snapshot_env_without_gateway_test.py | 24 ++- tst/unit/task_step/sync_env_clock_test.py | 12 +- .../task_step/test_apply_server_config.py | 10 +- .../task_step/test_prompt_agent_triggers.py | 36 ++--- tst/unit/task_step/test_run_code.py | 10 +- tst/unit/task_step/test_snapshot_env.py | 14 +- .../test_env_outcome_verifier_step.py | 3 +- .../task_step/verifiers/test_run_context.py | 25 +-- 45 files changed, 677 insertions(+), 165 deletions(-) create mode 100644 packages/agentenv-protocol/tests/test_control_token.py diff --git a/AGENTS.md b/AGENTS.md index 3adbc5780..cb46bb888 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,7 +116,7 @@ and deserialized through a registry. |---|---| | `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset`, `close` and `load_file_artifact_universe`; teardown closes each env a run deployed before terminating its sandboxes by id. 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`, `env/envs/computer/driver_env.py`: 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.]` (`env/envs/computer/utils/install.py`): files, apt `packages` with extra `sources`, a `python` requirements file for a venv, `downloads` by URL and sha256, `run` scripts, the services the base's init runs, and `ports`; it deploys only as a computer env's driver), `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 on a builder of the put's sandbox provider, by default the env's (`utils/builder.py`; `--sandbox` on `env build`, `bundle register` and `run`): this machine's Docker for `local`, else a VM of the provider, which fetches the put's stored files by signed URL, takes the rest inline and pushes to the image store itself, so it builds only with stores a remote VM reaches; each arch goes to the first link that runs it natively, else one that emulates it (a VM registers QEMU; `--no-emulate` leaves it `skipped` instead, for a later put), and its pair records the provider that built it (`builder`); a run or register that reuses a computer env composes what it has left (`pending`); tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile, build context and lock so any env with the same inputs reuses it; every layer's packages, requirements and downloads are resolved at put in a container of the base for each arch (`utils/packages.py`, `utils/resolve_in_base.py`), stored in the object store by sha256 under `agentenv-packages/`, and listed in a lock stored by a digest of its inputs, which any env with the same inputs reuses, and builds install only the lock's files, every install step `RUN --network=none`; 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; gate 3 then boots each native build on the put's sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`, which declares each OS with its own decorator (`@linux(...)`, or `@install(os_type, spec)` for any OS type) holding its install, its launch and, optionally, a skill and settings and build settings that add to the class's there, and install steps and ready checks for every OS or those their `os=` names; everything that reads its settings, launch or steps asks for the computer's OS type; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference; its env.toml's `build_settings`, values of its `BuildSettings` checked at put, are written to `.agentenv/build_settings.json` in its folder, so they're part of the image's key, its install steps read them, and a deploy can't change them). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `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`: its environment's files by path, stored as the one wrapped `file_artifact_ref` every environment artifact had when it has one file at its filename, else as `file_artifact_refs`; `get_file_artifacts()` reads either, and a load hands each file to the env's `data/add` with its path, which a pre-v1 load, taking one file, refuses), `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`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each env still up closes (`Env.close`; a computer env ends any recording still open), what stayed open is registered from its namespace, then every sandbox is terminated by id. `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). | +| `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`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. A computer's framework-only routes (everything but MCP and the card) take the per-deploy control token its provider boots it with (`DeployedEnv.control_token`, `AGENTENV_CONTROL_TOKEN`); framework calls send `deployed.control_headers`, a `verify` runs under `protocol_v1.authorized`, and a `run_code` script inside the computer gets `input["env"]["headers"]`. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each env still up closes (`Env.close`; a computer env ends any recording still open), what stayed open is registered from its namespace, then every sandbox is terminated by id. `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB, DynamoDB, Firestore with MongoDB compatibility), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. `store/routing.py` is the `@local` namespace: while the CLI or a `build_app` server runs, an `@local/…` id and what an `@local` task's run records go to local stores beside the configured ones, and that run's writes to the configured stores are refused. A new backend must pass the conformance kits in `tst/store/`. | | `config/` | The `Config` singleton (`get_config`, `configure`, `reset_config`) in `config/runtime.py`, file discovery in `config/loader.py`, and `load_impl`, which resolves `module:Class` pointers. `agent_env.store` re-exports the config names for compatibility. | | `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra), `smol_vm` (the `smol` extra), `tensorlake` (the `tensorlake` extra), `tenki` (the `tenki` extra), `vercel` (the `vercel` extra), `boxd` (the `boxd` extra), `coreweave` (the `coreweave` extra), `daytona` (the `daytona` extra), `boat` (the `boat` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, and for VMs on this machine the address their requests to it arrive at, `REACHES_HOST_AT`; `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store), `EnvironmentGatewayProvider` (`gateway`, an env's default), which renders a docker-compose for the gateway and its MCP servers inside the sandbox, and `EnvironmentServerProvider` (`server`), one MCP server in its own container with no gateway in front and no state store; `providers/env_state/` holds env-state providers (`local_postgres` built in). | diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index 8cc9fb639..1fa1e4619 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -227,6 +227,22 @@ async def display_get(self) -> dict: ... async def display_set(self, width: int, height: int) -> dict: ... ``` +### Framework-only routes and the control token + +Everything an environment serves but its MCP endpoint and its card is agent-env's own: the data +plane, every `@extension` route and the tool-access routes. When agent-env deploys an environment +that takes one, it boots it with a per-deploy token in `AGENTENV_CONTROL_TOKEN` +(`CONTROL_TOKEN_ENV`), and the SDK then refuses a call to those routes that doesn't carry it as +`Authorization: Bearer `, answering 401 with the code `unauthorized` before the route runs. +The MCP endpoint and the card stay open, so an agent reaches the environment's tools as before and +nothing else. Without the variable, as when the environment runs on its own, every route is open. +`control_guard()` wraps a route an environment adds itself the same way, as a computer driver does +its `/svc//` routes. + +On the client side each call takes `headers`, and `client.control_headers(token)` builds them. +Code that names none, a task's verifier say, runs under `client.authorized({env_url: token})`: +within it, a call to an address under `env_url` carries that token. + ### Tool access without a gateway An environment agents reach directly, with no gateway in front, can enforce per-role tool access @@ -588,7 +604,10 @@ report = await run_probe("http://localhost:18765", "full") # a ProbeReport failed = [c for c in report.checks if c.status == "failed"] ``` -The CLI exits 0 when no check failed, 1 when one did and 2 when it can't reach the driver. +The CLI exits 0 when no check failed, 1 when one did and 2 when it can't reach the driver. A +driver booted with a control token is probed with it: `--token`, by default +`$AGENTENV_CONTROL_TOKEN`, or `run_probe(..., token=...)`. Given one, the probe also checks that +`computer-ready`'s status refuses a call without it and with a wrong one. | Level | Checks | Needs on the computer | |---|---|---| diff --git a/packages/agentenv-protocol/src/agentenv_protocol/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/__init__.py index b081ab73c..61f9cbab9 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/__init__.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/__init__.py @@ -53,6 +53,7 @@ manifest_compatible, ) from .types import ( + CONTROL_TOKEN_ENV, DATA_OBJECTS_EXTENSION_URI, INTAKE_EXTENSION_URI, MCP_PATH, @@ -161,6 +162,7 @@ "ExtensionCallError", "PROTOCOL_VERSION", "RPC_PATH", + "CONTROL_TOKEN_ENV", "WELL_KNOWN_PATH", "METHOD_RESET", "METHOD_ADD", diff --git a/packages/agentenv-protocol/src/agentenv_protocol/agent_env_environment.py b/packages/agentenv-protocol/src/agentenv_protocol/agent_env_environment.py index c88634844..de6450ae1 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/agent_env_environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/agent_env_environment.py @@ -5,6 +5,7 @@ import decimal import enum import functools +import hmac import inspect import json import logging @@ -28,7 +29,7 @@ from .tool_access import extensions as tool_access_extensions from .tool_context import EMPTY, ToolContext, bound, context_from_http, context_from_mcp from .transfers import WriteNamespaceGrant -from .types import DATA_OBJECTS_EXTENSION_URI, MCP_TRANSPORT, SKILL_EXTENSION_URI, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH, AddDataRequest, AddDataResponse, EnvironmentCapabilities, EnvironmentCard, EnvironmentExtension, EnvironmentInterface, EnvironmentTool, GetDataResponse, ResetDataResponse, error_body +from .types import CONTROL_TOKEN_ENV, DATA_OBJECTS_EXTENSION_URI, MCP_TRANSPORT, SKILL_EXTENSION_URI, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH, AddDataRequest, AddDataResponse, EnvironmentCapabilities, EnvironmentCard, EnvironmentExtension, EnvironmentInterface, EnvironmentTool, GetDataResponse, ResetDataResponse, error_body logger = logging.getLogger(__name__) @@ -343,10 +344,11 @@ def add_routes_to_app(self, app: Any, *, rpc_url: str = RPC_PATH, card_url: str if isinstance(self.handler, AgentEnvEnvironment): self.handler._application = self self.environment_card = self._card_for(app) - self._add_route(app, rpc_url, ["POST"], self._dispatch) + guard = control_guard() + self._add_route(app, rpc_url, ["POST"], guard(self._dispatch)) self._add_route(app, card_url, ["GET"], self._serve_card) for path, method, handler in self._ext_routes: - self._add_route(app, path, [method], handler) + self._add_route(app, path, [method], guard(handler)) for descriptor, fn in self._tools: self._register_tool(app, descriptor, fn) for resource in self._resources: @@ -1544,6 +1546,25 @@ def _error_response(error: AgentEnvError) -> Response: return _json_response(error_body(**error.fields()), error.status) +def control_guard() -> Callable[[Callable], Callable]: + """What wraps a framework-only route, everything an env serves but its MCP endpoint and its card: with + ``CONTROL_TOKEN_ENV`` set, a call that doesn't carry that token as its bearer is refused before the route runs; + without it, nothing.""" + token = os.environ.get(CONTROL_TOKEN_ENV) + if not token: + return lambda handler: handler + expected = f"Bearer {token}".encode() + + def guard(handler: Callable) -> Callable: + async def guarded(request: Request) -> Response: + if not hmac.compare_digest(request.headers.get("authorization", "").encode(), expected): + return _error_response(AgentEnvError( + "unauthorized", "this route is agent-env's own: it takes the env's control token", status=401)) + return await handler(request) + return guarded + return guard + + def _tool_access_handler(access: ToolAccess, action: str) -> Callable: async def handler(request: Request) -> Response: raw = await request.body() diff --git a/packages/agentenv-protocol/src/agentenv_protocol/client.py b/packages/agentenv-protocol/src/agentenv_protocol/client.py index 0d0bfe648..400e87399 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/client.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/client.py @@ -1,7 +1,12 @@ -"""Data-plane wire protocol client (JSON-RPC) for servers implementing the AgentEnv protocol.""" +"""Data-plane wire protocol client (JSON-RPC) for servers implementing the AgentEnv protocol. A call to an env's +framework-only routes carries its control token: as the call's ``headers`` (``control_headers``), or, for code that +names none, from the grant it runs under (``authorized``).""" from __future__ import annotations +import contextlib import logging +from collections.abc import Iterator +from contextvars import ContextVar import httpx @@ -11,6 +16,9 @@ logger = logging.getLogger(__name__) +#: The control token of each env address the running code is granted, by address. +_GRANTS: ContextVar[dict[str, str]] = ContextVar("agentenv_control_grants", default={}) + class ExtensionCallError(httpx.HTTPStatusError): """A non-2xx answer from an extension method, with the error fields the server sent. A body without them @@ -35,10 +43,39 @@ def _error_fields(response: httpx.Response) -> dict: return error if isinstance(error, dict) else {} -async def _rpc(base_url: str, method: str, params: dict, timeout: int, verify: bool) -> dict: +def control_headers(token: str | None) -> dict[str, str]: + """The headers that carry an env's control token to its framework-only routes; none without one.""" + return {"Authorization": f"Bearer {token}"} if token else {} + + +@contextlib.contextmanager +def authorized(grants: dict[str, str | None]) -> Iterator[None]: + """Within it, a call to an address under one of ``grants``, an env's address and its control token, carries + that token when the call names no headers of its own: how code agent-env runs against an env, a verifier's say, + reaches its framework-only routes as written.""" + granted = _GRANTS.set({**_GRANTS.get(), + **{url.rstrip("/"): token for url, token in grants.items() if url and token}}) + try: + yield + finally: + _GRANTS.reset(granted) + + +def request_headers(url: str, headers: dict[str, str] | None = None) -> dict[str, str]: + """What a call to ``url`` sends: ``headers`` when it names them, else the control token its grant gives + (``authorized``), if any.""" + if headers is not None: + return headers + return next((control_headers(token) for base, token in _GRANTS.get().items() + if url == base or url.startswith(f"{base}/")), {}) + + +async def _rpc(base_url: str, method: str, params: dict, timeout: int, verify: bool, + headers: dict[str, str] | None) -> dict: request = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params} + url = f"{base_url}{RPC_PATH}" async with httpx.AsyncClient(verify=verify) as client: - response = await client.post(f"{base_url}{RPC_PATH}", json=request, timeout=timeout) + response = await client.post(url, json=request, timeout=timeout, headers=request_headers(url, headers)) response.raise_for_status() body = response.json() if "error" in body: @@ -47,18 +84,20 @@ async def _rpc(base_url: str, method: str, params: dict, timeout: int, verify: b return body["result"] -async def reset_data(base_url: str, timeout: int = 30, verify: bool = True) -> ResetDataResponse: - return ResetDataResponse.model_validate(await _rpc(base_url, METHOD_RESET, {}, timeout, verify)) +async def reset_data(base_url: str, timeout: int = 30, verify: bool = True, *, + headers: dict[str, str] | None = None) -> ResetDataResponse: + return ResetDataResponse.model_validate(await _rpc(base_url, METHOD_RESET, {}, timeout, verify, headers)) -async def add_data(base_url: str, parts: list[Part], timeout: int = 120, verify: bool = True) -> AddDataResponse: +async def add_data(base_url: str, parts: list[Part], timeout: int = 120, verify: bool = True, *, + headers: dict[str, str] | None = None) -> AddDataResponse: params = {"parts": [p.model_dump(mode="json", exclude_none=True) for p in parts]} - return AddDataResponse.model_validate(await _rpc(base_url, METHOD_ADD, params, timeout, verify)) + return AddDataResponse.model_validate(await _rpc(base_url, METHOD_ADD, params, timeout, verify, headers)) async def get_data( base_url: str, timeout: int = 30, verify: bool = True, *, write_namespace: WriteNamespaceGrant | None = None, - paths: list[str] | None = None, + paths: list[str] | None = None, headers: dict[str, str] | None = None, ) -> GetDataResponse: """`data/get`. A server advertising `DATA_OBJECTS_EXTENSION_URI` may be handed `write_namespace` to upload its export under; `uploaded_object_path` reads from the answer where it did. `paths` names what a computer @@ -66,7 +105,7 @@ async def get_data( params = {} if write_namespace is None else {"write_namespace": write_namespace.model_dump(mode="json", exclude_none=True)} if paths is not None: params["paths"] = paths - return GetDataResponse.model_validate(await _rpc(base_url, METHOD_GET, params, timeout, verify)) + return GetDataResponse.model_validate(await _rpc(base_url, METHOD_GET, params, timeout, verify, headers)) async def get_card(base_url: str, timeout: int = 10, verify: bool = True) -> dict: @@ -174,7 +213,8 @@ def find_child(card: dict, name: str) -> dict | None: return None -async def get_interface_manifest(base_url: str, interface: str, endpoint: str = INTERFACE_MANIFEST_PATH, timeout: int = 30, verify: bool = True): +async def get_interface_manifest(base_url: str, interface: str, endpoint: str = INTERFACE_MANIFEST_PATH, timeout: int = 30, verify: bool = True, *, + headers: dict[str, str] | None = None): """Fetch one interface's manifest from a server's manifest index endpoint. ``endpoint`` is the index path — pass the one advertised on the card under @@ -185,12 +225,13 @@ async def get_interface_manifest(base_url: str, interface: str, endpoint: str = """ url = f"{base_url.rstrip('/')}{endpoint}/{interface}" async with httpx.AsyncClient(verify=verify) as client: - response = await client.get(url, timeout=timeout) + response = await client.get(url, timeout=timeout, headers=request_headers(url, headers)) response.raise_for_status() return response.json() -async def invoke_extension(base_url: str, card: dict, uri: str, params: dict | None = None, timeout: int = 30, verify: bool = True, *, method: str | None = None): +async def invoke_extension(base_url: str, card: dict, uri: str, params: dict | None = None, timeout: int = 30, verify: bool = True, *, method: str | None = None, + headers: dict[str, str] | None = None): """Invoke a card extension via its advertised REST endpoint. Reads the endpoint and HTTP verb of the extension method named `method` from the card, so the @@ -209,11 +250,12 @@ async def invoke_extension(base_url: str, card: dict, uri: str, params: dict | N if not op["endpoint"]: raise ValueError(f"extension {uri} has no endpoint in params") url = f"{base_url}{op['endpoint']}" + sent = request_headers(url, headers) async with httpx.AsyncClient(verify=verify) as client: if op["method"] == "GET": - response = await client.get(url, params=params or {}, timeout=timeout) + response = await client.get(url, params=params or {}, timeout=timeout, headers=sent) else: - response = await client.request(op["method"], url, json=params or {}, timeout=timeout) + response = await client.request(op["method"], url, json=params or {}, timeout=timeout, headers=sent) if response.status_code >= 400: raise ExtensionCallError(response) return response.json() diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/__init__.py index 58cb6c6db..b102b1be4 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/__init__.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/__init__.py @@ -11,9 +11,11 @@ __all__ = ["run_probe", "ProbeReport", "CheckResult", "Level", "Status", "Verdict"] -async def run_probe(url: str, level: Level = "full", *, timeout_s: float = 60, ready_timeout_s: float = 60) -> ProbeReport: - """Probe the driver serving its card at ``url``. ``ready_timeout_s`` is how long it may take to report ready.""" - driver = await Driver.connect(url, timeout_s) +async def run_probe(url: str, level: Level = "full", *, timeout_s: float = 60, ready_timeout_s: float = 60, + token: str | None = None) -> ProbeReport: + """Probe the driver serving its card at ``url``, sending ``token``, its control token, to its framework-only routes. + ``ready_timeout_s`` is how long it may take to report ready.""" + driver = await Driver.connect(url, timeout_s, token) async with driver.session() as session: probe = Probe(driver, session, ready_timeout_s) await probe.basic() diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/__main__.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/__main__.py index 06fd2ad51..bf502a101 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/__main__.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/__main__.py @@ -1,12 +1,15 @@ """``python -m agentenv_protocol.computer.conformance ``: probe a running driver, print each check, and -exit 1 when one failed or 2 when the driver couldn't be reached.""" +exit 1 when one failed or 2 when the driver couldn't be reached. ``--token`` is its control token, by default +``CONTROL_TOKEN_ENV``'s.""" from __future__ import annotations import argparse import asyncio +import os import sys from pathlib import Path +from ...types import CONTROL_TOKEN_ENV from . import run_probe _MARK = {"passed": "PASS", "failed": "FAIL", "unverified": "----", "not_applicable": "n/a "} @@ -19,9 +22,12 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--level", choices=("basic", "full"), default="full") parser.add_argument("--json", type=Path, help="also write the report to this file") parser.add_argument("--timeout", type=float, default=60, help="seconds for each call, and to report ready") + parser.add_argument("--token", default=os.environ.get(CONTROL_TOKEN_ENV), + help=f"the driver's control token, if it was given one (default: ${CONTROL_TOKEN_ENV})") args = parser.parse_args(argv) try: - report = asyncio.run(run_probe(args.url, args.level, timeout_s=args.timeout, ready_timeout_s=args.timeout)) + report = asyncio.run(run_probe(args.url, args.level, timeout_s=args.timeout, ready_timeout_s=args.timeout, + token=args.token)) except Exception as e: print(f"couldn't probe {args.url}: {type(e).__name__}: {e}", file=sys.stderr) return 2 diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py index bf9f4b50d..a6664754f 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py @@ -88,7 +88,8 @@ async def basic(self) -> None: for check, section in (("P0", self._ready), ("P1", self._card), ("P2", self._frame), ("P5", self._refusals), ("X", self._display), ("X", self._spaces), ("X", self._keyboard_settings), ("X", self._exec), ("X", self._data_get), ("X", self._app_exports), - ("X", self._tool_access), ("X", self._pointer), ("X", self._skill)): + ("X", self._tool_access), ("X", self._pointer), ("X", self._skill), + ("X", self._control)): await self._guarded(check, section) self._grants() @@ -422,6 +423,24 @@ async def export(app: dict = app) -> str | None: await self.check("X", f"app {app.get('name')}'s data/get answers its parts through the driver", export, *DATA_PLANE) + async def _control(self) -> None: + if not self.driver.headers: + return + + async def refused() -> str | None: + for sent, without in (({}, "without"), (protocol.control_headers("not-its-token"), "with a wrong")): + try: + await protocol.invoke_extension(self.driver.url, self.driver.card, COMPUTER_READY, method="status", + timeout=int(self.driver.timeout_s), headers=sent) + except ExtensionCallError as e: + if e.response.status_code != 401: + return f"computer-ready status answered a call {without} control token {e.response.status_code}" + continue + return f"computer-ready status answered a call {without} control token" + return None + + await self.check("X", "a framework-only route refuses a call without the control token", refused) + async def _tool_access(self) -> None: if not (self.driver.declares(DISABLE_TOOL_EXTENSION_URI) and self.driver.declares(ENABLE_TOOL_EXTENSION_URI)): return diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/client.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/client.py index a941e4f52..6750f84d4 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/client.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/client.py @@ -24,14 +24,15 @@ class Driver: - def __init__(self, url: str, card: dict, timeout_s: float) -> None: + def __init__(self, url: str, card: dict, timeout_s: float, token: str | None = None) -> None: self.url = url.rstrip("/") self.card = card self.timeout_s = timeout_s + self.headers = protocol.control_headers(token) if token else None @classmethod - async def connect(cls, url: str, timeout_s: float) -> Driver: - return cls(url, await protocol.get_card(url.rstrip("/"), timeout=int(timeout_s)), timeout_s) + async def connect(cls, url: str, timeout_s: float, token: str | None = None) -> Driver: + return cls(url, await protocol.get_card(url.rstrip("/"), timeout=int(timeout_s)), timeout_s, token) @property def name(self) -> str: @@ -59,12 +60,13 @@ async def session(self, role: str | None = None) -> AsyncIterator[ClientSession] async def invoke(self, uri: str, method: str, params: dict | None = None) -> Any: return await protocol.invoke_extension(self.url, self.card, uri, params or {}, method=method, - timeout=int(self.timeout_s)) + timeout=int(self.timeout_s), headers=self.headers) async def rpc(self, method: str, params: dict | None = None, card: dict | None = None) -> dict: """``method``'s answer from the data plane of ``card``, the driver's own or a child's it fronts.""" async with httpx.AsyncClient(timeout=self.timeout_s) as http: - response = await http.post(self.url + ((card or self.card).get("url") or "/agentenv"), + url = self.url + ((card or self.card).get("url") or "/agentenv") + response = await http.post(url, headers=protocol.request_headers(url, self.headers), json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}) return response.json() diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/driver.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/driver.py index ddc16a3e4..94ea2a96a 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/driver.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/driver.py @@ -11,7 +11,8 @@ from starlette.requests import Request from starlette.responses import JSONResponse, Response -from ..agent_env_environment import _CARD_CONFIG_ATTR, AgentEnvEnvironment, AgentEnvFastMCPApplication, environment_card +from ..agent_env_environment import (_CARD_CONFIG_ATTR, AgentEnvEnvironment, AgentEnvFastMCPApplication, control_guard, + environment_card) from ..tool_context import ToolContext from ..types import EnvironmentCard, EnvironmentExtension, EnvironmentTool from . import _linux, apps, card @@ -133,7 +134,7 @@ async def children_environments(self) -> list[EnvironmentCard] | None: return await apps.children(self._computer.apps) if self._computer.apps else None def _computer_front_apps(self, app: Any) -> Any: - app.custom_route("/svc/{name}/{path:path}", methods=["GET", "POST"])(self._computer_forward) + app.custom_route("/svc/{name}/{path:path}", methods=["GET", "POST"])(control_guard()(self._computer_forward)) return app async def _computer_forward(self, request: Request) -> Response: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/serve_app.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/serve_app.py index e617819cf..2f0823bb7 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/serve_app.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/serve_app.py @@ -1,19 +1,22 @@ """Serve a computer app's class from its file, as its computer's program starts it: ``python -m agentenv_protocol.computer.serve_app :``, run in the app's folder, serves the class's card and -``computer-app/v1`` on the Unix socket ``.agentenv/server.sock`` there. Before it serves, it keeps a copy of the app's -data paths as installed, which a reset puts back; once it serves, it adds the app's launcher and launches a boot app -(``AppRuntime.start``). A server that can't start, or whose start fails once it serves, -writes why to ``.agentenv/server.failure``, which its computer's driver reports, and exits. Needs the ``app`` extra.""" +``computer-app/v1`` on the Unix socket ``.agentenv/server.sock`` there, which only the computer reaches, so it takes no +control token (``CONTROL_TOKEN_ENV``): the driver checks it in front of the app. Before it serves, it keeps a copy of +the app's data paths as installed, which a reset puts back; once it serves, it adds the app's launcher and launches a +boot app (``AppRuntime.start``). A server that can't start, or whose start fails once it serves, writes why to +``.agentenv/server.failure``, which its computer's driver reports, and exits. Needs the ``app`` extra.""" from __future__ import annotations import asyncio import contextlib +import os import sys import traceback from pathlib import Path import uvicorn +from ..types import CONTROL_TOKEN_ENV from ._load import load_class from .app import FAILURE, SOCKET, ComputerAppEnvironment @@ -35,6 +38,7 @@ def _serve(argv: list[str]) -> None: if len(argv) != 1 or ":" not in argv[0]: raise SystemExit(USAGE) file, _, name = argv[0].partition(":") + os.environ.pop(CONTROL_TOKEN_ENV, None) app = load_class(Path(file), name, ComputerAppEnvironment)() runtime = app._computer_app_runtime() app._computer_app_keep_initial() diff --git a/packages/agentenv-protocol/src/agentenv_protocol/types.py b/packages/agentenv-protocol/src/agentenv_protocol/types.py index 448b430b6..0c91725df 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/types.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/types.py @@ -16,6 +16,9 @@ def error_body(code: str, message: str, **fields: Any) -> dict: WELL_KNOWN_PATH = "/.well-known/agent-env.json" RPC_PATH = "/agentenv" +#: The variable that hands an env the token its framework-only routes take as their bearer, everything it serves but +#: its MCP endpoint and its card; agent-env sets it when it deploys the env, and without it those routes are open. +CONTROL_TOKEN_ENV = "AGENTENV_CONTROL_TOKEN" # A card declares its MCP endpoint as an `additionalInterfaces` entry with this transport; a card # without one is reached at MCP_PATH by convention. diff --git a/packages/agentenv-protocol/tests/test_computer_app_server.py b/packages/agentenv-protocol/tests/test_computer_app_server.py index 7bd53b0b9..90fd0cc9b 100644 --- a/packages/agentenv-protocol/tests/test_computer_app_server.py +++ b/packages/agentenv-protocol/tests/test_computer_app_server.py @@ -1,6 +1,7 @@ """A computer app's own server: it adds the app's launcher, launches a boot app once per boot and says whether the app is ready (``computer-app/v1``); its computer's driver finds it on the socket in the app's folder and asks it, lists it -as a child environment and forwards its requests to it, and leaves the paths it owns out of its own data/get.""" +as a child environment and forwards its requests to it, only with the computer's control token when it has one, and +leaves the paths it owns out of its own data/get.""" from __future__ import annotations import asyncio @@ -31,7 +32,7 @@ from agentenv_protocol.computer import app, app_runtime, apps, session # noqa: E402 from agentenv_protocol.computer.app import COMPUTER_APP, FAILURE, SOCKET # noqa: E402 from agentenv_protocol.computer.platform import Platform # noqa: E402 -from agentenv_protocol.types import DATA_OBJECTS_EXTENSION_URI, WELL_KNOWN_PATH # noqa: E402 +from agentenv_protocol.types import CONTROL_TOKEN_ENV, DATA_OBJECTS_EXTENSION_URI, WELL_KNOWN_PATH # noqa: E402 @@ -309,6 +310,30 @@ async def test_the_driver_lists_its_apps_as_children_forwards_each_ones_requests "Documents/note.txt"] +@pytest.mark.asyncio +async def test_with_a_control_token_the_driver_lists_its_apps_and_forwards_to_one_only_with_it(tmp_path, monkeypatch): + from computer_fake import FakeDriver, http_client + + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / "Mail").mkdir() + (tmp_path / "Mail/inbox.txt").write_text("hi") + get = {"jsonrpc": "2.0", "id": 1, "method": "data/get", "params": {}} + with tempfile.TemporaryDirectory(dir="/tmp", prefix="apps-") as root: + monkeypatch.setattr(apps, "APPS", Path(root)) + async with _serving(Inbox(), Path(root) / "mail", monkeypatch): + monkeypatch.setenv(CONTROL_TOKEN_ENV, "s3cret") + async with http_client(FakeDriver()) as http: + card = (await http.get(WELL_KNOWN_PATH)).json() + refused = [await http.post("/svc/mail/agentenv", json=get, headers=headers) + for headers in ({}, client.control_headers("guess"))] + forwarded = await http.post("/svc/mail/agentenv", json=get, headers=client.control_headers("s3cret")) + + assert [child["name"] for child in card["children_environments"]] == ["mail"] + assert [answer.status_code for answer in refused] == [401, 401] + assert [part["metadata"][DATA_OBJECTS_EXTENSION_URI]["path"] for part in forwarded.json()["result"]["parts"]] == [ + "Mail/inbox.txt"] + + @pytest.mark.asyncio async def test_the_session_launches_an_app_in_a_session_of_its_own_from_its_folder(tmp_path, monkeypatch): monkeypatch.setenv("DISPLAY", ":99") diff --git a/packages/agentenv-protocol/tests/test_computer_conformance.py b/packages/agentenv-protocol/tests/test_computer_conformance.py index 154738a95..3f2f8ddd6 100644 --- a/packages/agentenv-protocol/tests/test_computer_conformance.py +++ b/packages/agentenv-protocol/tests/test_computer_conformance.py @@ -1,6 +1,7 @@ """The conformance probe against fake drivers on loopback: the fake driver with the fake fixture passes every check; a -driver without what full needs gets those checks unverified; a driver broken one way fails the check for that way; each -app a driver fronts that exports is asked for its export once; the report's verdicts and the CLI.""" +driver given a control token passes with it, refusing calls without it, and fails without it; a driver without what +full needs gets those checks unverified; a driver broken one way fails the check for that way; each app a driver +fronts that exports is asked for its export once; the report's verdicts and the CLI.""" from __future__ import annotations import json @@ -33,6 +34,7 @@ from agentenv_protocol.computer.conformance import checks # noqa: E402 from agentenv_protocol.computer.conformance.client import Driver # noqa: E402 from agentenv_protocol.computer.conformance.__main__ import main # noqa: E402 +from agentenv_protocol import CONTROL_TOKEN_ENV # noqa: E402 from agentenv_protocol.tool_access import DISABLE_TOOL_EXTENSION_URI # noqa: E402 from agentenv_protocol.types import EnvironmentCard # noqa: E402 @@ -71,6 +73,18 @@ async def test_the_fake_driver_passes_every_check_at_full_and_cleans_up(home): assert ["kill", "4242"] in driver.commands +@pytest.mark.asyncio +async def test_a_driver_given_a_control_token_passes_with_it_and_refuses_calls_without_it(monkeypatch): + monkeypatch.setenv(CONTROL_TOKEN_ENV, "s3cret") + + report = await _probe(FixtureDriver(), "basic", token="s3cret") + unauthorized = await _probe(FixtureDriver(), "basic") + + assert report.passed, _failed(report) + assert _status(report, "a framework-only route refuses a call without the control token") == "passed" + assert not unauthorized.passed and "401 unauthorized" in _failed(unauthorized)[0] + + @pytest.mark.asyncio async def test_a_driver_whose_default_space_is_norm_passes_at_full(): report = await _probe(FixtureDriver(settings=ComputerSettings(default_space="norm"))) diff --git a/packages/agentenv-protocol/tests/test_computer_serve.py b/packages/agentenv-protocol/tests/test_computer_serve.py index 84aa77113..775d48d26 100644 --- a/packages/agentenv-protocol/tests/test_computer_serve.py +++ b/packages/agentenv-protocol/tests/test_computer_serve.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import os import sys import pytest @@ -11,6 +12,7 @@ pytest.importorskip("PIL") pytest.importorskip("uvicorn") +from agentenv_protocol import CONTROL_TOKEN_ENV # noqa: E402 from agentenv_protocol.computer import ComputerDriverEnvironment, run_install_step, serve_app # noqa: E402 from agentenv_protocol.computer.app import BUILD_SETTINGS, FAILURE, SOCKET # noqa: E402 from agentenv_protocol.computer.app_runtime import AppRuntime # noqa: E402 @@ -164,10 +166,11 @@ async def lifespan(): return tmp_path, ran -def test_an_apps_server_serves_on_the_socket_in_its_folder_and_starts_the_app_once_it_serves(app_folder, - monkeypatch): +def test_an_apps_server_serves_on_the_socket_in_its_folder_with_no_control_token_and_starts_the_app_once_it_serves( + app_folder, monkeypatch): folder, ran = app_folder started = [] + monkeypatch.setenv(CONTROL_TOKEN_ENV, "s3cret") async def start(runtime): started.append(runtime) @@ -177,6 +180,7 @@ async def start(runtime): serve_app.main(["app.py:Notes"]) (server,) = ran + assert CONTROL_TOKEN_ENV not in os.environ assert server.config.uds == str(SOCKET) and not server.should_exit assert not (folder / SOCKET).exists() and not (folder / FAILURE).exists() assert len(started) == 1 and isinstance(started[0], AppRuntime) diff --git a/packages/agentenv-protocol/tests/test_control_token.py b/packages/agentenv-protocol/tests/test_control_token.py new file mode 100644 index 000000000..27cfb3d73 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_control_token.py @@ -0,0 +1,147 @@ +"""The control token: with CONTROL_TOKEN_ENV set when an env mounts, everything it serves but its MCP endpoint and its +card refuses a call without that token, or with another, before the route runs, and answers one with it; the MCP +endpoint and the card stay open, and without the variable every route is open. The client sends a call's own headers, +or else the token a grant gives the address it calls.""" +from __future__ import annotations + +import contextlib + +import anyio +import httpx +import pytest + +pytest.importorskip("mcp.server.fastmcp") + +from mcp import ClientSession # noqa: E402 +from mcp.client import streamable_http as _streamable_http # noqa: E402 +from mcp.server.fastmcp import FastMCP # noqa: E402 + +from agentenv_protocol import ( # noqa: E402 + CONTROL_TOKEN_ENV, + RPC_PATH, + WELL_KNOWN_PATH, + AgentEnvEnvironment, + add_data, + client, + environment_card, + extension, + tool, +) + +pytestmark = pytest.mark.skipif(not hasattr(_streamable_http, "streamable_http_client"), + reason="mcp without streamable_http_client") + +_BASE = "http://127.0.0.1:8000" +RAN: list[str] = [] + + +@environment_card(name="desk") +class _Desk(AgentEnvEnvironment): + tool_access = True + + @tool() + def read(self) -> str: + return "read" + + @add_data + async def load(self, parts: list) -> None: + RAN.append("data/add") + + @extension("urn:agentenv:test-exec/v1", op="run") + async def run(self, argv: list) -> dict: + RAN.append("run") + return {"ran": argv} + + +@contextlib.asynccontextmanager +async def _served(): + """An ``_Desk`` mounted on mcp's FastMCP as the environment's variables stand, and its REST and MCP clients.""" + app = FastMCP("desk", json_response=True) + _Desk().mount(app) + asgi = app.streamable_http_app() + async with app.session_manager.run(): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi), base_url=_BASE) as rest: + async with _streamable_http.streamable_http_client(f"{_BASE}/mcp", http_client=rest) as (read, write, _): + async with ClientSession(read, write) as mcp: + await mcp.initialize() + yield rest, mcp + + +async def _calls(rest: httpx.AsyncClient, headers: dict) -> list[int]: + """The status of a call to each framework-only route with ``headers``: the data plane, an extension, tool access.""" + load = {"jsonrpc": "2.0", "id": 1, "method": "data/add", "params": {"parts": [{"kind": "data", "data": {}}]}} + return [(await rest.post(RPC_PATH, json=load, headers=headers)).status_code, + (await rest.post("/agentenv/ext/test-exec/run", json={"argv": ["ls"]}, headers=headers)).status_code, + (await rest.post("/tools/disable", json={"role": "agent", "tools": ["read"]}, headers=headers)).status_code] + + +@pytest.mark.asyncio +async def test_with_a_token_every_framework_only_route_refuses_a_call_without_it_before_it_runs(monkeypatch): + monkeypatch.setenv(CONTROL_TOKEN_ENV, "s3cret") + RAN.clear() + + with anyio.fail_after(30): + async with _served() as (rest, mcp): + without = await _calls(rest, {}) + wrong = await _calls(rest, client.control_headers("guess")) + refusal = (await rest.post("/agentenv/ext/test-exec/run", json={"argv": ["ls"]})).json() + ran_before = list(RAN) + right = await _calls(rest, client.control_headers("s3cret")) + card = await rest.get(WELL_KNOWN_PATH) + tools = [t.name for t in (await mcp.list_tools()).tools] + + assert without == wrong == [401, 401, 401] and ran_before == [] + assert refusal["error"]["code"] == "unauthorized" and refusal["error"]["applied"] == "no" + assert right == [200, 200, 200] and RAN == ["data/add", "run"] + assert card.status_code == 200 and tools == ["read"] + + +@pytest.mark.asyncio +async def test_without_a_token_every_route_is_open(monkeypatch): + monkeypatch.delenv(CONTROL_TOKEN_ENV, raising=False) + + with anyio.fail_after(30): + async with _served() as (rest, _): + assert await _calls(rest, {}) == [200, 200, 200] + + +def test_a_call_sends_its_own_headers_or_else_the_token_its_grant_gives_an_address_under_the_envs(): + env = "http://desk:18765" + + with client.authorized({env: "s3cret", "http://other:1": None, None: "orphan"}): + granted = [client.request_headers(url) for url in (env, f"{env}/svc/mail/agentenv", f"{env}0/x", + "http://other:1/x")] + own = client.request_headers(f"{env}/agentenv", {"X-Mine": "1"}) + with client.authorized({env: "rotated"}): + inner = client.request_headers(f"{env}/agentenv") + outer = client.request_headers(f"{env}/agentenv") + after = client.request_headers(f"{env}/agentenv") + + bearer = {"Authorization": "Bearer s3cret"} + assert granted == [bearer, bearer, {}, {}] and own == {"X-Mine": "1"} + assert inner == {"Authorization": "Bearer rotated"} and outer == bearer and after == {} + assert client.control_headers(None) == {} and client.control_headers("") == {} + + +@pytest.mark.asyncio +async def test_invoke_extension_and_the_data_plane_send_the_token(monkeypatch): + seen = [] + + def handle(request: httpx.Request) -> httpx.Response: + seen.append((request.url.path, request.headers.get("authorization"))) + return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": {"parts": []}}) + + real = httpx.AsyncClient + monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: real(transport=httpx.MockTransport(handle))) + card = {"capabilities": {"extensions": [{"uri": "urn:agentenv:test-exec/v1", "params": { + "methods": {"run": {"endpoint": "/agentenv/ext/test-exec/run"}}}}]}} + + await client.invoke_extension("http://desk", card, "urn:agentenv:test-exec/v1", {}, method="run", + headers=client.control_headers("own")) + await client.get_data("http://desk", headers=client.control_headers("own")) + with client.authorized({"http://desk": "granted"}): + await client.add_data("http://desk", []) + await client.reset_data("http://desk", headers={}) + + assert seen == [("/agentenv/ext/test-exec/run", "Bearer own"), ("/agentenv", "Bearer own"), + ("/agentenv", "Bearer granted"), ("/agentenv", None)] diff --git a/src/agent_env/env/env.py b/src/agent_env/env/env.py index 059607c87..1a5e6b3dc 100644 --- a/src/agent_env/env/env.py +++ b/src/agent_env/env/env.py @@ -93,6 +93,8 @@ class DeployedEnv: instance_id: Optional[str] = None created_at_utc: Optional[str] = None expires_at_utc: Optional[str] = None + # The token the env's framework-only routes take, from the provider that booted it with it; None when they're open. + control_token: Optional[str] = None def __post_init__(self) -> None: # Only older records lack their card: they keep the stored values. @@ -110,6 +112,11 @@ def environment_url(self) -> Optional[str]: """The env's address: its card URL without the well-known path; card paths join onto it.""" return _environment_url(self.environment_card_url) if self.environment_card_url else None + @property + def control_headers(self) -> dict[str, str]: + """What a call to the env's framework-only routes sends: its control token, when it has one.""" + return protocol_v1.control_headers(self.control_token) + def supports(self, uri: str, method: str) -> bool: """Whether the stored env card offers `method` on the extension at `uri`; no network.""" return protocol_v1.find_extension_method(self.environment_card or {}, uri, method) is not None @@ -135,7 +142,8 @@ async def invoke(self, uri: str, method: str, params: Optional[dict] = None, *, card = self.environment_card if environment_name is None else self.get_child_env_card(environment_name) if card is None or not self.environment_url or protocol_v1.find_extension_method(card, uri, method) is None: raise EnvCapabilityUnsupported(uri, method, self.env_id, self.instance_id, environment_name) - return await protocol_v1.invoke_extension(self.environment_url, card, uri, params, timeout, method=method) + return await protocol_v1.invoke_extension(self.environment_url, card, uri, params, timeout, method=method, + headers=self.control_headers) @classmethod def _fields_from(cls, data: dict) -> dict: @@ -152,6 +160,7 @@ def _fields_from(cls, data: dict) -> dict: instance_id=data.get("instance_id"), created_at_utc=data.get("created_at_utc"), expires_at_utc=data.get("expires_at_utc"), + control_token=data.get("control_token"), ) diff --git a/src/agent_env/env/envs/computer/utils/boot.py b/src/agent_env/env/envs/computer/utils/boot.py index 70b4ea55c..6b7b8952c 100644 --- a/src/agent_env/env/envs/computer/utils/boot.py +++ b/src/agent_env/env/envs/computer/utils/boot.py @@ -11,6 +11,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Optional +from agentenv_protocol import CONTROL_TOKEN_ENV from agentenv_protocol import client as protocol_v1 from agentenv_protocol.computer import APP_SETTINGS_ENV, COMPUTER_READY, SETTINGS_ENV from agentenv_protocol.computer.apps import ANSWER_TIMEOUT_S @@ -87,19 +88,25 @@ def recorded_settings(computer: ComputerArtifact, values: dict[str, Any]) -> dic for name, setting in computer.config.items() if setting.phase == "deploy"} +def control_token() -> str: + """A token for one computer's framework-only routes (``CONTROL_TOKEN_ENV``), which agent-env holds and gives no + agent.""" + return secrets.token_urlsafe(32) + + async def boot(sandbox_provider: SandboxProvider, *, image: str, port: int, environment: dict[str, str], network_policy: Optional[NetworkPolicy], timeout: int, attribution: Optional[Attribution], disk_size_gb: float = 10, cpu: Optional[float] = None, memory_mb: Optional[int] = None, ready_s: float = READY_TIMEOUT_S) -> Booted: """``image`` booted with ``environment`` on ``sandbox_provider``, which the caller chose to run its arch natively, - its driver's ``port`` reached on the sandbox, once the driver says the computer is ready; a boot that fails is torn - down.""" + its driver's ``port`` reached on the sandbox, once the driver says the computer is ready, asked with the control + token ``environment`` gives it; a boot that fails is torn down.""" sandbox = await sandbox_provider.create_container( image_name=image, port=port, env=environment, network_policy=network_policy, disk_size_gb=disk_size_gb, timeout=timeout, attribution=dict(attribution or {}), **_size_kwargs(cpu, memory_mb)) try: url = sandbox.tunnel_urls[port] - return Booted(sandbox, url, await ready(url, ready_s)) + return Booted(sandbox, url, await ready(url, ready_s, environment.get(CONTROL_TOKEN_ENV))) except BaseException: await discard(sandbox) raise @@ -124,10 +131,10 @@ def ready_timeout(apps: list[ComputerAppEnv], os_type: str) -> float: if (launch := app.launch(os_type)) and launch.at == "boot" and launch.ready)]) -async def ready(url: str, timeout: float) -> dict: - """The card of the driver at ``url``, read once its ``computer-ready`` status says it's ready, so each app it fronts - is on it as the app's own server serves it; raises TimeoutError, naming what it last saw, when it isn't within - ``timeout`` seconds.""" +async def ready(url: str, timeout: float, token: Optional[str] = None) -> dict: + """The card of the driver at ``url``, read once its ``computer-ready`` status, asked with its control ``token``, + says it's ready, so each app it fronts is on it as the app's own server serves it; raises TimeoutError, naming + what it last saw, when it isn't within ``timeout`` seconds.""" last = "nothing answered" try: async with asyncio.timeout(timeout): @@ -135,7 +142,8 @@ async def ready(url: str, timeout: float) -> dict: try: card = await protocol_v1.get_card(url, timeout=5) status = await protocol_v1.invoke_extension(url, card, COMPUTER_READY, None, _STATUS_TIMEOUT_S, - method="status") + method="status", + headers=protocol_v1.control_headers(token)) if status.get("ready"): return await protocol_v1.get_card(url, timeout=5) last = "not ready: " + "; ".join(f"{check['name']}: {check.get('detail')}" diff --git a/src/agent_env/env/envs/computer/utils/composer.py b/src/agent_env/env/envs/computer/utils/composer.py index dd3ce211f..ab11cfe32 100644 --- a/src/agent_env/env/envs/computer/utils/composer.py +++ b/src/agent_env/env/envs/computer/utils/composer.py @@ -27,6 +27,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any +from agentenv_protocol import CONTROL_TOKEN_ENV from agentenv_protocol.computer import apps as installed_apps from agentenv_protocol.computer.app import BUILD_SETTINGS, SOCKET from agentenv_protocol.computer.conformance import run_probe @@ -182,9 +183,12 @@ def _probed(env: ComputerEnv, driver: ComputerDriverEnv, form: str, arch: str, r async def _probe(links: list[SandboxProvider], env: ComputerEnv, driver: ComputerDriverEnv, image: str) -> tuple[ProbeReport, dict, str]: - """``image`` booted at the env's settings, as a deploy boots it, on the first of ``links`` that boots it, probed at - ``full`` within ``_PROBE_TIMEOUT_S`` and torn down; the report, the driver's card and the provider's name.""" - environment = boot.settings_environment(env.computer, _values(env), environment_name=env.environment_name) + """``image`` booted at the env's settings with a control token of its own, as a deploy boots it, on the first of + ``links`` that boots it, probed at ``full`` with that token within ``_PROBE_TIMEOUT_S`` and torn down; the report, + the driver's card and the provider's name.""" + token = boot.control_token() + environment = {**boot.settings_environment(env.computer, _values(env), environment_name=env.environment_name), + CONTROL_TOKEN_ENV: token} problems = [] for link in links: try: @@ -196,7 +200,7 @@ async def _probe(links: list[SandboxProvider], env: ComputerEnv, driver: Compute continue try: async with asyncio.timeout(_PROBE_TIMEOUT_S): - return await run_probe(booted.url, "full"), booted.card, type(link).__name__ + return await run_probe(booted.url, "full", token=token), booted.card, type(link).__name__ finally: await boot.discard(booted.sandbox) raise RuntimeError("; ".join(problems)) diff --git a/src/agent_env/env/envs/computer/utils/inside.py b/src/agent_env/env/envs/computer/utils/inside.py index 2d5eaa6ce..2a526a0b4 100644 --- a/src/agent_env/env/envs/computer/utils/inside.py +++ b/src/agent_env/env/envs/computer/utils/inside.py @@ -47,9 +47,11 @@ def max_timeout_s(deployed: DeployedSandboxEnv) -> float: def loopback(deployed: DeployedSandboxEnv) -> dict: - """The env's id, address and MCP endpoint as code inside the computer reaches them: the driver on loopback.""" + """The env's id, address and MCP endpoint as code inside the computer reaches them, the driver on loopback, and + the ``headers`` its framework-only routes take: code agent-env runs in the computer gets the computer's token.""" url = f"http://127.0.0.1:{deployed.metadata['computer']['port']}" - return {"id": deployed.env_id, "url": url, "mcp_url": url + deployed.mcp_url.removeprefix(deployed.environment_url)} + return {"id": deployed.env_id, "url": url, "mcp_url": url + deployed.mcp_url.removeprefix(deployed.environment_url), + "headers": deployed.control_headers} async def add_artifacts(deployed: DeployedSandboxEnv, files: dict[str, FileArtifact], @@ -79,7 +81,8 @@ async def add_files(deployed: DeployedSandboxEnv, files: dict[str, bytes]) -> No async def read_file(deployed: DeployedSandboxEnv, path: str) -> bytes: """The bytes of the file at ``path``; FileNotFoundError when there is none.""" try: - answer = await protocol_v1.get_data(deployed.environment_url, timeout=_READ_TIMEOUT_S, paths=[path]) + answer = await protocol_v1.get_data(deployed.environment_url, timeout=_READ_TIMEOUT_S, paths=[path], + headers=deployed.control_headers) except httpx.TransportError as e: raise _broken(deployed, "data/get", e) from e for part in answer.parts: @@ -102,7 +105,8 @@ async def run(deployed: DeployedSandboxEnv, argv: list[str], *, timeout_s: float async def _add(deployed: DeployedSandboxEnv, parts: list[FilePart], size: int, url: Optional[str] = None) -> None: try: - await protocol_v1.add_data(url or deployed.environment_url, parts, timeout=data_plane_load_timeout_s(size)) + await protocol_v1.add_data(url or deployed.environment_url, parts, timeout=data_plane_load_timeout_s(size), + headers=deployed.control_headers) except httpx.TransportError as e: raise _broken(deployed, "data/add, so some of the files it carried may be written, each whole", e) from e diff --git a/src/agent_env/providers/env_providers/env_computer_provider.py b/src/agent_env/providers/env_providers/env_computer_provider.py index e4903e743..a0d991a12 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -8,6 +8,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, ClassVar, Optional +from agentenv_protocol import CONTROL_TOKEN_ENV from agentenv_protocol.computer import COMPUTER_DISPLAY, COMPUTER_RECORDING from agent_env.artifact.artifacts.computer import ComputerArtifact @@ -17,6 +18,7 @@ from agent_env.env.envs.computer.utils.boot import ( app_settings_environment, boot, + control_token, discard, ready, ready_timeout, @@ -90,12 +92,13 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par raise ValueError(f"{type(sandbox_provider).__name__} runs none of {env.id}'s ready builds natively " f"({', '.join(f'{form}/{arch}' for form, arch, _ in builds)})") form, arch, build = native[0] - computer, name = env.computer_artifact(), env.environment_name + computer, name, token = env.computer_artifact(), env.environment_name, control_token() values = _values(computer, values) policy = NetworkPolicy.from_dict(env.network) if env.network else None booted = await boot( sandbox_provider, image=build["image"], port=env.driver.port, network_policy=policy, - environment=_environment(env, computer, values, params), timeout=ttl_seconds, attribution=attribution, + environment=_environment(env, computer, values, params, token), timeout=ttl_seconds, + attribution=attribution, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, ready_s=ready_timeout(env.composed_apps(), env.computer.os.type)) self._container_sandboxes.append(booted.sandbox) @@ -107,7 +110,7 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par environment_card_url=f"{booted.url}{WELL_KNOWN_PATH}", environment_card=booted.card, environment_card_read_at_utc=datetime.now(timezone.utc).isoformat(), sandbox_id=booted.sandbox.sandbox_id, sandbox_type=booted.sandbox.type, - sandbox_ids={ComputerEnv.type: {name: booted.sandbox.sandbox_id}}, + sandbox_ids={ComputerEnv.type: {name: booted.sandbox.sandbox_id}}, control_token=token, metadata={"computer": { "form": form, "arch": arch, "provider": type(sandbox_provider).__name__, "image": build["image"], "computer": {"id": env.metadata["computer"]["id"], "version": env.metadata["computer"]["version"]}, @@ -123,8 +126,8 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par async def reboot(self, env: ComputerEnv, deployed: DeployedSandboxEnv) -> None: """Replace the computer ``deployed`` records with a fresh container of the image it booted, once its recordings - ended, its settings applied as that deploy applied them (a generated one minted afresh), on the same sandbox and - port, readied as a deploy readies it.""" + ended, its settings applied as that deploy applied them (a generated one minted afresh) and its control token + the record's, on the same sandbox and port, readied as a deploy readies it.""" record = deployed.metadata["computer"] params, values, _ = computer_params.checked(env, record["env_params"]) computer = ComputerArtifact.get(record["computer"]["id"], record["computer"]["version"]) @@ -134,10 +137,12 @@ async def reboot(self, env: ComputerEnv, deployed: DeployedSandboxEnv) -> None: sandbox = await sandbox_provider.get_sandbox(deployed.sandbox_id) try: await sandbox_provider.reset_container(sandbox, image_name=record["image"], port=env.driver.port, - env=_environment(env, computer, values, params)) + env=_environment(env, computer, values, params, + deployed.control_token)) except NotImplementedError as e: # a reset the env supports, which this provider can't carry out raise RuntimeError(f"'{env.id}' can't be reset on {type(sandbox_provider).__name__}: {e}") from None - card = await ready(deployed.environment_url, ready_timeout(env.composed_apps(), env.computer.os.type)) + card = await ready(deployed.environment_url, ready_timeout(env.composed_apps(), env.computer.os.type), + deployed.control_token) _require_name(env, card) await _require_display(deployed, values.get("resolution")) @@ -159,11 +164,14 @@ def _values(computer: ComputerArtifact, values: dict) -> dict: return {**{key: setting.default for key, setting in computer.config.items()}, **values} -def _environment(env: ComputerEnv, computer: ComputerArtifact, values: dict, - params: computer_params.ComputerParams) -> dict[str, str]: +def _environment(env: ComputerEnv, computer: ComputerArtifact, values: dict, params: computer_params.ComputerParams, + token: Optional[str]) -> dict[str, str]: + """The computer's boot environment: its settings, its apps', and the control token its driver's framework-only + routes take, unless its record has none, as one an older agent-env booted has not.""" return {**settings_environment(computer, values, environment_name=env.environment_name, driver_settings=params.driver_settings), - **app_settings_environment(env.composed_apps(), params.app_settings, env.computer.os.type)} + **app_settings_environment(env.composed_apps(), params.app_settings, env.computer.os.type), + **({CONTROL_TOKEN_ENV: token} if token else {})} def _require_name(env: ComputerEnv, card: dict) -> None: diff --git a/src/agent_env/task_step/task_steps/apply_server_config.py b/src/agent_env/task_step/task_steps/apply_server_config.py index d1495fb9b..41f82ffd0 100644 --- a/src/agent_env/task_step/task_steps/apply_server_config.py +++ b/src/agent_env/task_step/task_steps/apply_server_config.py @@ -255,7 +255,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: ) result = await protocol_v1.invoke_extension( base_url, card, directive.uri, params=directive.args, - timeout=self.timeout_seconds, + timeout=self.timeout_seconds, headers=deployed.control_headers, ) except ApplyServerConfigError: raise diff --git a/src/agent_env/task_step/task_steps/post_turn_hooks.py b/src/agent_env/task_step/task_steps/post_turn_hooks.py index 4a04ab10d..0afb54b71 100644 --- a/src/agent_env/task_step/task_steps/post_turn_hooks.py +++ b/src/agent_env/task_step/task_steps/post_turn_hooks.py @@ -152,7 +152,8 @@ async def call_tool(self, tool: str, args: Optional[dict], role: str) -> dict: "source": STEP_SOURCE_POST_TURN_HOOK, "hook_artifact_id": self._hook_artifact_id} async with httpx.AsyncClient() as client: resp = await client.request(op["method"], f"{self._deployed.environment_url}{op['endpoint']}", json=body, - headers={AGENT_ENV_ROLE_HEADER: role}, timeout=_TOOL_CALL_TIMEOUT_S) + headers={AGENT_ENV_ROLE_HEADER: role, **self._deployed.control_headers}, + timeout=_TOOL_CALL_TIMEOUT_S) if resp.status_code >= 400: raise RuntimeError(f"env '{self.env_id}': /step call_tool {tool!r} failed (HTTP {resp.status_code}): " f"{resp.text[:300]}") diff --git a/src/agent_env/task_step/task_steps/run_code.py b/src/agent_env/task_step/task_steps/run_code.py index 98b773526..e1ae345bc 100644 --- a/src/agent_env/task_step/task_steps/run_code.py +++ b/src/agent_env/task_step/task_steps/run_code.py @@ -5,7 +5,9 @@ A script run for an env also gets ``input["env"] = {"id", "url", "mcp_url"}``: the env's address, where its card is at ``/.well-known/agent-env.json``, and its MCP endpoint, as the script reaches them: on the env's host as that host does (``Sandbox.url_on_host``), since a host can't -always call its own public URL; inside a computer, the driver on loopback. +always call its own public URL; inside a computer, the driver on loopback, with the ``headers`` that +carry the computer's control token to its framework-only routes. A script run in an agent's sandbox +never gets the token. ``script_artifact_id`` is either a single-file ``FileArtifact`` or a ``FileArtifactUniverse`` whose ``.py`` members are staged side by side so the entry diff --git a/src/agent_env/task_step/task_steps/snapshot_env.py b/src/agent_env/task_step/task_steps/snapshot_env.py index a1748adb7..fd178d0b9 100644 --- a/src/agent_env/task_step/task_steps/snapshot_env.py +++ b/src/agent_env/task_step/task_steps/snapshot_env.py @@ -99,7 +99,8 @@ def _snapshot_upload(card: dict, timeout_seconds: float, sandbox_type: Optional[ return _SnapshotUpload(store, grant) -async def _push_s3_credentials(base_url: str, card: dict, timeout_seconds: float) -> None: +async def _push_s3_credentials(base_url: str, card: dict, timeout_seconds: float, + headers: Optional[dict[str, str]]) -> None: """Best-effort: push the object store's shared AWS creds + bucket to a service advertising add_s3_credentials, if the store shares any. Never raises (get_data falls back). An @local run's snapshots land in the local stores, so it hands a service no credentials to @@ -132,6 +133,7 @@ async def _push_s3_credentials(base_url: str, card: dict, timeout_seconds: float _S3_CREDENTIALS_EXTENSION_URI, params, timeout=int(timeout_seconds), + headers=headers, ) logger.info("snapshot_env: pushed S3 creds to %s", base_url) except Exception: @@ -377,13 +379,14 @@ def _resolve_deployed_env(self, context: TaskStepContext): async def _capture_env_trajectory(self, entry: dict, context: TaskStepContext, env_id: str, deployed) -> None: """Stream the trajectory to a temp file, upload it verbatim (no envelope), and fill ``entry``.""" method, url = _trajectory_route(deployed, self.gateway_url) + headers = None if self.gateway_url else deployed.control_headers store = get_config().get_object_store() event_count = 0 timeout = httpx.Timeout(_TRAJECTORY_CAPTURE_BUDGET_SECONDS, connect=_CONNECT_TIMEOUT_SECONDS) tmp = tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) try: async with httpx.AsyncClient(timeout=timeout) as client: - async with client.stream(method, url) as response: + async with client.stream(method, url, headers=headers) as response: response.raise_for_status() async for chunk in response.aiter_bytes(): tmp.write(chunk) @@ -404,6 +407,7 @@ async def _capture_env_trajectory(self, entry: dict, context: TaskStepContext, e async def _capture_env_changes(self, entry: dict, context: TaskStepContext, env_id: str, deployed) -> None: """Page the gateway's changes into a JSONL upload; fill ``entry`` with its url and attribution counts.""" method, url = _changes_route(deployed, self.gateway_url) + headers = None if self.gateway_url else deployed.control_headers counts = {"change_count": 0, "attributed_count": 0, "ambiguous_count": 0} timeout = httpx.Timeout(_CHANGES_CAPTURE_BUDGET_SECONDS, connect=_CONNECT_TIMEOUT_SECONDS) tmp = tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) @@ -411,7 +415,8 @@ async def _capture_env_changes(self, entry: dict, context: TaskStepContext, env_ after_id, has_more = 0, True async with httpx.AsyncClient(timeout=timeout) as client: while has_more: - response = await client.request(method, url, params={"after_id": after_id, "limit": CHANGES_PAGE_MAX}) + response = await client.request(method, url, params={"after_id": after_id, "limit": CHANGES_PAGE_MAX}, + headers=headers) response.raise_for_status() page = response.json() for change in page["changes"]: @@ -496,18 +501,21 @@ async def _export_environment_to_file( base_url = await legacy_protocol.v1_base_url(deployed, gateway_url, environment_name, mcp=True) if base_url is not None: + headers = deployed.control_headers if deployed is not None else None card_base, card = await legacy_protocol.child_env_card(deployed, gateway_url, environment_name) if returns_file_tree(card or {}): upload = await asyncio.to_thread(_snapshot_upload, card or {}, timeout_seconds, getattr(deployed, "sandbox_type", None), FILE_TREE_LIMITS) - return await get_data(base_url, environment_name, timeout_seconds, upload, **(data_get_params or {})) + return await get_data(base_url, environment_name, timeout_seconds, upload, headers=headers, + **(data_get_params or {})) upload = await asyncio.to_thread( _snapshot_upload, card or {}, timeout_seconds, getattr(deployed, "sandbox_type", None) ) # Push S3 creds (no-op unless the service advertises the extension), with a grant too: a # bundle the grant cannot hold can still be uploaded with them. - await _push_s3_credentials(card_base, card or {}, timeout_seconds) - resp, upload = await get_data(base_url, environment_name, timeout_seconds, upload, **(data_get_params or {})) + await _push_s3_credentials(card_base, card or {}, timeout_seconds, headers) + resp, upload = await get_data(base_url, environment_name, timeout_seconds, upload, headers=headers, + **(data_get_params or {})) part = resp.parts[0] if resp.parts else None if upload is not None and (path := uploaded_object_path(part)) is not None: return upload.object_url(path) diff --git a/src/agent_env/task_step/task_steps/sync_env_clock.py b/src/agent_env/task_step/task_steps/sync_env_clock.py index 1dc48a55d..ede3a409b 100644 --- a/src/agent_env/task_step/task_steps/sync_env_clock.py +++ b/src/agent_env/task_step/task_steps/sync_env_clock.py @@ -128,5 +128,5 @@ async def _sync_one(self, deployed, gateway_url: str, service: str, env_get_time raise RuntimeError(f"gateway did not expose env_get_time_url; cannot sync {service}") result = await protocol_v1.invoke_extension(base_url, card, EXT_CLOCK_URI, params={"env_get_time_url": env_get_time_url}, - timeout=self.timeout_seconds) + timeout=self.timeout_seconds, headers=deployed.control_headers) return {"synced": True, "result": result} diff --git a/src/agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py b/src/agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py index 1e8c89d1b..3df30016b 100644 --- a/src/agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +++ b/src/agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py @@ -11,6 +11,8 @@ import uuid from typing import ClassVar, Optional +from agentenv_protocol import client as protocol_v1 + from agent_env.task_step.context import PromptResponse, TaskStepContext from agent_env.entity.refs import EntityRef from agent_env.task_step.task_step import TaskStep, TaskStepDependency @@ -25,7 +27,8 @@ class EnvOutcomeVerifierTaskStep(TaskStep): """Runs the artifact's ``verify(mcp_url)``; a ``run`` parameter also gets the ``Run`` of ``prompt_id``, and an - ``env_url`` parameter the env's address, which the protocol client takes as it is.""" + ``env_url`` parameter the env's address, which the protocol client takes as it is: ``verify`` runs under a grant + of the env's control token, so its calls reach the env's framework-only routes as written.""" type: ClassVar[str] = "env_outcome_verifier" entity_refs = ( @@ -124,7 +127,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: keywords = {"env_url": deployed.environment_url} if "env_url" in parameters else {} if "run" in parameters: keywords["run"] = await load_run_context(context, self._graded_prompt(context)) - results = await module.verify(deployed.mcp_url, **keywords) + with protocol_v1.authorized({deployed.environment_url: deployed.control_token}): + results = await module.verify(deployed.mcp_url, **keywords) score = aggregate_score(results, self.score_aggregator) if "verifications" not in context.metadata: diff --git a/tst/integration/computer/app/artifacts/check_desk/run.py b/tst/integration/computer/app/artifacts/check_desk/run.py index 38db8dd25..20c7c0cfd 100644 --- a/tst/integration/computer/app/artifacts/check_desk/run.py +++ b/tst/integration/computer/app/artifacts/check_desk/run.py @@ -18,7 +18,9 @@ def run(input): time.sleep(0.5) with urllib.request.urlopen(f"{input['env']['url']}/.well-known/agent-env.json", timeout=10) as response: card = json.load(response) - with urllib.request.urlopen(f"{input['env']['url']}/agentenv/ext/computer-ready/status", timeout=30) as response: + status = urllib.request.Request(f"{input['env']['url']}/agentenv/ext/computer-ready/status", + headers=input["env"]["headers"]) + with urllib.request.urlopen(status, timeout=30) as response: ready = json.load(response) with urllib.request.urlopen("http://127.0.0.1:8137/", timeout=10) as response: clock = json.load(response) diff --git a/tst/integration/computer/app/artifacts/check_notes/run.py b/tst/integration/computer/app/artifacts/check_notes/run.py index e91675f11..50e145ae3 100644 --- a/tst/integration/computer/app/artifacts/check_notes/run.py +++ b/tst/integration/computer/app/artifacts/check_notes/run.py @@ -18,7 +18,9 @@ def run(input): time.sleep(0.5) with urllib.request.urlopen(f"{input['env']['url']}/.well-known/agent-env.json", timeout=10) as response: card = json.load(response) - with urllib.request.urlopen(f"{input['env']['url']}/agentenv/ext/computer-ready/status", timeout=30) as response: + status = urllib.request.Request(f"{input['env']['url']}/agentenv/ext/computer-ready/status", + headers=input["env"]["headers"]) + with urllib.request.urlopen(status, timeout=30) as response: ready = json.load(response) return {**json.loads(written.read_text()), "card": card["name"], "app_ready": [check for check in ready["checks"] if check["name"].startswith("app ")], diff --git a/tst/integration/computer/app/artifacts/reset_apps/run.py b/tst/integration/computer/app/artifacts/reset_apps/run.py index fedff288f..72f7e9904 100644 --- a/tst/integration/computer/app/artifacts/reset_apps/run.py +++ b/tst/integration/computer/app/artifacts/reset_apps/run.py @@ -1,5 +1,5 @@ -"""Each app's data/reset, sent from inside the computer through the driver's route to it, and what the apps keep -after: the notes in ~/Notes, and whether the clock's alarms are there.""" +"""Each app's data/reset, sent from inside the computer through the driver's route to it with the computer's control +token, and what the apps keep after: the notes in ~/Notes, and whether the clock's alarms are there.""" import json import urllib.request from pathlib import Path @@ -10,7 +10,7 @@ def run(input): for app in ("notes", "clock"): request = urllib.request.Request(f"{input['env']['url']}/svc/{app}/agentenv", data=json.dumps( {"jsonrpc": "2.0", "id": 1, "method": "data/reset", "params": {}}).encode(), - headers={"Content-Type": "application/json"}) + headers={"Content-Type": "application/json", **input["env"]["headers"]}) with urllib.request.urlopen(request, timeout=60) as response: answers[app] = "error" not in json.load(response) return {"reset": answers, "notes": sorted(p.name for p in (Path.home() / "Notes").iterdir()), diff --git a/tst/integration/computer/computer_env_test.py b/tst/integration/computer/computer_env_test.py index 0f0467849..d258d04ea 100644 --- a/tst/integration/computer/computer_env_test.py +++ b/tst/integration/computer/computer_env_test.py @@ -7,17 +7,26 @@ kept by their paths in its environment artifact after its upload under the grant fails, comes back from a reset fresh, takes the snapshot back through load_artifact, and leaves nothing once torn down; an open one uploads each file under the snapshot's grant, kept where it was put; and a recording open when the computer resets or is torn down is ended by -the computer and registered whole. Needs Docker and the local registry on localhost:5000.""" +the computer and registered whole. A deployed computer answers its framework-only routes only with the control token +agent-env booted it with, from this machine or from another container as an agent would call, and keeps it through a +reset, while its MCP endpoint and card stay open to anyone. Needs Docker and the local registry on localhost:5000.""" import asyncio +import json import platform import subprocess import time import uuid +import httpx import pytest +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client -from agentenv_protocol.computer import COMPUTER_READY +from agentenv_protocol import RPC_PATH, WELL_KNOWN_PATH +from agentenv_protocol import client as protocol_v1 +from agentenv_protocol.computer import COMPUTER_DISPLAY, COMPUTER_EXEC, COMPUTER_READY, COMPUTER_RECORDING +from agentenv_protocol.tool_access import DISABLE_TOOL_EXTENSION_URI from agentenv_protocol.computer.conformance import run_probe from agentenv_protocol.computer.conformance.client import Driver @@ -120,7 +129,7 @@ async def test_with_no_network_it_is_reached_reaches_nothing_comes_back_fresh_an artifact_id=note.id).execute(context) context = await RunCodeTaskStep(id="read", version=None, env_id=closed.id, script_artifact_id=script.id).execute(context) - driver = await Driver.connect(deployed.environment_url, 30) + driver = await Driver.connect(deployed.environment_url, 30, deployed.control_token) egress = await driver.run("python3", "-c", "import urllib.request; urllib.request.urlopen('http://1.1.1.1', timeout=5)") await driver.run("sh", "-c", "echo x > Desktop/left.txt") context = await SnapshotComputerEnvTaskStep(id="snapshot", version=None, env_id=closed.id, @@ -128,10 +137,10 @@ async def test_with_no_network_it_is_reached_reaches_nothing_comes_back_fresh_an file_paths=["Desktop", "Downloads/note.txt"]).execute(context) snapshot = {path: file.load() for path, file in _snapshotted(context, closed).items()} await ResetEnvTaskStep(id="reset", version=None, env_id=closed.id, fail_task_on_error=True).execute(context) - left = await (await Driver.connect(deployed.environment_url, 30)).run("test", "-e", "Desktop/left.txt") + left = await (await Driver.connect(deployed.environment_url, 30, deployed.control_token)).run("test", "-e", "Desktop/left.txt") context = await LoadArtifactTaskStep(id="restore", version=None, env_id=closed.id, artifact_from_step_id="snapshot").execute(context) - restored = await (await Driver.connect(deployed.environment_url, 30)).run("cat", "Desktop/left.txt") + restored = await (await Driver.connect(deployed.environment_url, 30, deployed.control_token)).run("cat", "Desktop/left.txt") finally: report = await teardown_run(context) sandbox = deployed.sandbox_id @@ -154,7 +163,7 @@ async def test_an_open_computer_uploads_each_file_under_the_snapshots_grant_and_ deployed = await Env.get(desktop.id).deploy() context = TaskStepContext(deployed_envs=[deployed]) try: - driver = await Driver.connect(deployed.environment_url, 30) + driver = await Driver.connect(deployed.environment_url, 30, deployed.control_token) await driver.run("sh", "-c", "mkdir -p Documents/reports && printf one > Documents/reports/a.txt " "&& printf two > Downloads/b.txt") context = await SnapshotComputerEnvTaskStep(id="snapshot", version=None, env_id=desktop.id, @@ -194,6 +203,74 @@ async def test_a_recording_open_at_a_reset_or_at_teardown_is_ended_by_its_comput assert entry["segments"] >= 1 +@pytest.mark.asyncio +async def test_only_agent_env_reaches_a_deployed_computers_framework_only_routes_while_its_mcp_and_card_stay_open( + desktop): + with namespace_routing(): + deployed = await Env.get(desktop.id).deploy() + context = TaskStepContext(deployed_envs=[deployed]) + try: + url, routes = deployed.environment_url, _framework_only(deployed.environment_card) + async with httpx.AsyncClient(timeout=30) as http: + refused = {name: [(await http.request(verb, url + path, json=body, headers=headers)).status_code + for headers in ({}, protocol_v1.control_headers("guess"))] + for name, (verb, path, body) in routes.items()} + card = await http.get(url + WELL_KNOWN_PATH) + from_a_container = _post_from_a_container(url + routes["computer-exec run"][1], + routes["computer-exec run"][2]) + async with streamablehttp_client(deployed.mcp_url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + tools = {tool.name for tool in (await session.list_tools()).tools} + waited = await session.call_tool("computer_wait", {"ms": 0}) + driver = await Driver.connect(url, 30, deployed.control_token) + untouched = await driver.run("test", "-e", "Desktop/pwned") + await ResetEnvTaskStep(id="reset", version=None, env_id=desktop.id, + fail_task_on_error=True).execute(context) + after_reset = await (await Driver.connect(url, 30, deployed.control_token)).run("echo", "back") + verb, path, body = routes["computer-exec run"] + async with httpx.AsyncClient(timeout=30) as http: + still_refused = (await http.request(verb, url + path, json=body)).status_code + finally: + report = await teardown_run(context) + + assert not report.failed + assert refused == {name: [401, 401] for name in routes}, refused + assert from_a_container == 401 + assert card.status_code == 200 and card.json()["name"] == "desktop" + assert "computer_screenshot" in tools and not waited.isError + assert untouched["exit_code"] != 0, "a refused computer-exec call ran" + assert (after_reset["exit_code"], after_reset["stdout"]) == (0, "back\n") and still_refused == 401 + + +def _framework_only(card: dict) -> dict[str, tuple[str, str, dict]]: + """A call to each kind of framework-only route the card declares, by name: verb, path and body. The command would + leave Desktop/pwned behind, were it run.""" + calls = {"computer-exec run": (COMPUTER_EXEC, "run", {"argv": ["touch", "Desktop/pwned"], "request_id": "pwned"}), + "computer-ready status": (COMPUTER_READY, "status", None), + "computer-display get": (COMPUTER_DISPLAY, "get", None), + "computer-recording end_all": (COMPUTER_RECORDING, "end_all", {"reason": "pwned"}), + "disable-tool": (DISABLE_TOOL_EXTENSION_URI, "disable", {"role": "agent", "tools": ["computer_wait"]})} + routes = {name: (op["method"], op["endpoint"], body) + for name, (uri, method, body) in calls.items() + if (op := protocol_v1.find_extension_method(card, uri, method)) is not None} + assert len(routes) == len(calls), routes + return {**routes, "data/get": ("POST", RPC_PATH, {"jsonrpc": "2.0", "id": 1, "method": "data/get", "params": {}})} + + +def _post_from_a_container(url: str, body: dict) -> int: + """The status a POST of ``body`` to ``url`` gets from another container on this machine's network, as an agent + with a shell would send it.""" + script = ("import json, sys, urllib.request, urllib.error\n" + "request = urllib.request.Request(sys.argv[1], data=sys.argv[2].encode(), " + "headers={'Content-Type': 'application/json'})\n" + "try:\n print(urllib.request.urlopen(request, timeout=30).status)\n" + "except urllib.error.HTTPError as e:\n print(e.code)\n") + out = docker("run", "--rm", "--network", "host", "public.ecr.aws/docker/library/python:3.12-slim", + "python", "-c", script, url, json.dumps(body)) + return int(out.stdout.decode().strip()) + + def _record(name: str, env_id: str) -> StartComputerRecordingTaskStep: return StartComputerRecordingTaskStep(id=f"start-{name}", version=None, env_id=env_id, name=name, fail_task_on_error=True) diff --git a/tst/unit/computer_home.py b/tst/unit/computer_home.py index 1d9d8e3dc..9564ca2fd 100644 --- a/tst/unit/computer_home.py +++ b/tst/unit/computer_home.py @@ -1,6 +1,6 @@ """A computer at its driver's boundary, for steps that reach inside one: its home a folder on this machine, ``data/add`` writing there, ``data/get`` reading there, and computer-exec/v1 ``run`` running commands there; each call recorded, -and the address each ``data/add`` is sent to. +with the headers it sent, and the address each ``data/add`` is sent to. ``pkill`` matches nothing, so one test never ends another's processes on this machine.""" from __future__ import annotations @@ -19,6 +19,8 @@ from agent_env.env.env import DeployedSandboxEnv PORT = 18765 +#: The control token the computer was booted with, which each call to its driver carries. +TOKEN = "t0ken" CARD = {"name": "desk", "capabilities": {"operations": ["data/reset", "data/add", "data/get"], "extensions": [ {"uri": COMPUTER_EXEC, "params": {"endpoint": "/computer/exec", "methods": {"run": {}}, "max_output_bytes": 1 << 20, "max_timeout_s": 3600}}]}} @@ -29,13 +31,14 @@ def __init__(self, home: Path): self.home = home self.calls: list[tuple] = [] self.added_at: list[str] = [] + self.headers: list[dict | None] = [] self.add_fails: Exception | None = None def deployed(self, env_id: str = "desk", card: dict | None = None, **computer) -> DeployedSandboxEnv: return DeployedSandboxEnv(env_id=env_id, env_version=1, env_provider_type="computer", environment_card_url="http://127.0.0.1:20001/.well-known/agent-env.json", environment_card=card or CARD, sandbox_id="local-1", sandbox_type="local", - metadata={"computer": {"port": PORT, **computer}}) + metadata={"computer": {"port": PORT, **computer}}, control_token=TOKEN) def install(self, monkeypatch) -> Computer: monkeypatch.setattr(protocol_v1, "add_data", self.add_data) @@ -43,9 +46,10 @@ def install(self, monkeypatch) -> Computer: monkeypatch.setattr(protocol_v1, "invoke_extension", self.invoke_extension) return self - async def add_data(self, base_url, parts, timeout=120, verify=True): + async def add_data(self, base_url, parts, timeout=120, verify=True, *, headers=None): self.calls.append(("data/add", [p.metadata[DATA_OBJECTS_EXTENSION_URI] for p in parts], timeout)) self.added_at.append(base_url) + self.headers.append(headers) if self.add_fails is not None: raise self.add_fails for part in parts: @@ -54,15 +58,18 @@ async def add_data(self, base_url, parts, timeout=120, verify=True): target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(base64.b64decode(part.file.bytes)) - async def get_data(self, base_url, timeout=30, verify=True, *, write_namespace=None, paths=None): + async def get_data(self, base_url, timeout=30, verify=True, *, write_namespace=None, paths=None, headers=None): self.calls.append(("data/get", paths)) + self.headers.append(headers) return GetDataResponse(parts=[ FilePart(file=FileWithBytes(bytes=base64.b64encode((self.home / path).read_bytes()).decode()), metadata={DATA_OBJECTS_EXTENSION_URI: {"path": path}}) for path in paths if (self.home / path).is_file()]) - async def invoke_extension(self, base_url, card, uri, params=None, timeout=30, verify=True, *, method=None): + async def invoke_extension(self, base_url, card, uri, params=None, timeout=30, verify=True, *, method=None, + headers=None): assert (uri, method) == (COMPUTER_EXEC, "run") + self.headers.append(headers) self.calls.append(("run", params, timeout)) return await asyncio.to_thread(self._run, params) diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index d1095a050..3f145d4aa 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -6,6 +6,7 @@ import asyncio import contextlib +import dataclasses import hashlib import importlib.metadata import json @@ -19,6 +20,7 @@ import httpx import pytest +from agentenv_protocol import CONTROL_TOKEN_ENV from agentenv_protocol.computer import COMPUTER_DISPLAY, COMPUTER_RECORDING from agentenv_protocol.tool_access import DISABLE_TOOL_EXTENSION_URI from click.testing import CliRunner @@ -34,6 +36,7 @@ from agent_env.config import get_config from agent_env.env import Env from agent_env.env import env as env_module +from agent_env.env.env import DeployedEnv from agent_env.env.envs.computer.utils import boot, builder, composer, gate, inside, packages, resolve_in_base from agent_env.env.envs.computer.utils.composer import Composing, _probe from agent_env.env.envs.computer.env import ComputerEnv @@ -251,7 +254,8 @@ def test_the_pairs_left_to_compose_are_those_failed_interrupted_or_unprobed_on_a assert composer.pending(Env.get(env.id)) == [] -def test_the_probe_boots_a_build_at_the_envs_settings_as_a_deploy_does(base, driver, docker, monkeypatch): +def test_the_probe_boots_a_build_at_the_envs_settings_with_a_token_of_its_own_as_a_deploy_does(base, driver, docker, + monkeypatch): env = _put(base, driver, compose=False, computer_settings={"timezone": "Europe/Paris"}, display={"width": 1600, "height": 900}) booted = [] @@ -262,12 +266,14 @@ async def fake_boot(link, **kwargs): monkeypatch.setattr(boot, "boot", fake_boot) monkeypatch.setattr(boot, "discard", AsyncMock()) - monkeypatch.setattr(composer, "run_probe", AsyncMock(return_value="report")) + probed = AsyncMock(return_value="report") + monkeypatch.setattr(composer, "run_probe", probed) assert asyncio.run(_probe([SimpleNamespace()], env, driver, "img")) == ( "report", CARD, "SimpleNamespace") assert {k: v for k, v in booted[0].items() if k.startswith("AGENTENV_SETTING_")} == { "AGENTENV_SETTING_RESOLUTION": "1600x900", "AGENTENV_SETTING_TIMEZONE": "Europe/Paris"} + probed.assert_awaited_once_with("http://computer", "full", token=booted[0][CONTROL_TOKEN_ENV]) def test_a_build_whose_probe_fails_or_that_never_gets_ready_fails_at_the_probe_and_isnt_deployed(base, driver, docker, @@ -503,8 +509,8 @@ def test_builds_of_one_env_at_once_keep_each_others_pairs_and_a_failure_doesnt_r @pytest.fixture def boots(monkeypatch): """The boots a deploy asks for, each a sandbox serving the desktop driver's card at 127.0.0.1:20001; the driver - shows a 1600x900 screen, and each extension call it gets is recorded.""" - made = SimpleNamespace(boots=[], calls=[], sandboxes=[], display=(1600, 900)) + shows a 1600x900 screen, and each extension call it gets is recorded, with the headers it carried.""" + made = SimpleNamespace(boots=[], calls=[], headers=[], sandboxes=[], display=(1600, 900)) async def fake_boot(sandbox_provider, **kwargs): made.boots.append({**kwargs, "provider": type(sandbox_provider).__name__}) @@ -519,8 +525,9 @@ async def terminate(): return boot.Booted(sandbox, "http://127.0.0.1:20001", {**DRIVER_CARD, "name": kwargs["environment"]["ENVIRONMENT_NAME"]}) - async def invoke(base_url, card, uri, params=None, timeout=30, verify=True, *, method=None): + async def invoke(base_url, card, uri, params=None, timeout=30, verify=True, *, method=None, headers=None): made.calls.append((uri, method, params)) + made.headers.append(headers) width, height = made.display return {"width": width, "height": height, "scale": 1, "modes": []} if uri == COMPUTER_DISPLAY else {} @@ -530,7 +537,7 @@ async def invoke(base_url, card, uri, params=None, timeout=30, verify=True, *, m @pytest.mark.asyncio -async def test_a_computer_env_deploys_its_first_native_ready_build_with_its_settings_and_display( +async def test_a_computer_env_deploys_its_first_native_ready_build_with_its_settings_display_and_a_control_token( base, driver, docker, boots): env = _put(base, driver, display={"width": 1600, "height": 900}) @@ -545,7 +552,12 @@ async def test_a_computer_env_deploys_its_first_native_ready_build_with_its_sett assert (environment["AGENTENV_SETTING_RESOLUTION"], environment["AGENTENV_SETTING_TIMEZONE"]) == ("1600x900", "Asia/Tokyo") assert json.loads(environment["AGENTENV_COMPUTER_SETTINGS"]) == {"default_space": "norm"} assert environment["ENVIRONMENT_NAME"] == "desktop" and "AGENTENV_SETTING_THEME" not in environment + assert len(deployed.control_token) >= 32 and environment[CONTROL_TOKEN_ENV] == deployed.control_token assert boots.calls == [(COMPUTER_DISPLAY, "get", None)] + assert boots.headers == [{"Authorization": f"Bearer {deployed.control_token}"}] + assert DeployedEnv.from_dict(dataclasses.asdict(deployed)).control_token == deployed.control_token + second = await Env.get(env.id).deploy() + assert second.control_token != deployed.control_token assert (deployed.env_provider_type, deployed.sandbox_ids) == ("computer", {"computer": {"desktop": "local-1"}}) assert deployed.environment_card == DRIVER_CARD and deployed.mcp_url == "http://127.0.0.1:20001/mcp" record = deployed.metadata["computer"] @@ -580,7 +592,9 @@ async def reset_container(self, sandbox, **kwargs): assert replaced is sandbox and not sandbox.terminated assert (kwargs["image_name"], kwargs["port"]) == (deployed.metadata["computer"]["image"], 18765) assert (kwargs["env"]["AGENTENV_SETTING_TIMEZONE"], kwargs["env"]["ENVIRONMENT_NAME"]) == ("Asia/Tokyo", "desktop") - env_computer_provider.ready.assert_awaited_once_with(deployed.environment_url, boot.READY_TIMEOUT_S) + assert kwargs["env"][CONTROL_TOKEN_ENV] == deployed.control_token + env_computer_provider.ready.assert_awaited_once_with(deployed.environment_url, boot.READY_TIMEOUT_S, + deployed.control_token) assert boots.calls == [(COMPUTER_RECORDING, "end_all", {"reason": "reset"}), "reset_container", (COMPUTER_DISPLAY, "get", None)] @@ -674,22 +688,26 @@ async def test_a_boot_that_never_gets_ready_takes_its_sandbox_and_work_folder_do @pytest.mark.asyncio -async def test_a_boot_is_ready_with_the_card_read_once_its_status_says_so_each_app_on_it(monkeypatch): +async def test_a_boot_is_ready_with_the_card_read_once_its_status_asked_with_its_token_says_so_each_app_on_it( + monkeypatch): cards = iter([{"name": "desk"}, {"name": "desk"}, {"name": "desk", "children_environments": [{"name": "mail"}]}]) statuses = iter([{"ready": False, "checks": [{"name": "app mail", "ok": False, "detail": "starting"}]}, {"ready": True}]) + sent = [] async def card(url, timeout): return next(cards) - async def status(url, card, uri, params, timeout, method): + async def status(url, card, uri, params, timeout, method, headers): + sent.append(headers) return next(statuses) monkeypatch.setattr(boot.protocol_v1, "get_card", card) monkeypatch.setattr(boot.protocol_v1, "invoke_extension", status) monkeypatch.setattr(boot, "_POLL_S", 0) - assert await boot.ready("http://desk", 5) == {"name": "desk", "children_environments": [{"name": "mail"}]} + assert await boot.ready("http://desk", 5, "t0ken") == {"name": "desk", "children_environments": [{"name": "mail"}]} + assert sent == [{"Authorization": "Bearer t0ken"}] * 2 def test_a_boot_mints_a_generated_setting_resolves_a_secret_its_record_withholds_both_and_a_secret_is_a_reference( diff --git a/tst/unit/env/test_env.py b/tst/unit/env/test_env.py index 0f40fe177..7d6a4893f 100644 --- a/tst/unit/env/test_env.py +++ b/tst/unit/env/test_env.py @@ -113,6 +113,19 @@ async def test_a_record_without_a_card_offers_nothing(sent): assert sent == [] +@pytest.mark.asyncio +async def test_a_record_with_a_control_token_sends_it_on_each_extension_call_and_keeps_it_through_storage( + composed, sent): + deployed = _record(composed) + deployed.control_token = "t0ken" + + await deployed.invoke(EXT_STEP_URI, "step", {"action": "list_tools"}) + + assert [request.headers.get("authorization") for request in sent] == ["Bearer t0ken"] + assert DeployedEnv.from_dict(dataclasses.asdict(deployed)).control_token == "t0ken" + assert _record(composed).control_headers == {} + + def test_child_env_cards_come_from_the_card_by_name(composed): deployed = _record(composed) assert deployed.get_child_env_card("slack")["url"] == "/svc/mcp-slack/agentenv" diff --git a/tst/unit/store/aws/test_shared_credentials.py b/tst/unit/store/aws/test_shared_credentials.py index 2b50bcab5..049f4a01c 100644 --- a/tst/unit/store/aws/test_shared_credentials.py +++ b/tst/unit/store/aws/test_shared_credentials.py @@ -19,7 +19,7 @@ def pushed(monkeypatch): calls: list[dict] = [] - async def fake_invoke(base_url, card, uri, params=None, timeout=30): + async def fake_invoke(base_url, card, uri, params=None, timeout=30, headers=None): calls.append(params) monkeypatch.setattr(protocol_v1, "invoke_extension", fake_invoke) @@ -43,7 +43,7 @@ def _merged_env(caller_env: dict[str, str] | None = None, metadata: dict | None async def test_nothing_is_shared_by_default(s3, host_credentials, pushed): if s3: set_object_store(S3ObjectStore(object(), "bucket", region="us-west-2")) - await _push_s3_credentials("https://svc", _CARD, 30) + await _push_s3_credentials("https://svc", _CARD, 30, None) assert not any(k.startswith("AWS_") for k in _merged_env()) assert pushed == [] @@ -51,7 +51,7 @@ async def test_nothing_is_shared_by_default(s3, host_credentials, pushed): @pytest.mark.asyncio async def test_an_opted_in_s3_store_shares_the_frozen_chain(host_credentials, pushed): set_object_store(S3ObjectStore(object(), "bucket", region="us-west-2", share_credentials=True)) - await _push_s3_credentials("https://svc", _CARD, 30) + await _push_s3_credentials("https://svc", _CARD, 30, None) assert {k: v for k, v in _merged_env().items() if k.startswith("AWS_")} == { "AWS_ACCESS_KEY_ID": "AKIA-HOST", "AWS_SECRET_ACCESS_KEY": "host-secret", @@ -72,7 +72,7 @@ async def test_an_opted_in_s3_store_shares_the_frozen_chain(host_credentials, pu async def test_an_local_run_hands_a_service_no_credentials_to_upload_a_snapshot_with(owner, shared, host_credentials, pushed, cli_routing): set_object_store(S3ObjectStore(object(), "bucket", region="us-west-2", share_credentials=True)) with routing.run_scope(owner): - await _push_s3_credentials("https://svc", _CARD, 30) + await _push_s3_credentials("https://svc", _CARD, 30, None) assert [call["bucket"] for call in pushed] == (["bucket"] if shared else []) diff --git a/tst/unit/task_step/add_skills_env_test.py b/tst/unit/task_step/add_skills_env_test.py index 3b8001ea5..576536f04 100644 --- a/tst/unit/task_step/add_skills_env_test.py +++ b/tst/unit/task_step/add_skills_env_test.py @@ -35,7 +35,7 @@ def agent(monkeypatch): """The agent's skill registrations and the env calls the step made.""" made = SimpleNamespace(calls=[], register=AsyncMock(return_value={"ok": True})) - async def invoke(base_url, card, uri, params=None, timeout=30, verify=True, *, method=None): + async def invoke(base_url, card, uri, params=None, timeout=30, verify=True, *, method=None, headers=None): made.calls.append((base_url, card["name"], uri, method, params)) return {"name": params["name"], "description": "How to use this env", "skill_md": f"---\nname: {params['name']}\n"} @@ -69,7 +69,7 @@ async def test_each_envs_skill_is_installed_under_its_environment_name_and_recor async def test_a_skill_with_files_is_stored_once_and_installed_as_a_bundle(agent, local_stores, monkeypatch): files = {"LINUX.md": "# Linux\n", "WORKFLOW.md": "# Workflow\n"} - async def invoke(base_url, card, uri, params=None, timeout=30, verify=True, *, method=None): + async def invoke(base_url, card, uri, params=None, timeout=30, verify=True, *, method=None, headers=None): return {"name": params["name"], "description": "Drive the desktop", "skill_md": "# Skill\n", "files": files} monkeypatch.setattr(env_module.protocol_v1, "invoke_extension", invoke) @@ -86,7 +86,7 @@ async def invoke(base_url, card, uri, params=None, timeout=30, verify=True, *, m @pytest.mark.asyncio async def test_a_skill_file_whose_path_leaves_its_folder_is_refused_before_anything_is_stored(agent, local_stores, monkeypatch): - async def invoke(base_url, card, uri, params=None, timeout=30, verify=True, *, method=None): + async def invoke(base_url, card, uri, params=None, timeout=30, verify=True, *, method=None, headers=None): return {"name": params["name"], "description": "d", "skill_md": "# Skill\n", "files": {"../../victim": "x"}} monkeypatch.setattr(env_module.protocol_v1, "invoke_extension", invoke) diff --git a/tst/unit/task_step/snapshot_env_upload_grant_test.py b/tst/unit/task_step/snapshot_env_upload_grant_test.py index 9554f333c..b9c82a9d4 100644 --- a/tst/unit/task_step/snapshot_env_upload_grant_test.py +++ b/tst/unit/task_step/snapshot_env_upload_grant_test.py @@ -43,7 +43,7 @@ def store(local_stores, tmp_path): def pushes(monkeypatch): pushed: list[str] = [] - async def push(base_url, card, timeout_seconds): + async def push(base_url, card, timeout_seconds, headers): pushed.append(base_url) monkeypatch.setattr(snapshot_mod, "_push_s3_credentials", push) diff --git a/tst/unit/task_step/snapshot_env_without_gateway_test.py b/tst/unit/task_step/snapshot_env_without_gateway_test.py index a262d6407..ad213fe01 100644 --- a/tst/unit/task_step/snapshot_env_without_gateway_test.py +++ b/tst/unit/task_step/snapshot_env_without_gateway_test.py @@ -1,6 +1,6 @@ -"""snapshot_env on an env deployed without a gateway: reached through the card its deployment recorded, its -environments the card's children its names reach that export, and the env itself when it exports beside them, or the -env alone, each kept as one file as through a gateway and sent its own data/get params; an environment +"""snapshot_env on an env deployed without a gateway: reached through the card its deployment recorded, with its control +token, its environments the card's children its names reach that export, and the env itself when it exports beside +them, or the env alone, each kept as one file as through a gateway and sent its own data/get params; an environment whose data/get returns a file tree, a computer's, kept file by file by its paths under a grant sized for a file tree; and an env that fails under the upload grant asked again without it.""" @@ -124,6 +124,24 @@ async def test_the_env_itself_is_reached_when_its_card_has_no_children(local_sto assert sent == [("/agentenv", {})] +@pytest.mark.asyncio +async def test_a_record_with_a_control_token_sends_it_on_its_data_get(local_stores, monkeypatch): + seen, real = [], httpx.AsyncClient + + def handle(request: httpx.Request) -> httpx.Response: + seen.append(request.headers.get("authorization")) + return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": {"parts": [ + {"kind": "data", "data": {"rows": 1}}]}}) + + monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: real(transport=httpx.MockTransport(handle))) + record = _record(_server()) + record.control_token = "t0ken" + + await SnapshotEnvTaskStep.snapshot_env_state(env=_Desk(), gateway_url=None, snapshot_id="snap-t", deployed=record) + + assert seen == ["Bearer t0ken"] + + @pytest.mark.asyncio async def test_a_file_tree_is_kept_file_by_file_by_its_paths_under_a_grant_sized_for_it(local_stores, tmp_path, monkeypatch): diff --git a/tst/unit/task_step/sync_env_clock_test.py b/tst/unit/task_step/sync_env_clock_test.py index 31ff6735f..471ca3a3a 100644 --- a/tst/unit/task_step/sync_env_clock_test.py +++ b/tst/unit/task_step/sync_env_clock_test.py @@ -35,7 +35,7 @@ async def test_sync_one_skips_unadvertised(monkeypatch): async def fake_card(base_url, **kw): return {"capabilities": {"extensions": []}} monkeypatch.setattr(mod.protocol_v1, "get_card", fake_card) - out = await _step()._sync_one(None, "http://gw", "email", CLOCK_URL) + out = await _step()._sync_one(_cardless(), "http://gw", "email", CLOCK_URL) assert out == {"synced": False, "reason": "not_advertised"} @@ -55,7 +55,7 @@ async def fake_invoke(base_url, c, uri, params=None, **kw): monkeypatch.setattr(mod.protocol_v1, "get_card", fake_card) monkeypatch.setattr(mod.protocol_v1, "invoke_extension", fake_invoke) - out = await _step()._sync_one(None, "http://gw", "email", CLOCK_URL) + out = await _step()._sync_one(_cardless(), "http://gw", "email", CLOCK_URL) assert out["synced"] is True assert calls["uri"] == EXT_CLOCK_URI and calls["params"] == {"env_get_time_url": CLOCK_URL} @@ -66,7 +66,7 @@ async def fake_card(base_url, **kw): return {"capabilities": {"extensions": []}} monkeypatch.setattr(mod.protocol_v1, "get_card", fake_card) with pytest.raises(RuntimeError): - await _step(tolerate_missing_sync_time=False)._sync_one(None, "http://gw", "email", CLOCK_URL) + await _step(tolerate_missing_sync_time=False)._sync_one(_cardless(), "http://gw", "email", CLOCK_URL) @pytest.mark.asyncio @@ -144,6 +144,12 @@ async def fetch(client, key, base_url): return json.loads((await gw._serve_env_card()).body) +def _cardless() -> DeployedEnv: + """A record that kept no card, which a sync reads live from the gateway.""" + return DeployedGatewayEnv(env_id="e1", env_version=1, gateway_url="http://gw", mcp_url="http://gw/mcp", db_web_url=None, + sandbox_id="sb-1") + + def _carded(card: dict) -> DeployedEnv: from agent_env.env.gateway.constants import WELL_KNOWN_PATH diff --git a/tst/unit/task_step/test_apply_server_config.py b/tst/unit/task_step/test_apply_server_config.py index 07dec1269..f9badb26c 100644 --- a/tst/unit/task_step/test_apply_server_config.py +++ b/tst/unit/task_step/test_apply_server_config.py @@ -88,14 +88,14 @@ def __init__(self, cards: dict[str, dict]): self.get_calls: list[str] = [] self.post_calls: list[tuple[str, dict]] = [] - async def get(self, url, *, timeout=None, params=None): + async def get(self, url, *, timeout=None, params=None, headers=None): self.get_calls.append(url) for service, card in self.cards.items(): if f"/svc/mcp-{service}/.well-known/agent-env.json" in url: return _resp(200, url, card) return _resp(404, url, {"error": "no card"}) - async def request(self, method, url, *, json=None, timeout=None): + async def request(self, method, url, *, json=None, timeout=None, headers=None): self.post_calls.append((url, json)) return _resp(200, url, {"ok": True, "echo": json}) @@ -269,7 +269,7 @@ async def test_tolerate_unadvertised_still_fails_on_rejected_args(): # fails loud — that's the fail-loud-on-unknown-persona contract. gw = _MockGateway({"slack": _card(_ext(_SET_ACTING_USER_URI, "/agentenv/ext/set_acting_user"))}) - async def rejecting_request(method, url, *, json=None, timeout=None): + async def rejecting_request(method, url, *, json=None, timeout=None, headers=None): gw.post_calls.append((url, json)) return _resp(500, url, {"error": "user_not_found: nobody@x.com"}) @@ -452,7 +452,7 @@ async def test_non_404_card_fetch_still_raises_under_tolerate(): # endpoint that errors (500) is a real fault and must still fail loud. gw = _MockGateway({}) - async def failing_get(url, *, timeout=None, params=None): + async def failing_get(url, *, timeout=None, params=None, headers=None): gw.get_calls.append(url) return _resp(500, url, {"error": "boom"}) @@ -472,7 +472,7 @@ async def failing_get(url, *, timeout=None, params=None): async def test_server_rejects_args_raises_with_status(): gw = _MockGateway({"slack": _card(_ext(_SET_ERRORS_URI, "/agentenv/ext/set_errors"))}) - async def rejecting_request(method, url, *, json=None, timeout=None): + async def rejecting_request(method, url, *, json=None, timeout=None, headers=None): gw.post_calls.append((url, json)) return _resp(400, url, {"error": "error_rate must be between 0.0 and 1.0"}) diff --git a/tst/unit/task_step/test_prompt_agent_triggers.py b/tst/unit/task_step/test_prompt_agent_triggers.py index a34bda4a3..5f26930a6 100644 --- a/tst/unit/task_step/test_prompt_agent_triggers.py +++ b/tst/unit/task_step/test_prompt_agent_triggers.py @@ -47,7 +47,7 @@ def _resp(status: int, body: dict, method: str = "GET") -> httpx.Response: async def test_snapshot_shape_multi_env(): sent = [] - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): sent.append((url, params, timeout)) return _resp(200, {"triggers": [{"id": "v6-rate", "status": "fired"}, {"id": "v6-accept", "status": "armed"}]}) @@ -63,7 +63,7 @@ async def fake_get(self, url, timeout, params=None): @pytest.mark.asyncio async def test_an_env_whose_card_offers_no_triggers_state_reads_as_empty_with_no_request(): - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): raise AssertionError("no request for an env whose card offers no triggers state") ctx = _ctx(_env("env-x", "http://gw-x", EXT_CLOCK_URI)) @@ -75,7 +75,7 @@ async def fake_get(self, url, timeout, params=None): @pytest.mark.asyncio async def test_a_404_from_an_advertised_triggers_state_fails_closed(): - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): return _resp(404, {}) ctx = _ctx(_env("env-x", "http://gw-x")) @@ -87,7 +87,7 @@ async def fake_get(self, url, timeout, params=None): @pytest.mark.asyncio async def test_fail_closed_raises_on_persistent_error(): - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): raise httpx.ConnectError("gateway down") ctx = _ctx(_env("env-x", "http://gw-x")) @@ -101,7 +101,7 @@ async def fake_get(self, url, timeout, params=None): async def test_retries_then_succeeds(): calls = {"n": 0} - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): calls["n"] += 1 if calls["n"] == 1: raise httpx.ConnectError("transient") @@ -119,7 +119,7 @@ async def fake_get(self, url, timeout, params=None): async def test_triggers_state_is_read_at_the_stored_cards_address(): sent = [] - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): sent.append(url) return _resp(200, {"triggers": []}) @@ -220,7 +220,7 @@ def _patched_store(store=None, *, key_prefix=""): async def test_capture_uploads_state_and_records_summary(): calls = {"n": 0} - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): if url.endswith("/clock/state"): return _resp(200, _CLOCK) calls["n"] += 1 @@ -256,7 +256,7 @@ async def fake_get(self, url, timeout, params=None): @pytest.mark.asyncio async def test_capture_is_kept_under_the_fixture_prefix(): - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): return _resp(200, _CLOCK if url.endswith("/clock/state") else _STATE) ctx = TaskStepContext( @@ -274,7 +274,7 @@ async def fake_get(self, url, timeout, params=None): @pytest.mark.asyncio async def test_capture_records_clock_context_and_per_trigger_detail(): """A recurrence reads "armed" forever, so only fire_count shows it fired.""" - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): return _resp(200, _CLOCK if url.endswith("/clock/state") else _CLOCK_STATE) ctx = TaskStepContext( @@ -313,7 +313,7 @@ async def test_capture_distinguishes_a_failed_coupling_from_one_that_never_fired "failure_count": 2, "last_failure_at": "2026-06-01T12:00:00Z"}, ]} - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): return _resp(200, _CLOCK if url.endswith("/clock/state") else failed) ctx = TaskStepContext( @@ -343,7 +343,7 @@ async def test_capture_does_not_wait_on_a_firing_recurring_clock_trigger(): "status": "firing", "fire_count": 3, "next_mark": "2026-06-03T00:00:00Z"}]} calls = {"n": 0} - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): if url.endswith("/clock/state"): return _resp(200, _CLOCK) calls["n"] += 1 @@ -366,7 +366,7 @@ async def fake_get(self, url, timeout, params=None): @pytest.mark.asyncio async def test_capture_tolerates_a_gateway_without_clock_state(): """An unreachable clock state: capture still succeeds, clock recorded as None.""" - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): if url.endswith("/clock/state"): raise httpx.ConnectError("no clock route") return _resp(200, _STATE) @@ -397,7 +397,7 @@ async def fake_get(self, url, timeout, params=None): async def test_capture_records_no_clock_with_no_request_when_the_card_offers_no_clock_state(): sent = [] - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): sent.append(url) return _resp(200, _STATE) @@ -419,7 +419,7 @@ async def fake_get(self, url, timeout, params=None): async def test_capture_reads_triggers_and_clock_at_the_stored_cards_address(): sent = [] - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): sent.append((url, timeout)) return _resp(200, _CLOCK if url.endswith("/clock/state") else _STATE) @@ -488,7 +488,7 @@ def test_settle_poll_does_not_wait_out_a_delay_or_working_hours(): async def test_capture_noop_without_registrations(): calls = {"n": 0} - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): calls["n"] += 1 return _resp(200, _STATE) @@ -502,7 +502,7 @@ async def fake_get(self, url, timeout, params=None): @pytest.mark.asyncio async def test_capture_fail_open_records_error(): - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): raise httpx.ConnectError("gateway gone") ctx = TaskStepContext( @@ -526,7 +526,7 @@ async def test_capture_grace_polls_until_settled(): responses = [firing, _STATE] calls = {"n": 0} - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): if url.endswith("/clock/state"): return _resp(200, _CLOCK) body = responses[min(calls["n"], len(responses) - 1)] @@ -572,7 +572,7 @@ async def hanging_get(self, url, timeout): @pytest.mark.asyncio async def test_capture_put_failure_keeps_inline_summary(): - async def fake_get(self, url, timeout, params=None): + async def fake_get(self, url, timeout, params=None, headers=None): return _resp(200, _STATE) class _FailingStore(_FakeStore): diff --git a/tst/unit/task_step/test_run_code.py b/tst/unit/task_step/test_run_code.py index 3d48575a8..de5865b58 100644 --- a/tst/unit/task_step/test_run_code.py +++ b/tst/unit/task_step/test_run_code.py @@ -26,7 +26,7 @@ from agent_env.providers.sandbox_providers.sandbox import Sandbox, VmSandbox from agent_env.task_step.context import DeployedAgent, DeployedSandbox, TaskStepContext from agent_env.task_step.task_steps import run_code as _run_code_module -from tst.unit.computer_home import CARD, Computer +from tst.unit.computer_home import CARD, TOKEN, Computer from tst.unit.event_loop_probe import on_event_loop from agent_env.task_step.task_steps.run_code import ( RunCodeExecutionError, @@ -1309,13 +1309,15 @@ async def _run_in(computer, body, deployed=None, **step): @pytest.mark.asyncio -async def test_a_script_for_a_computer_runs_inside_it_and_reaches_its_driver_on_loopback(computer): +async def test_a_script_for_a_computer_runs_inside_it_and_reaches_its_driver_on_loopback_with_its_token(computer): body = b"import os\ndef run(inp):\n return {'env': inp['env'], 'cwd': os.getcwd()}\n" ctx = await _run_in(computer, body) result = ctx.metadata["script_results"]["run-code-1"] - assert result["env"] == {"id": "desk", "url": "http://127.0.0.1:18765", "mcp_url": "http://127.0.0.1:18765/mcp"} + assert result["env"] == {"id": "desk", "url": "http://127.0.0.1:18765", "mcp_url": "http://127.0.0.1:18765/mcp", + "headers": {"Authorization": f"Bearer {TOKEN}"}} + assert computer.headers and all(sent == {"Authorization": f"Bearer {TOKEN}"} for sent in computer.headers) assert Path(result["cwd"]).resolve() == computer.home.resolve() run_dir = ".agentenv/run_code/run-code-1" assert [call[1] for call in computer.calls if call[0] == "data/add"] == [ @@ -1349,7 +1351,7 @@ async def test_a_run_the_computer_stopped_at_its_timeout_is_reported_as_one(comp async def test_a_run_whose_call_drops_fails_naming_why_and_the_next_one_runs_afresh(computer, monkeypatch): invoke, dropped = computer.invoke_extension, [] - async def drop_once(base_url, card, uri, params=None, timeout=30, verify=True, *, method=None): + async def drop_once(base_url, card, uri, params=None, timeout=30, verify=True, *, method=None, headers=None): if params["argv"][0] == "python3" and not dropped: dropped.append(params["request_id"]) raise httpx.RemoteProtocolError("the driver went away") diff --git a/tst/unit/task_step/test_snapshot_env.py b/tst/unit/task_step/test_snapshot_env.py index b118bc393..473cf9af7 100644 --- a/tst/unit/task_step/test_snapshot_env.py +++ b/tst/unit/task_step/test_snapshot_env.py @@ -246,7 +246,7 @@ async def __aenter__(self): async def __aexit__(self, *exc): return False - def stream(self, method, url, params=None): + def stream(self, method, url, params=None, headers=None): self.requests.append((method, url, params)) client = self @@ -464,7 +464,7 @@ def __init__(self, rows=None, error=None, page_size=2): self.rows = rows or [] self.page_size = page_size - async def request(self, method, url, params=None): + async def request(self, method, url, params=None, headers=None): self.requests.append((method, url, params)) if self.error: raise self.error @@ -736,7 +736,7 @@ async def test_export_service_to_file_materializes_filepart_zip(monkeypatch, tmp async def fake_supports_v1(base_url): return True - async def fake_get_data(base_url, timeout=None): + async def fake_get_data(base_url, timeout=None, headers=None): return GetDataResponse(parts=[part]) async def fake_get_card(base_url, timeout=10): @@ -782,7 +782,7 @@ async def test_export_service_to_file_resolves_relative_uri_and_streams( async def fake_supports_v1(base_url): return True - async def fake_get_data(base_url, timeout=None): + async def fake_get_data(base_url, timeout=None, headers=None): return GetDataResponse(parts=[part]) async def fake_get_card(base_url, timeout=10): @@ -815,7 +815,7 @@ async def __aenter__(self): async def __aexit__(self, *exc): return False - def stream(self, method, url, timeout=None): + def stream(self, method, url, timeout=None, headers=None): captured["method"] = method captured["url"] = url return _FakeStreamCtx() @@ -840,7 +840,7 @@ def _serve_filepart_uri(monkeypatch, uri): async def fake_supports_v1(base_url): return True - async def fake_get_data(base_url, timeout=None): + async def fake_get_data(base_url, timeout=None, headers=None): return GetDataResponse(parts=[part]) async def fake_get_card(base_url, timeout=10): @@ -975,7 +975,7 @@ async def test_export_service_to_file_filepart_without_bytes_or_uri_raises_value async def fake_supports_v1(base_url): return True - async def fake_get_data(base_url, timeout=None): + async def fake_get_data(base_url, timeout=None, headers=None): return GetDataResponse(parts=[part]) async def fake_get_card(base_url, timeout=10): diff --git a/tst/unit/task_step/verifiers/test_env_outcome_verifier_step.py b/tst/unit/task_step/verifiers/test_env_outcome_verifier_step.py index 8c98dfaee..0ae826cb0 100644 --- a/tst/unit/task_step/verifiers/test_env_outcome_verifier_step.py +++ b/tst/unit/task_step/verifiers/test_env_outcome_verifier_step.py @@ -5,6 +5,7 @@ import pytest from agent_env.artifact import FileArtifact +from agent_env.env.env import DeployedEnv from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_steps.verifiers.env_outcome_verifier import EnvOutcomeVerifierTaskStep, ScoreAggregator from tst.unit.event_loop_probe import on_event_loop @@ -77,7 +78,7 @@ def load(): monkeypatch.setattr(FileArtifact, "get", staticmethod(lambda id, version=None: SimpleNamespace(load=load))) step = EnvOutcomeVerifierTaskStep(id="outcome", version=None, env_id="e", file_artifact_id="script", verifier_id="v") ctx = TaskStepContext() - ctx.deployed_envs.append(SimpleNamespace(env_id="e", mcp_url="http://mcp")) + ctx.deployed_envs.append(DeployedEnv(env_id="e", env_version=1, mcp_url="http://mcp")) asyncio.run(step.execute(ctx)) diff --git a/tst/unit/task_step/verifiers/test_run_context.py b/tst/unit/task_step/verifiers/test_run_context.py index f6f542fd4..f9477a6c4 100644 --- a/tst/unit/task_step/verifiers/test_run_context.py +++ b/tst/unit/task_step/verifiers/test_run_context.py @@ -9,10 +9,12 @@ from types import SimpleNamespace import pytest +from agentenv_protocol import client as protocol_v1 from agent_env.a2a_agent import conversation_store from agent_env.artifact import FileArtifact from agent_env.config import get_config +from agent_env.env.env import DeployedEnv from agent_env.task_step.context import PromptResponse, TaskStepContext from agent_env.task_step.task_steps.verifiers.env_outcome_verifier import EnvOutcomeVerifierTaskStep from agent_env.task_step.task_steps.verifiers import Run, RunRecordUnavailable @@ -384,7 +386,7 @@ def _outcome(script: bytes, monkeypatch, **kw) -> EnvOutcomeVerifierTaskStep: @pytest.mark.asyncio async def test_a_verify_asking_for_run_is_handed_the_run_it_grades(monkeypatch): context, _, _ = recorded_run() - context.deployed_envs.append(SimpleNamespace(env_id="calendar", mcp_url="http://mcp")) + context.deployed_envs.append(DeployedEnv(env_id="calendar", env_version=1, mcp_url="http://mcp")) await _outcome(RUN_SCRIPT, monkeypatch, prompt_id="p1").execute(context) @@ -396,7 +398,7 @@ async def test_a_verify_asking_for_run_is_handed_the_run_it_grades(monkeypatch): async def test_without_a_prompt_id_the_runs_last_prompt_is_graded(monkeypatch): context, _, _ = recorded_run() context.prompt_responses.insert(0, PromptResponse(prompt_id="warmup", response="ok", prompt_text="hi", step_id="w")) - context.deployed_envs.append(SimpleNamespace(env_id="calendar", mcp_url="http://mcp")) + context.deployed_envs.append(DeployedEnv(env_id="calendar", env_version=1, mcp_url="http://mcp")) await _outcome(RUN_SCRIPT, monkeypatch).execute(context) @@ -404,23 +406,28 @@ async def test_without_a_prompt_id_the_runs_last_prompt_is_graded(monkeypatch): @pytest.mark.asyncio -async def test_a_verify_asking_for_env_url_is_handed_the_envs_address(monkeypatch): +async def test_a_verify_asking_for_env_url_is_handed_the_envs_address_and_reaches_it_with_its_control_token( + monkeypatch): context, _, _ = recorded_run() context.deployed_envs.append(SimpleNamespace(env_id="calendar", mcp_url="http://127.0.0.1:20001/mcp", - environment_url="http://127.0.0.1:20001")) - script = (b"async def verify(mcp_url, env_url, run=None):\n" - b" return [{'id': 'c', 'score': 1.0, 'env_url': env_url, 'turns': len(run.turns)}]\n") + environment_url="http://127.0.0.1:20001", control_token="t0ken")) + script = (b"from agentenv_protocol import client\n" + b"async def verify(mcp_url, env_url, run=None):\n" + b" return [{'id': 'c', 'score': 1.0, 'env_url': env_url, 'turns': len(run.turns),\n" + b" 'headers': client.request_headers(env_url + '/agentenv/ext/computer-exec/run')}]\n") await _outcome(script, monkeypatch, prompt_id="p1").execute(context) [row] = context.metadata["verifications"]["v"]["results"] assert (row["env_url"], row["turns"]) == ("http://127.0.0.1:20001", 3) + assert row["headers"] == {"Authorization": "Bearer t0ken"} + assert protocol_v1.request_headers("http://127.0.0.1:20001/agentenv") == {} @pytest.mark.asyncio async def test_a_verify_with_an_unrelated_second_parameter_is_called_as_before(monkeypatch): context, _, _ = recorded_run() - context.deployed_envs.append(SimpleNamespace(env_id="calendar", mcp_url="http://mcp")) + context.deployed_envs.append(DeployedEnv(env_id="calendar", env_version=1, mcp_url="http://mcp")) script = b"async def verify(mcp_url, retries=3):\n return [{'id': 'c', 'score': 1.0, 'retries': retries}]\n" await _outcome(script, monkeypatch, prompt_id="p1").execute(context) @@ -431,7 +438,7 @@ async def test_a_verify_with_an_unrelated_second_parameter_is_called_as_before(m @pytest.mark.asyncio async def test_a_one_argument_verify_is_called_as_before(monkeypatch): context, _, _ = recorded_run() - context.deployed_envs.append(SimpleNamespace(env_id="calendar", mcp_url="http://mcp")) + context.deployed_envs.append(DeployedEnv(env_id="calendar", env_version=1, mcp_url="http://mcp")) script = b"async def verify(mcp_url):\n return [{'id': 'c', 'score': 1.0, 'url': mcp_url}]\n" await _outcome(script, monkeypatch, prompt_id="p1").execute(context) @@ -442,7 +449,7 @@ async def test_a_one_argument_verify_is_called_as_before(monkeypatch): @pytest.mark.asyncio async def test_an_unknown_prompt_id_fails_the_step(monkeypatch): context, _, _ = recorded_run() - context.deployed_envs.append(SimpleNamespace(env_id="calendar", mcp_url="http://mcp")) + context.deployed_envs.append(DeployedEnv(env_id="calendar", env_version=1, mcp_url="http://mcp")) with pytest.raises(RuntimeError, match="prompt_id='nope' not found"): await _outcome(RUN_SCRIPT, monkeypatch, prompt_id="nope").execute(context) From 764a372441dac5b746fa05c39ea7425b57f8bab7 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 01:03:52 -0700 Subject: [PATCH 09/16] test(computer): a restarted desktop gets the time a deploy gives a booting computer Run alone the restart test passes in ~55 s on this branch and on main; after the other computer suites on a loaded machine, its 60 s wait ran out (once on #426's CI, once on an EC2 box). Co-Authored-By: Claude Opus 5.5 --- tst/integration/computer/recording_chaos_test.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tst/integration/computer/recording_chaos_test.py b/tst/integration/computer/recording_chaos_test.py index b619def33..a2ffb8583 100644 --- a/tst/integration/computer/recording_chaos_test.py +++ b/tst/integration/computer/recording_chaos_test.py @@ -7,6 +7,7 @@ import json import platform import subprocess +import time import uuid from contextlib import asynccontextmanager from datetime import datetime @@ -20,6 +21,7 @@ from agentenv_protocol.computer import COMPUTER_READY, COMPUTER_RECORDING, Recording, RecordingManifest from agentenv_protocol.computer.conformance.client import Driver from agentenv_protocol.computer.recording import DISK_CHECK_S, RECORDINGS_DIR +from agent_env.env.envs.computer.utils.boot import READY_TIMEOUT_S from tst.integration.computer.conftest import docker pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -123,8 +125,10 @@ async def _desk(image: str, *docker_args: str): async def _ready(name: str) -> Driver: - """A client for the container's driver once its computer is ready; a restart may move its port.""" - for _ in range(240): + """A client for the container's driver once its computer is ready, within the time a deploy gives one; a restart may + move its port.""" + deadline = time.monotonic() + READY_TIMEOUT_S + while time.monotonic() < deadline: port = subprocess.run(["docker", "port", name, str(PORT)], capture_output=True, text=True).stdout if port: try: @@ -134,7 +138,7 @@ async def _ready(name: str) -> Driver: except Exception: # not serving yet pass await asyncio.sleep(0.25) - raise TimeoutError(f"{name}'s computer never became ready") + raise TimeoutError(f"{name}'s computer wasn't ready within {READY_TIMEOUT_S:.0f} s") async def _layout(driver: Driver) -> dict: From d52cd9b6793b71a80475ec12f118f99f38b3764c Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 01:05:51 -0700 Subject: [PATCH 10/16] fix(computer): a deployment's state is read with its control token; name the token's size Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/utils/boot.py | 4 +++- src/agent_env/env/legacy_protocol.py | 5 +++-- tst/unit/env/test_legacy_protocol.py | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/agent_env/env/envs/computer/utils/boot.py b/src/agent_env/env/envs/computer/utils/boot.py index 6b7b8952c..d59ae6530 100644 --- a/src/agent_env/env/envs/computer/utils/boot.py +++ b/src/agent_env/env/envs/computer/utils/boot.py @@ -36,6 +36,8 @@ _POLL_S = 0.5 #: How a setting a record keeps shows a secret's or a generated one's value, which it never holds. WITHHELD = "" +#: How many random bytes a computer's control token carries: 256 bits, past guessing. +CONTROL_TOKEN_BYTES = 32 @dataclass @@ -91,7 +93,7 @@ def recorded_settings(computer: ComputerArtifact, values: dict[str, Any]) -> dic def control_token() -> str: """A token for one computer's framework-only routes (``CONTROL_TOKEN_ENV``), which agent-env holds and gives no agent.""" - return secrets.token_urlsafe(32) + return secrets.token_urlsafe(CONTROL_TOKEN_BYTES) async def boot(sandbox_provider: SandboxProvider, *, image: str, port: int, environment: dict[str, str], diff --git a/src/agent_env/env/legacy_protocol.py b/src/agent_env/env/legacy_protocol.py index e9533a844..3721e2284 100644 --- a/src/agent_env/env/legacy_protocol.py +++ b/src/agent_env/env/legacy_protocol.py @@ -96,7 +96,8 @@ async def _export_state_at(base_url: str, timeout: int, verify: bool) -> dict: async def service_state(deployed: Optional[DeployedEnv], gateway_url: Optional[str], environment_name: str) -> dict: - """An MCP service's state as JSON: its v1 ``data/get`` answer when that is data, else ``GET /export-state``. + """An MCP service's state as JSON: its v1 ``data/get`` answer when that is data, asked with the deployment's + control token, else ``GET /export-state``. A service that exports a file from ``data/get`` (a bundle of its database, to reload from) still serves its state as JSON at ``/export-state``. @@ -104,7 +105,7 @@ async def service_state(deployed: Optional[DeployedEnv], gateway_url: Optional[s base_url = await v1_base_url(deployed, gateway_url, environment_name, mcp=True) if base_url is None: return await export_state(gateway_url, environment_name) - response = await protocol_v1.get_data(base_url) + response = await protocol_v1.get_data(base_url, headers=deployed.control_headers if deployed is not None else None) if not response.parts: return {} if isinstance(response.parts[0], DataPart): diff --git a/tst/unit/env/test_legacy_protocol.py b/tst/unit/env/test_legacy_protocol.py index 6f5a60a19..9508e8d2e 100644 --- a/tst/unit/env/test_legacy_protocol.py +++ b/tst/unit/env/test_legacy_protocol.py @@ -10,6 +10,7 @@ from agentenv_protocol.client import GetDataResponse from agent_env.env import legacy_protocol +from agent_env.env.env import DeployedSandboxEnv @pytest.mark.asyncio @@ -145,6 +146,26 @@ def handle(request: httpx.Request) -> httpx.Response: return asked +@pytest.mark.asyncio +async def test_a_childs_state_is_read_with_its_deployments_control_token(monkeypatch): + seen, real = [], httpx.AsyncClient + + def handle(request: httpx.Request) -> httpx.Response: + seen.append((request.url.path, request.headers.get("authorization"))) + return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": {"parts": [ + {"kind": "data", "data": {"alarms": []}}]}}) + + monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: real(transport=httpx.MockTransport(handle))) + deployed = DeployedSandboxEnv( + env_id="desk", env_version=1, sandbox_id="sb-1", environment_card_url=f"http://desk:18765{WELL_KNOWN_PATH}", + environment_card={"name": "desk", "capabilities": {"operations": ["data/get"]}, + "children_environments": [{"name": "clock", "url": "/svc/clock/agentenv"}]}, + control_token="t0ken") + + assert await legacy_protocol.service_state(deployed, None, "clock") == {"alarms": []} + assert seen == [("/svc/clock/agentenv", "Bearer t0ken")] + + @pytest.mark.asyncio @pytest.mark.parametrize("answer, state, reads_export_state", [ ([DataPart(data={"messages": 2})], {"messages": 2}, False), From 5ecb44483b4078a5906460a74d40358038e8a4d4 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 01:06:34 -0700 Subject: [PATCH 11/16] test(snapshot): the agent state capture's get_data fakes take headers Co-Authored-By: Claude Opus 5.5 --- tst/unit/task_step/test_snapshot_agent_state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tst/unit/task_step/test_snapshot_agent_state.py b/tst/unit/task_step/test_snapshot_agent_state.py index 8682c673e..551f1cd26 100644 --- a/tst/unit/task_step/test_snapshot_agent_state.py +++ b/tst/unit/task_step/test_snapshot_agent_state.py @@ -167,7 +167,7 @@ async def _no_probe(_base): got, legacy = [], [] - async def _get_data(base, timeout=30): + async def _get_data(base, timeout=30, headers=None): got.append(base) return GetDataResponse(parts=[DataPart(data={"rows": 1})]) @@ -200,7 +200,7 @@ async def test_a_service_that_exports_a_file_bundle_is_captured_from_its_export_ monkeypatch.setattr(artifact_mod, "EnvironmentUniverseArtifact", type("U", (), {"get": staticmethod(lambda _id: _StubUniverse())})) monkeypatch.setattr(config_mod, "get_config", lambda: type("Cfg", (), {"get_object_store_at": lambda self, object_url: store})()) - async def _get_data(base, timeout=30): + async def _get_data(base, timeout=30, headers=None): if base.endswith("/mcp-calendar"): return GetDataResponse(parts=[uploaded_file_part("calendar.zip", name="calendar.zip", mime_type="application/zip")]) return GetDataResponse(parts=[DataPart(data={"contacts": 4})]) From 08998d74090cd5bfd00382161ef158724486cf04 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 07:41:22 -0800 Subject: [PATCH 12/16] refactor(computer): an app's data plane in its own class; a card's operations and the driver's app errors in one shape The data-call logic (turns, stop and relaunch, the reset after a failed load, timeouts) moves out of ComputerAppEnvironment, the class app authors subclass, into app_data.AppDataPlane, which the app's handlers and launch hand their calls to. client.operations(card) replaces each hand-rolled read of a card's capabilities.operations. The driver answers a request for an app it doesn't have, or one whose server doesn't answer, with the protocol's errors (target_unavailable, delivery_failed). Co-Authored-By: Claude Opus 5.5 --- .../src/agentenv_protocol/client.py | 6 + .../src/agentenv_protocol/computer/app.py | 125 +++------------- .../agentenv_protocol/computer/app_data.py | 133 +++++++++++++++++- .../agentenv_protocol/computer/app_runtime.py | 5 +- .../computer/conformance/checks.py | 2 +- .../src/agentenv_protocol/computer/driver.py | 11 +- .../agentenv_protocol/computer/serve_app.py | 2 +- .../agentenv-protocol/tests/test_client.py | 8 ++ .../tests/test_computer_app_data.py | 8 +- .../tests/test_computer_app_server.py | 4 +- src/agent_env/env/env.py | 2 +- src/agent_env/env/envs/_deployment.py | 2 +- src/agent_env/env/envs/computer/env.py | 3 +- .../env/envs/computer/utils/inside.py | 2 +- .../verify_env_core_protocol.py | 4 +- .../task_step/task_steps/snapshot_env.py | 4 +- 16 files changed, 184 insertions(+), 137 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/client.py b/packages/agentenv-protocol/src/agentenv_protocol/client.py index 0d0bfe648..33f6f2375 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/client.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/client.py @@ -147,6 +147,12 @@ def intake_declaration(card: dict) -> dict | None: return params if params else None +def operations(card: dict) -> list[str] | None: + """The data-plane methods a card declares it serves (``capabilities.operations``), or None when it declares none, + as an older card doesn't.""" + return (card.get("capabilities") or {}).get("operations") + + def find_tool(card: dict, name: str) -> dict | None: """Return the MCP tool advertised under `name`, or None. diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py index f935eeae6..6772fc087 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py @@ -9,12 +9,10 @@ and a reset, as declared for the OS it runs on.""" from __future__ import annotations -import asyncio import functools import inspect import itertools import json -import logging import os from dataclasses import dataclass from pathlib import Path, PurePosixPath @@ -28,7 +26,7 @@ from ..transfers import RelativePath, WriteNamespaceGrant from ..types import (METHOD_ADD, METHOD_GET, METHOD_RESET, SKILL_EXTENSION_URI, EnvironmentExtension, IntakeDeclaration, IntakeFormat, intake_extension) -from . import app_data +from .app_data import AppDataPlane from .app_runtime import AppRuntime from .errors import InvalidArgument from .platform import detect @@ -39,7 +37,6 @@ _READY_CHECK_ATTR = "_aee_ready_check" _DATA_METHODS = {OP_RESET_DATA: METHOD_RESET, OP_ADD_DATA: METHOD_ADD, OP_GET_DATA: METHOD_GET} _PATHS = TypeAdapter(list[RelativePath]) -logger = logging.getLogger(__name__) #: What a launch's window can be matched by, as the session lists each window. WINDOW_KEYS = frozenset({"class", "instance", "title"}) #: The framework extension an app's own server serves: its launch as params, and its ``launch`` and ``ready`` methods. @@ -357,8 +354,9 @@ class ComputerAppEnvironment(AgentEnvEnvironment): it runs on. Its data plane is its ``@add_data``, ``@get_data`` and ``@reset_data`` hooks, or, with none, defaults on its - ``paths`` (``app_data``). The base class serves them around its contract: a load and a reset stop the app first and - relaunch it after, a load that fails is followed by a reset, and each call runs within ``data_timeout_s``.""" + ``paths``, which the base class serves around its contract (``app_data.AppDataPlane``): one call at a time, a load + and a reset with the app stopped and relaunched after, a load that fails followed by a reset, and each call within + ``data_timeout_s``.""" supports: ClassVar[list] = [] permissions: ClassVar[list[str]] = [] @@ -366,6 +364,7 @@ class ComputerAppEnvironment(AgentEnvEnvironment): #: Its data hooks' method names, by the operation each serves. _data_hooks: ClassVar[dict[str, str]] = {} _computer_app: Optional[AppRuntime] = None + _computer_app_data: Optional[AppDataPlane] = None launch: Optional[Launch] = None paths: list[str] = [] #: A file of the app's folder holding its skill for agents, when no ``@agent_skill`` method writes it. @@ -407,6 +406,9 @@ def __init__(self) -> None: self.launch, {fn.__name__: getattr(self, fn.__name__) for fn in _methods(cls) if hasattr(fn, _READY_CHECK_ATTR) and _runs_on(getattr(fn, _READY_CHECK_ATTR), on)}, ready.timeout_s if ready is not None else DEFAULT_TIMEOUT_S) + self._computer_app_data = AppDataPlane( + self._computer_app, {op: getattr(self, name) for op, name in cls._data_hooks.items()}, self.paths, + cls.data_timeout_s) @classmethod def settings_on(cls, os_type: Optional[str]) -> type[BaseModel]: @@ -426,12 +428,7 @@ def build_settings_on(cls, os_type: Optional[str]) -> type[BaseModel]: async def _computer_app_launch(self) -> dict: if self.launch is None: raise InvalidArgument(f"{type(self).__name__} declares no launch") - runtime = self._computer_app_runtime() - await self._computer_app_wait_turn("launch") - try: - return {"pid": await runtime.launch_app()} - finally: - runtime.turn.release() + return {"pid": await self._computer_app_data_plane().launch()} @extension(COMPUTER_APP, op="ready", method="GET") async def _computer_app_ready(self) -> dict: @@ -442,6 +439,10 @@ def _computer_app_runtime(self) -> AppRuntime: raise TypeError(f"{type(self).__qualname__}.__init__ must call super().__init__()") return self._computer_app + def _computer_app_data_plane(self) -> AppDataPlane: + self._computer_app_runtime() + return self._computer_app_data + def extension_params(self, uri: str) -> dict: if uri == COMPUTER_APP: return {"launch": self.launch.model_dump(mode="json", exclude_none=True) if self.launch else None, @@ -474,106 +475,20 @@ def declared_extensions(self) -> list[EnvironmentExtension]: @add_data async def _computer_app_add_data(self, parts: list) -> None: - await self._computer_app_data(OP_ADD_DATA, parts) + await self._computer_app_data_plane().call(OP_ADD_DATA, parts) @get_data async def _computer_app_get_data(self, write_namespace: Optional[WriteNamespaceGrant] = None) -> list: - return await self._computer_app_data(OP_GET_DATA, write_namespace) + return await self._computer_app_data_plane().call(OP_GET_DATA, write_namespace) @reset_data async def _computer_app_reset_data(self) -> None: - await self._computer_app_data(OP_RESET_DATA) + await self._computer_app_data_plane().call(OP_RESET_DATA) @agent_skill def _computer_app_skill(self) -> tuple[str, str]: return _skill_parts(Path(self._computer_app_skill_file()).read_text(), os.environ.get("ENVIRONMENT_NAME", "")) - async def _computer_app_data(self, op: str, *args: Any) -> Any: - """``op``, by its hook or its paths' default, one data call or launch at a time: a load and a reset stop the app - first and relaunch it after, and a load that fails is followed by a reset. A call fails once it runs past - ``data_timeout_s``, but a thread can't be stopped, so the reset and relaunch after it wait for it to end, and so - does the next call.""" - call, runtime = self._computer_app_data_call(op), self._computer_app_runtime() - await self._computer_app_wait_turn(op) - answer = asyncio.get_running_loop().create_future() - change = asyncio.ensure_future(self._computer_app_change(op, call, args, answer)) - runtime.changing.add(change) - change.add_done_callback(runtime.changing.discard) - change.add_done_callback(lambda _: runtime.turn.release()) - return await answer - - async def _computer_app_change(self, op: str, call: Callable, args: tuple, answer: asyncio.Future) -> None: - """``op`` between the app's stop and its relaunch, answered on ``answer``: as it ends, or once it runs past - ``data_timeout_s``.""" - runtime = self._computer_app_runtime() - try: - running = op != OP_GET_DATA and await runtime.stop_app() - try: - result = await self._computer_app_within(op, call, args, answer) - except Exception: - if op == OP_ADD_DATA: - await self._computer_app_undo(answer) - raise - finally: - if running: - await runtime.launch_app() - except Exception as e: - if not answer.done(): - answer.set_exception(e) - else: - answer.set_result(result) - - async def _computer_app_undo(self, answer: asyncio.Future) -> None: - """Reset the app's data after a load that failed.""" - if (reset := self._computer_app_data_call(OP_RESET_DATA)) is None: - return - try: - await self._computer_app_within(OP_RESET_DATA, reset, (), answer) - except Exception as e: - logger.warning(f"the reset after a failed data/add failed too: {type(e).__name__}: {e}") - - async def _computer_app_wait_turn(self, op: str) -> None: - """Wait, within ``data_timeout_s``, for the data call before to end, its reset and relaunch included.""" - try: - await asyncio.wait_for(self._computer_app_runtime().turn.acquire(), self.data_timeout_s) - except TimeoutError: - raise TimeoutError(f"{op} waited past the app's data_timeout_s, {self.data_timeout_s:g} s, for the data " - "call before it to end") from None - - def _computer_app_data_call(self, op: str) -> Optional[Callable]: - """What serves ``op``: the class's hook, or its paths' default; None when nothing does.""" - if (name := self._data_hooks.get(op)) is not None: - hook = getattr(self, name) - if op == OP_GET_DATA and "write_namespace" not in inspect.signature(hook).parameters: - return lambda write_namespace=None: hook() - return hook - if self._data_hooks or not self.paths: - return None - return {OP_ADD_DATA: functools.partial(app_data.add, paths=self.paths), - OP_GET_DATA: functools.partial(app_data.get, self.paths), - OP_RESET_DATA: functools.partial(app_data.reset, self.paths)}[op] - - async def _computer_app_within(self, op: str, call: Callable, args: tuple, answer: asyncio.Future) -> Any: - """``call``'s result within ``data_timeout_s``; past it, ``answer`` fails at once and the call is cancelled, - and this raises the same once it has ended: at once for what awaits, after any thread it runs.""" - work = asyncio.ensure_future(_called(call, *args)) - try: - return await asyncio.wait_for(asyncio.shield(work), self.data_timeout_s) - except TimeoutError: - late = TimeoutError(f"{op} ran past the app's data_timeout_s, {self.data_timeout_s:g} s") - if not answer.done(): - answer.set_exception(late) - work.cancel() - await asyncio.wait([work]) - if not work.cancelled(): - work.exception() - raise late from None - - def _computer_app_keep_initial(self) -> None: - """Keep the app's paths as installed, before anything changes them, when they serve its data.""" - if self.paths and not self._data_hooks: - app_data.keep_initial(self.paths) - def _computer_app_skill_file(self) -> Optional[str]: """The file of the app's folder holding its skill on this OS, or the class's.""" declared = type(self)._installs.get(detect().os) @@ -628,14 +543,6 @@ def declaration(cls) -> AppDeclaration: ) -async def _called(fn: Callable, *args: Any) -> Any: - """``fn``'s result, a sync one's from a thread, so it can't hold up the server.""" - if inspect.iscoroutinefunction(fn): - return await fn(*args) - result = await app_data.in_thread(fn, *args) - return await result if inspect.isawaitable(result) else result - - def _skill_parts(text: str, name: str) -> tuple[str, str]: """A skill file's description and body: from its frontmatter's ``description`` when it has one.""" if text.startswith("---\n") and (end := text.find("\n---", 4)) != -1: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py index 9035754c0..3505055ef 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_data.py @@ -1,22 +1,139 @@ -"""The data of an app that declares only the paths it keeps it in, under the session user's home: a load writes the files -it's given under them, an export returns the files there, and a reset puts back the copy its server kept when it first -started on this boot, which is the app as installed, since every boot starts from the computer's image.""" +"""An app's data plane as its own server serves it (``AppDataPlane``), by the app's data hooks or, with none, the +defaults on the paths it keeps its data in, under the session user's home: a load writes the files it's given under +them, an export returns the files there, and a reset puts back the copy its server kept when it first started on this +boot, which is the app as installed, since every boot starts from the computer's image.""" from __future__ import annotations import asyncio +import functools +import inspect +import logging import shutil from collections.abc import Callable from pathlib import Path, PurePosixPath -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional +from ..agent_env_environment import OP_ADD_DATA, OP_GET_DATA, OP_RESET_DATA from ..transfers import WriteNamespaceGrant from ..types import FilePart, uploaded_object_path from .dataplane import file_parts, part_bytes +if TYPE_CHECKING: + from .app_runtime import AppRuntime + +logger = logging.getLogger(__name__) + #: Where an app's server keeps its paths as installed, in the app's folder. INITIAL = Path(".agentenv/initial") +class AppDataPlane: + """An app's data plane, served around its contract: one data call or launch at a time; a load and a reset stop the + running app first and relaunch it after, and a load that fails is followed by a reset. A call fails once it runs + past ``timeout_s`` and is cancelled: what awaits stops at once, but a thread can't be stopped, so a sync hook's or + a default's thread is waited for, as are the reset and relaunch after it and the next call.""" + + def __init__(self, runtime: AppRuntime, hooks: dict[str, Callable], paths: list[str], timeout_s: float) -> None: + self.runtime = runtime + self.hooks = hooks + self.paths = paths + self.timeout_s = timeout_s + self.turn = asyncio.Lock() + self.changing: set[asyncio.Task] = set() + + def keep_initial(self) -> None: + """Keep the app's paths as installed, before anything changes them, when they serve its data.""" + if self.paths and not self.hooks: + keep_initial(self.paths) + + async def call(self, op: str, *args: Any) -> Any: + """``op``'s answer, by its hook or its paths' default, once the data call before has ended. The change it + makes, with the reset and relaunch after it, holds the turn until it ends, past an answer given at its + timeout.""" + served = self._served(op) + await self._wait_turn(op) + answer = asyncio.get_running_loop().create_future() + change = asyncio.ensure_future(self._change(op, served, args, answer)) + self.changing.add(change) + change.add_done_callback(self.changing.discard) + change.add_done_callback(lambda _: self.turn.release()) + return await answer + + async def launch(self) -> int: + """Launch the app once the data call before has ended; its pid.""" + await self._wait_turn("launch") + try: + return await self.runtime.launch_app() + finally: + self.turn.release() + + async def _change(self, op: str, served: Callable, args: tuple, answer: asyncio.Future) -> None: + """``op`` between the app's stop and its relaunch, answered on ``answer``: as it ends, or once it runs past + ``timeout_s``.""" + try: + running = op != OP_GET_DATA and await self.runtime.stop_app() + try: + result = await self._within(op, served, args, answer) + except Exception: + if op == OP_ADD_DATA: + await self._undo(answer) + raise + finally: + if running: + await self.runtime.launch_app() + except Exception as e: + if not answer.done(): + answer.set_exception(e) + else: + answer.set_result(result) + + async def _undo(self, answer: asyncio.Future) -> None: + """Reset the app's data after a load that failed.""" + if (reset := self._served(OP_RESET_DATA)) is None: + return + try: + await self._within(OP_RESET_DATA, reset, (), answer) + except Exception as e: + logger.warning(f"the reset after a failed data/add failed too: {type(e).__name__}: {e}") + + async def _wait_turn(self, op: str) -> None: + """Wait, within ``timeout_s``, for the data call before to end, its reset and relaunch included.""" + try: + await asyncio.wait_for(self.turn.acquire(), self.timeout_s) + except TimeoutError: + raise TimeoutError(f"{op} waited past the app's data_timeout_s, {self.timeout_s:g} s, for the data call " + "before it to end") from None + + def _served(self, op: str) -> Optional[Callable]: + """What serves ``op``: the app's hook, or its paths' default; None when nothing does.""" + if self.hooks: + hook = self.hooks.get(op) + if hook is not None and op == OP_GET_DATA and "write_namespace" not in inspect.signature(hook).parameters: + return lambda write_namespace=None: hook() + return hook + if not self.paths: + return None + return {OP_ADD_DATA: functools.partial(add, paths=self.paths), + OP_GET_DATA: functools.partial(get, self.paths), + OP_RESET_DATA: functools.partial(reset, self.paths)}[op] + + async def _within(self, op: str, served: Callable, args: tuple, answer: asyncio.Future) -> Any: + """``served``'s result within ``timeout_s``; past it, ``answer`` fails at once and the call is cancelled, and + this raises the same once it has ended: at once for what awaits, after any thread it runs.""" + work = asyncio.ensure_future(_called(served, *args)) + try: + return await asyncio.wait_for(asyncio.shield(work), self.timeout_s) + except TimeoutError: + late = TimeoutError(f"{op} ran past the app's data_timeout_s, {self.timeout_s:g} s") + if not answer.done(): + answer.set_exception(late) + work.cancel() + await asyncio.wait([work]) + if not work.cancelled(): + work.exception() + raise late from None + + def keep_initial(paths: list[str]) -> None: """Copy what's under each of ``paths`` of the home into ``INITIAL``, unless this boot kept a copy already.""" if INITIAL.exists(): @@ -53,6 +170,14 @@ async def reset(paths: list[str]) -> None: await in_thread(_restore, Path.home(), paths) +async def _called(fn: Callable, *args: Any) -> Any: + """``fn``'s result, a sync one's from a thread, so it can't hold up the server.""" + if inspect.iscoroutinefunction(fn): + return await fn(*args) + result = await in_thread(fn, *args) + return await result if inspect.isawaitable(result) else result + + async def in_thread(fn: Callable, *args: Any) -> Any: """``fn``'s result from a thread, which a cancelled call still waits for to the end, since a thread can't be stopped.""" diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py index fb29a9907..89c5bcd8f 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py @@ -46,8 +46,7 @@ class AppReadiness(BaseModel): class AppRuntime: """An app's server's state: its ``launch``, its ``@ready_check`` methods by name, bound to the app, how long it may - take to be ready, why its last launch failed, each sync check still running in its thread, the ``turn`` its data - calls and launches take one at a time, and each data call still running.""" + take to be ready, why its last launch failed, and each sync check still running in its thread.""" def __init__(self, launch: Optional[Launch], ready_checks: dict[str, Callable[[], Any]], timeout_s: float) -> None: self.launch = launch @@ -56,8 +55,6 @@ def __init__(self, launch: Optional[Launch], ready_checks: dict[str, Callable[[] self.started = time.time() self.launch_failed: Optional[str] = None self.checking: dict[str, asyncio.Future] = {} - self.turn = asyncio.Lock() - self.changing: set[asyncio.Task] = set() async def start(self) -> None: """Once the desktop session is up, add the app's menu launcher and launch a boot app, unless the computer has diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py index bf9f4b50d..16f139d89 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py @@ -412,7 +412,7 @@ async def get() -> str | None: async def _app_exports(self) -> None: for app in self.driver.card.get("children_environments") or []: - if "data/get" not in ((app.get("capabilities") or {}).get("operations") or []): + if "data/get" not in (protocol.operations(app) or []): continue async def export(app: dict = app) -> str | None: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/driver.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/driver.py index ddc16a3e4..0b660db85 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/driver.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/driver.py @@ -9,14 +9,16 @@ import httpx from pydantic import BaseModel, ConfigDict from starlette.requests import Request -from starlette.responses import JSONResponse, Response +from starlette.responses import Response -from ..agent_env_environment import _CARD_CONFIG_ATTR, AgentEnvEnvironment, AgentEnvFastMCPApplication, environment_card +from ..agent_env_environment import (_CARD_CONFIG_ATTR, AgentEnvEnvironment, AgentEnvFastMCPApplication, + _error_response, environment_card) from ..tool_context import ToolContext from ..types import EnvironmentCard, EnvironmentExtension, EnvironmentTool from . import _linux, apps, card from .checks import check_features, check_names from .dataplane import DataPlane +from .errors import DeliveryFailed, TargetUnavailable from .extensions import Extensions from .operations import ReadyCheck, check_class, default_for from .platform import Platform, detect @@ -141,13 +143,12 @@ async def _computer_forward(self, request: Request) -> Response: name = request.path_params["name"] folder = next((folder for folder in self._computer.apps if folder.name == name), None) if folder is None: - return JSONResponse({"error": f"this computer has no app named {name!r}"}, status_code=404) + return _error_response(TargetUnavailable(f"this computer has no app named {name!r}")) try: answer = await apps.forward(folder, request.method, request.path_params["path"], request.url.query, await request.body(), request.headers.get("content-type")) except (httpx.HTTPError, OSError) as e: - return JSONResponse({"error": f"app {name}'s server isn't answering: {type(e).__name__}: {e}"}, - status_code=502) + return _error_response(DeliveryFailed(f"app {name}'s server isn't answering: {type(e).__name__}: {e}")) return Response(answer.content, status_code=answer.status_code, media_type=answer.headers.get("content-type")) # The card: what the driver's operations support. diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/serve_app.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/serve_app.py index e617819cf..4ef274fa7 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/serve_app.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/serve_app.py @@ -37,7 +37,7 @@ def _serve(argv: list[str]) -> None: file, _, name = argv[0].partition(":") app = load_class(Path(file), name, ComputerAppEnvironment)() runtime = app._computer_app_runtime() - app._computer_app_keep_initial() + app._computer_app_data_plane().keep_initial() asgi = app.create_app().streamable_http_app() server = uvicorn.Server(uvicorn.Config(asgi, uds=str(SOCKET), log_level="warning")) serving, failed = asgi.router.lifespan_context, [] diff --git a/packages/agentenv-protocol/tests/test_client.py b/packages/agentenv-protocol/tests/test_client.py index 13088bf08..edcd38798 100644 --- a/packages/agentenv-protocol/tests/test_client.py +++ b/packages/agentenv-protocol/tests/test_client.py @@ -148,6 +148,14 @@ def test_extension_params_returns_empty_when_absent_or_paramless(): } +def test_operations_are_what_a_card_declares_and_none_when_it_declares_none(): + assert protocol_v1.operations({"capabilities": {"operations": ["data/get"]}}) == ["data/get"] + assert protocol_v1.operations({"capabilities": {"operations": []}}) == [] + assert protocol_v1.operations({"capabilities": {}}) is None + assert protocol_v1.operations({"capabilities": None}) is None + assert protocol_v1.operations({}) is None + + def test_find_tool_returns_matching(): assert protocol_v1.find_tool(_CARD_WITH_TOOLS, "slack_ping")["description"] == "Ping the server." diff --git a/packages/agentenv-protocol/tests/test_computer_app_data.py b/packages/agentenv-protocol/tests/test_computer_app_data.py index d0ec10597..abbd71509 100644 --- a/packages/agentenv-protocol/tests/test_computer_app_data.py +++ b/packages/agentenv-protocol/tests/test_computer_app_data.py @@ -89,7 +89,7 @@ async def test_an_app_with_only_paths_loads_exports_and_resets_them_as_installed (home / "Mail").mkdir() (home / "Mail/inbox.txt").write_text("installed") mail = PathsMail() - mail._computer_app_keep_initial() + mail._computer_app_data_plane().keep_initial() async with _http(mail) as http: card = (await http.get(WELL_KNOWN_PATH)).json() @@ -112,7 +112,7 @@ async def test_a_file_under_none_of_its_paths_or_through_a_link_out_of_them_is_r (home / "Mail").mkdir() os.symlink(home / "Documents", home / "Mail" / "shared") mail = PathsMail() - mail._computer_app_keep_initial() + mail._computer_app_data_plane().keep_initial() async with _http(mail) as http: outside = await http.post(RPC_PATH, json=_call("data/add", {"parts": [_inline("Mail/a.txt", b"a"), @@ -152,7 +152,7 @@ async def test_hooks_override_its_paths_and_read_each_part_they_get_with_part_by @pytest.mark.asyncio async def test_a_load_and_a_reset_stop_the_running_app_and_relaunch_it_and_an_export_leaves_it_be(home, desktop): mail = PathsMail() - mail._computer_app_keep_initial() + mail._computer_app_data_plane().keep_initial() await mail._computer_app.launch_app() async with _http(mail) as http: @@ -166,7 +166,7 @@ async def test_a_load_and_a_reset_stop_the_running_app_and_relaunch_it_and_an_ex @pytest.mark.asyncio async def test_an_app_that_isnt_running_is_left_stopped(home, desktop): mail = PathsMail() - mail._computer_app_keep_initial() + mail._computer_app_data_plane().keep_initial() async with _http(mail) as http: await _rpc(http, "data/add", {"parts": [_inline("Mail/a.txt", b"a")]}) diff --git a/packages/agentenv-protocol/tests/test_computer_app_server.py b/packages/agentenv-protocol/tests/test_computer_app_server.py index 7bd53b0b9..231c281e4 100644 --- a/packages/agentenv-protocol/tests/test_computer_app_server.py +++ b/packages/agentenv-protocol/tests/test_computer_app_server.py @@ -296,6 +296,7 @@ async def test_the_driver_lists_its_apps_as_children_forwards_each_ones_requests mail = client.find_child(card, "mail") forwarded = (await http.post(mail["url"], json=get)).json()["result"]["parts"] unknown = await http.post("/svc/notes/agentenv", json=get) + silent = await http.post("/svc/music/agentenv", json=get) own = (await http.post("/agentenv", json={**get, "params": {"paths": ["Documents", "Mail"]}})).json() assert [(child["name"], child["url"]) for child in card["children_environments"]] == [ @@ -304,7 +305,8 @@ async def test_the_driver_lists_its_apps_as_children_forwards_each_ones_requests assert client.find_extension_method(mail, COMPUTER_APP, "ready")["endpoint"].startswith("/svc/mail/") assert [(part["metadata"][DATA_OBJECTS_EXTENSION_URI]["path"], base64.b64decode(part["file"]["bytes"])) for part in forwarded] == [("Mail/inbox.txt", b"Mail/inbox.txt")] - assert unknown.status_code == 404 + assert (unknown.status_code, unknown.json()["error"]["code"]) == (404, "target_unavailable") + assert (silent.status_code, silent.json()["error"]["code"]) == (502, "delivery_failed") assert [part["metadata"][DATA_OBJECTS_EXTENSION_URI]["path"] for part in own["result"]["parts"]] == [ "Documents/note.txt"] diff --git a/src/agent_env/env/env.py b/src/agent_env/env/env.py index 059607c87..3bdc55afc 100644 --- a/src/agent_env/env/env.py +++ b/src/agent_env/env/env.py @@ -124,7 +124,7 @@ def get_child_env_card(self, environment_name: str) -> Optional[dict]: reaches.""" card = self.environment_card or {} composed = isinstance(card.get("children_environments"), list) - own = not composed or (card.get("capabilities") or {}).get("operations") + own = not composed or protocol_v1.operations(card) if own and card.get("name") == environment_name: return card return protocol_v1.find_child(card, environment_name) if composed else None diff --git a/src/agent_env/env/envs/_deployment.py b/src/agent_env/env/envs/_deployment.py index 8cd3858d0..fc097d55b 100644 --- a/src/agent_env/env/envs/_deployment.py +++ b/src/agent_env/env/envs/_deployment.py @@ -112,7 +112,7 @@ async def load_by_signed_url(env: MCPServerEnv | WebsiteEnv, environment_artifac if not env._deployed.environment_card: raise RuntimeError(f"{deployed_by}, whose record carries no env card, so '{env.environment_name}' has no data plane to load into") base_url = await legacy_protocol.v1_base_url(env._deployed, env._gateway_url, env.environment_name, mcp=True) - operations = ((env._deployed.get_child_env_card(env.environment_name) or {}).get("capabilities") or {}).get("operations") + operations = protocol_v1.operations(env._deployed.get_child_env_card(env.environment_name) or {}) if base_url is None: raise RuntimeError(f"{deployed_by}, whose card offers '{env.environment_name}' no data plane to load into") if operations is not None and (missing := [op for op in LOAD_OPERATIONS if op not in operations]): diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index 93a749801..a92ccd9e7 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Optional, Self from agentenv_protocol import METHOD_ADD, RPC_PATH +from agentenv_protocol import client as protocol_v1 from agent_env.artifact.artifacts.computer import ComputerArtifact, SettingValue from agent_env.attribution import Attribution @@ -279,7 +280,7 @@ def _data_plane(self, environment_name: str) -> str: if card is None: raise ValueError(f"computer env '{self.id}' has no environment named '{environment_name}': it has " f"{self._environment_names()}") - if METHOD_ADD not in ((card.get("capabilities") or {}).get("operations") or []): + if METHOD_ADD not in (protocol_v1.operations(card) or []): raise ValueError(f"'{environment_name}' in computer env '{self.id}' serves no data/add, so nothing loads " "into it") return self._deployed.environment_url + card.get("url", RPC_PATH).removesuffix(RPC_PATH) diff --git a/src/agent_env/env/envs/computer/utils/inside.py b/src/agent_env/env/envs/computer/utils/inside.py index 2d5eaa6ce..7be9f394f 100644 --- a/src/agent_env/env/envs/computer/utils/inside.py +++ b/src/agent_env/env/envs/computer/utils/inside.py @@ -36,7 +36,7 @@ def missing(deployed: DeployedSandboxEnv) -> list[str]: """What the env's card doesn't serve of computer-exec/v1 ``run``, ``data/add`` and ``data/get``.""" - operations = ((deployed.environment_card or {}).get("capabilities") or {}).get("operations") + operations = protocol_v1.operations(deployed.environment_card or {}) absent = [] if deployed.supports(COMPUTER_EXEC, "run") else [f"{COMPUTER_EXEC} run"] return absent + [op for op in (METHOD_ADD, METHOD_GET) if operations is not None and op not in operations] diff --git a/src/agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py b/src/agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py index 0f5f02144..16adf5090 100644 --- a/src/agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +++ b/src/agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py @@ -7,6 +7,7 @@ import httpx from agentenv_protocol import METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH +from agentenv_protocol import client as protocol_v1 from agent_env.task_step.context import TaskStepContext from agent_env.entity.refs import EntityRef @@ -49,7 +50,6 @@ def from_dict(cls, data: dict) -> VerifyCoreEnvironmentProtocolStep: return cls(**cls._base_from_dict(data), env_id=data["env_id"]) async def execute(self, context: TaskStepContext) -> TaskStepContext: - from agentenv_protocol import client as protocol_v1 from agent_env.env.env import Env deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None) @@ -87,7 +87,7 @@ def _advertised_support(card: Optional[dict], method: str) -> Optional[bool]: backing = children[0] if len(children) == 1 else (card if not children else None) if backing is None: return None - advertised = (backing.get("capabilities") or {}).get("operations") + advertised = protocol_v1.operations(backing) return True if advertised is None else method in advertised async def _probe_operation(self, environment_url: str, method: str, params: dict) -> Optional[bool]: diff --git a/src/agent_env/task_step/task_steps/snapshot_env.py b/src/agent_env/task_step/task_steps/snapshot_env.py index a1748adb7..4b082f3ef 100644 --- a/src/agent_env/task_step/task_steps/snapshot_env.py +++ b/src/agent_env/task_step/task_steps/snapshot_env.py @@ -851,9 +851,9 @@ def _card_environments(deployed) -> list[str]: names = [card.get("name")] else: names = [child.get("name") for child in children if deployed.get_child_env_card(child.get("name")) is child - and ((operations := (child.get("capabilities") or {}).get("operations")) is None + and ((operations := protocol_v1.operations(child)) is None or METHOD_GET in operations)] - if METHOD_GET in ((card.get("capabilities") or {}).get("operations") or []): + if METHOD_GET in (protocol_v1.operations(card) or []): names.insert(0, card.get("name")) if not all(names): raise RuntimeError(f"Deployed env '{deployed.env_id}' recorded a card naming no environment to snapshot") From 28c41a262766a68644f543ba4af81eb3d500bbfb Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 08:15:42 -0800 Subject: [PATCH 13/16] fix(validator): probe a computer's data plane with its control token; a 401 is unknown Co-Authored-By: Claude Opus 5.5 --- .../verify_env_core_protocol.py | 12 +++++---- .../verify_env_core_protocol_test.py | 12 ++++++--- .../task_step/verifiers/test_run_context.py | 25 +++++++------------ 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py b/src/agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py index 16adf5090..20d25ea43 100644 --- a/src/agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +++ b/src/agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py @@ -65,7 +65,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: protocol = {} for method, probe_params in _CORE_OPERATIONS: if probe_params is not None: - supported = await self._probe_operation(deployed.environment_url, method, probe_params) + supported = await self._probe_operation(deployed.environment_url, method, probe_params, + deployed.control_headers) else: supported = self._advertised_support(card, method) protocol[method] = {"supported": supported} @@ -90,19 +91,20 @@ def _advertised_support(card: Optional[dict], method: str) -> Optional[bool]: advertised = protocol_v1.operations(backing) return True if advertised is None else method in advertised - async def _probe_operation(self, environment_url: str, method: str, params: dict) -> Optional[bool]: - """Mutation-free: -32601 = unregistered; -32000 = gateway couldn't forward (None); + async def _probe_operation(self, environment_url: str, method: str, params: dict, + headers: dict[str, str]) -> Optional[bool]: + """Mutation-free: -32601 = unregistered; -32000 = gateway couldn't forward, or a 401 = not let in (None); anything else — a result, or -32602 from the empty-parts add probe — proves registration.""" body = {"jsonrpc": "2.0", "id": f"verify-{method}", "method": method, "params": params} try: async with httpx.AsyncClient(timeout=15) as client: - response = await client.post(f"{environment_url}{RPC_PATH}", json=body) + response = await client.post(f"{environment_url}{RPC_PATH}", json=body, headers=headers) error = (response.json() or {}).get("error") or {} except Exception as e: logger.warning(f"operation probe {method} for '{self.env_id}' failed: {e}") return None if error.get("code") == -32601: return False - if error.get("code") == -32000: + if response.status_code == 401 or error.get("code") == -32000: return None return True diff --git a/tst/unit/env/task_step/verify_env_core_protocol_test.py b/tst/unit/env/task_step/verify_env_core_protocol_test.py index f1c75be26..857c516c6 100644 --- a/tst/unit/env/task_step/verify_env_core_protocol_test.py +++ b/tst/unit/env/task_step/verify_env_core_protocol_test.py @@ -108,8 +108,10 @@ def test_missing_deployed_env_raises(self): assert "not found in context.deployed_envs" in str(e) -def _probe(payload): +def _probe(payload, status=200): class _Resp: + status_code = status + def json(self): return payload @@ -127,7 +129,7 @@ async def post(self, *args, **kwargs): return _Resp() with patch("agent_env.task_step.task_steps.env_card_validator.verify_env_core_protocol.httpx.AsyncClient", _Client): - return asyncio.run(_make_step()._probe_operation("https://gw", "data/add", {"parts": []})) + return asyncio.run(_make_step()._probe_operation("https://gw", "data/add", {"parts": []}, {})) class TestProbeOperation: @@ -143,6 +145,9 @@ def test_gateway_forward_error_is_indeterminate(self): def test_result_means_registered(self): assert _probe({"jsonrpc": "2.0", "id": 1, "result": {"parts": []}}) is True + def test_a_refusal_for_the_control_token_is_indeterminate(self): + assert _probe({"error": {"code": "unauthorized", "message": "no", "applied": "no"}}, status=401) is None + class TestWire: @pytest.mark.parametrize("gateway_url", ["https://gw", "https://gw/", "https://sandbox.example.com/sandbox/sandbox-vm-1-18765"]) @@ -150,7 +155,7 @@ def test_reads_the_card_and_probes_the_data_plane_at_the_cards_address(self, gat base = gateway_url.rstrip("/") ctx = TaskStepContext(deployed_envs=[DeployedGatewayEnv( env_id="e1", env_version=3, gateway_url=gateway_url, mcp_url=f"{base}/mcp", db_web_url=None, sandbox_id="sb", - environment_card_url=f"{gateway_url}{WELL_KNOWN_PATH}", + environment_card_url=f"{gateway_url}{WELL_KNOWN_PATH}", control_token="s3cret", )]) sent = [] @@ -169,6 +174,7 @@ def handle(request): ("GET", f"{base}{WELL_KNOWN_PATH}"), ("POST", f"{base}/agentenv"), ("POST", f"{base}/agentenv"), ] assert [(b["method"], b["params"]) for b in (json.loads(r.content) for r in sent[1:])] == [("data/add", {"parts": []}), ("data/get", {})] + assert [r.headers.get("authorization") for r in sent] == [None, "Bearer s3cret", "Bearer s3cret"] assert fake_env.update_metadata.call_args[0][0]["validated_environment_protocol"] == { "data/reset": {"supported": True}, "data/add": {"supported": True}, "data/get": {"supported": True}, } diff --git a/tst/unit/task_step/verifiers/test_run_context.py b/tst/unit/task_step/verifiers/test_run_context.py index f9477a6c4..f6f542fd4 100644 --- a/tst/unit/task_step/verifiers/test_run_context.py +++ b/tst/unit/task_step/verifiers/test_run_context.py @@ -9,12 +9,10 @@ from types import SimpleNamespace import pytest -from agentenv_protocol import client as protocol_v1 from agent_env.a2a_agent import conversation_store from agent_env.artifact import FileArtifact from agent_env.config import get_config -from agent_env.env.env import DeployedEnv from agent_env.task_step.context import PromptResponse, TaskStepContext from agent_env.task_step.task_steps.verifiers.env_outcome_verifier import EnvOutcomeVerifierTaskStep from agent_env.task_step.task_steps.verifiers import Run, RunRecordUnavailable @@ -386,7 +384,7 @@ def _outcome(script: bytes, monkeypatch, **kw) -> EnvOutcomeVerifierTaskStep: @pytest.mark.asyncio async def test_a_verify_asking_for_run_is_handed_the_run_it_grades(monkeypatch): context, _, _ = recorded_run() - context.deployed_envs.append(DeployedEnv(env_id="calendar", env_version=1, mcp_url="http://mcp")) + context.deployed_envs.append(SimpleNamespace(env_id="calendar", mcp_url="http://mcp")) await _outcome(RUN_SCRIPT, monkeypatch, prompt_id="p1").execute(context) @@ -398,7 +396,7 @@ async def test_a_verify_asking_for_run_is_handed_the_run_it_grades(monkeypatch): async def test_without_a_prompt_id_the_runs_last_prompt_is_graded(monkeypatch): context, _, _ = recorded_run() context.prompt_responses.insert(0, PromptResponse(prompt_id="warmup", response="ok", prompt_text="hi", step_id="w")) - context.deployed_envs.append(DeployedEnv(env_id="calendar", env_version=1, mcp_url="http://mcp")) + context.deployed_envs.append(SimpleNamespace(env_id="calendar", mcp_url="http://mcp")) await _outcome(RUN_SCRIPT, monkeypatch).execute(context) @@ -406,28 +404,23 @@ async def test_without_a_prompt_id_the_runs_last_prompt_is_graded(monkeypatch): @pytest.mark.asyncio -async def test_a_verify_asking_for_env_url_is_handed_the_envs_address_and_reaches_it_with_its_control_token( - monkeypatch): +async def test_a_verify_asking_for_env_url_is_handed_the_envs_address(monkeypatch): context, _, _ = recorded_run() context.deployed_envs.append(SimpleNamespace(env_id="calendar", mcp_url="http://127.0.0.1:20001/mcp", - environment_url="http://127.0.0.1:20001", control_token="t0ken")) - script = (b"from agentenv_protocol import client\n" - b"async def verify(mcp_url, env_url, run=None):\n" - b" return [{'id': 'c', 'score': 1.0, 'env_url': env_url, 'turns': len(run.turns),\n" - b" 'headers': client.request_headers(env_url + '/agentenv/ext/computer-exec/run')}]\n") + environment_url="http://127.0.0.1:20001")) + script = (b"async def verify(mcp_url, env_url, run=None):\n" + b" return [{'id': 'c', 'score': 1.0, 'env_url': env_url, 'turns': len(run.turns)}]\n") await _outcome(script, monkeypatch, prompt_id="p1").execute(context) [row] = context.metadata["verifications"]["v"]["results"] assert (row["env_url"], row["turns"]) == ("http://127.0.0.1:20001", 3) - assert row["headers"] == {"Authorization": "Bearer t0ken"} - assert protocol_v1.request_headers("http://127.0.0.1:20001/agentenv") == {} @pytest.mark.asyncio async def test_a_verify_with_an_unrelated_second_parameter_is_called_as_before(monkeypatch): context, _, _ = recorded_run() - context.deployed_envs.append(DeployedEnv(env_id="calendar", env_version=1, mcp_url="http://mcp")) + context.deployed_envs.append(SimpleNamespace(env_id="calendar", mcp_url="http://mcp")) script = b"async def verify(mcp_url, retries=3):\n return [{'id': 'c', 'score': 1.0, 'retries': retries}]\n" await _outcome(script, monkeypatch, prompt_id="p1").execute(context) @@ -438,7 +431,7 @@ async def test_a_verify_with_an_unrelated_second_parameter_is_called_as_before(m @pytest.mark.asyncio async def test_a_one_argument_verify_is_called_as_before(monkeypatch): context, _, _ = recorded_run() - context.deployed_envs.append(DeployedEnv(env_id="calendar", env_version=1, mcp_url="http://mcp")) + context.deployed_envs.append(SimpleNamespace(env_id="calendar", mcp_url="http://mcp")) script = b"async def verify(mcp_url):\n return [{'id': 'c', 'score': 1.0, 'url': mcp_url}]\n" await _outcome(script, monkeypatch, prompt_id="p1").execute(context) @@ -449,7 +442,7 @@ async def test_a_one_argument_verify_is_called_as_before(monkeypatch): @pytest.mark.asyncio async def test_an_unknown_prompt_id_fails_the_step(monkeypatch): context, _, _ = recorded_run() - context.deployed_envs.append(DeployedEnv(env_id="calendar", env_version=1, mcp_url="http://mcp")) + context.deployed_envs.append(SimpleNamespace(env_id="calendar", mcp_url="http://mcp")) with pytest.raises(RuntimeError, match="prompt_id='nope' not found"): await _outcome(RUN_SCRIPT, monkeypatch, prompt_id="nope").execute(context) From 2b76894988346ecd5ce13b369e7953c5a0b52722 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 08:15:47 -0800 Subject: [PATCH 14/16] feat(task): run every step under a grant of each deployed env's control headers A protocol-client call a step makes to a deployed env's address, naming no headers of its own, sends that env's control headers, so plugin steps and verifiers reach a computer's framework-only routes without passing the token. The grant now carries headers, not a token. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +- packages/agentenv-protocol/README.md | 5 +- .../src/agentenv_protocol/client.py | 19 +++-- .../tests/test_control_token.py | 11 +-- src/agent_env/task/task.py | 5 +- .../verifiers/env_outcome_verifier.py | 10 +-- tst/unit/task/control_grant_test.py | 74 +++++++++++++++++++ 7 files changed, 104 insertions(+), 25 deletions(-) create mode 100644 tst/unit/task/control_grant_test.py diff --git a/AGENTS.md b/AGENTS.md index cb46bb888..49c24dc0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,7 +116,7 @@ and deserialized through a registry. |---|---| | `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset`, `close` and `load_file_artifact_universe`; teardown closes each env a run deployed before terminating its sandboxes by id. 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`, `env/envs/computer/driver_env.py`: 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.]` (`env/envs/computer/utils/install.py`): files, apt `packages` with extra `sources`, a `python` requirements file for a venv, `downloads` by URL and sha256, `run` scripts, the services the base's init runs, and `ports`; it deploys only as a computer env's driver), `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 on a builder of the put's sandbox provider, by default the env's (`utils/builder.py`; `--sandbox` on `env build`, `bundle register` and `run`): this machine's Docker for `local`, else a VM of the provider, which fetches the put's stored files by signed URL, takes the rest inline and pushes to the image store itself, so it builds only with stores a remote VM reaches; each arch goes to the first link that runs it natively, else one that emulates it (a VM registers QEMU; `--no-emulate` leaves it `skipped` instead, for a later put), and its pair records the provider that built it (`builder`); a run or register that reuses a computer env composes what it has left (`pending`); tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile, build context and lock so any env with the same inputs reuses it; every layer's packages, requirements and downloads are resolved at put in a container of the base for each arch (`utils/packages.py`, `utils/resolve_in_base.py`), stored in the object store by sha256 under `agentenv-packages/`, and listed in a lock stored by a digest of its inputs, which any env with the same inputs reuses, and builds install only the lock's files, every install step `RUN --network=none`; 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; gate 3 then boots each native build on the put's sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`, which declares each OS with its own decorator (`@linux(...)`, or `@install(os_type, spec)` for any OS type) holding its install, its launch and, optionally, a skill and settings and build settings that add to the class's there, and install steps and ready checks for every OS or those their `os=` names; everything that reads its settings, launch or steps asks for the computer's OS type; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference; its env.toml's `build_settings`, values of its `BuildSettings` checked at put, are written to `.agentenv/build_settings.json` in its folder, so they're part of the image's key, its install steps read them, and a deploy can't change them). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `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`: its environment's files by path, stored as the one wrapped `file_artifact_ref` every environment artifact had when it has one file at its filename, else as `file_artifact_refs`; `get_file_artifacts()` reads either, and a load hands each file to the env's `data/add` with its path, which a pre-v1 load, taking one file, refuses), `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`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. A computer's framework-only routes (everything but MCP and the card) take the per-deploy control token its provider boots it with (`DeployedEnv.control_token`, `AGENTENV_CONTROL_TOKEN`); framework calls send `deployed.control_headers`, a `verify` runs under `protocol_v1.authorized`, and a `run_code` script inside the computer gets `input["env"]["headers"]`. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each env still up closes (`Env.close`; a computer env ends any recording still open), what stayed open is registered from its namespace, then every sandbox is terminated by id. `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). | +| `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`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. A computer's framework-only routes (everything but MCP and the card) take the per-deploy control token its provider boots it with (`DeployedEnv.control_token`, `AGENTENV_CONTROL_TOKEN`); framework calls send `deployed.control_headers`, `Task.run` runs every step under `protocol_v1.authorized` with each deployed env's (a `verify` included), and a `run_code` script inside the computer gets `input["env"]["headers"]`. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each env still up closes (`Env.close`; a computer env ends any recording still open), what stayed open is registered from its namespace, then every sandbox is terminated by id. `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB, DynamoDB, Firestore with MongoDB compatibility), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. `store/routing.py` is the `@local` namespace: while the CLI or a `build_app` server runs, an `@local/…` id and what an `@local` task's run records go to local stores beside the configured ones, and that run's writes to the configured stores are refused. A new backend must pass the conformance kits in `tst/store/`. | | `config/` | The `Config` singleton (`get_config`, `configure`, `reset_config`) in `config/runtime.py`, file discovery in `config/loader.py`, and `load_impl`, which resolves `module:Class` pointers. `agent_env.store` re-exports the config names for compatibility. | | `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra), `smol_vm` (the `smol` extra), `tensorlake` (the `tensorlake` extra), `tenki` (the `tenki` extra), `vercel` (the `vercel` extra), `boxd` (the `boxd` extra), `coreweave` (the `coreweave` extra), `daytona` (the `daytona` extra), `boat` (the `boat` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, and for VMs on this machine the address their requests to it arrive at, `REACHES_HOST_AT`; `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store), `EnvironmentGatewayProvider` (`gateway`, an env's default), which renders a docker-compose for the gateway and its MCP servers inside the sandbox, and `EnvironmentServerProvider` (`server`), one MCP server in its own container with no gateway in front and no state store; `providers/env_state/` holds env-state providers (`local_postgres` built in). | @@ -237,6 +237,9 @@ orchestrators call it the same way. What callers rely on: through `record_task_failure()`, retries through `undo_steps()`. - The runner writes its run identifier to `context.metadata["workflow_id"]` before calling `run()`; core stores it opaquely with the instance. +- Each step runs under `protocol_v1.authorized` for the deployed envs in its context: a protocol-client + call the step makes to a deployed env's address, naming no headers of its own, sends that env's + `DeployedEnv.control_headers`. - A runner records every run it accepts in the run index (`agent_env.runner.store`): `insert_run` at submit, `set_running` when it starts, `mark_terminal` when it ends. The explorer lists, counts and groups runs from that collection and never calls the runner to ask, so a run missing there is diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index 1fa1e4619..5462c89e6 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -240,8 +240,9 @@ nothing else. Without the variable, as when the environment runs on its own, eve its `/svc//` routes. On the client side each call takes `headers`, and `client.control_headers(token)` builds them. -Code that names none, a task's verifier say, runs under `client.authorized({env_url: token})`: -within it, a call to an address under `env_url` carries that token. +Code that names none runs under `client.authorized({env_url: client.control_headers(token)})`: +within it, a call to an address under `env_url` sends those headers. agent-env runs every task +step, a verifier's `verify` included, under such a grant for each environment it deployed. ### Tool access without a gateway diff --git a/packages/agentenv-protocol/src/agentenv_protocol/client.py b/packages/agentenv-protocol/src/agentenv_protocol/client.py index 2450076f0..e77622b38 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/client.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/client.py @@ -16,8 +16,8 @@ logger = logging.getLogger(__name__) -#: The control token of each env address the running code is granted, by address. -_GRANTS: ContextVar[dict[str, str]] = ContextVar("agentenv_control_grants", default={}) +#: The headers that carry the control token of each env address the running code is granted, by address. +_GRANTS: ContextVar[dict[str, dict[str, str]]] = ContextVar("agentenv_control_grants", default={}) class ExtensionCallError(httpx.HTTPStatusError): @@ -49,12 +49,12 @@ def control_headers(token: str | None) -> dict[str, str]: @contextlib.contextmanager -def authorized(grants: dict[str, str | None]) -> Iterator[None]: - """Within it, a call to an address under one of ``grants``, an env's address and its control token, carries - that token when the call names no headers of its own: how code agent-env runs against an env, a verifier's say, - reaches its framework-only routes as written.""" +def authorized(grants: dict[str | None, dict[str, str]]) -> Iterator[None]: + """Within it, a call to an address under one of ``grants``, an env's address and the headers that carry its + control token (``control_headers``), sends those headers when the call names none of its own: how the steps + agent-env runs, and the verifiers they call, reach an env's framework-only routes as written.""" granted = _GRANTS.set({**_GRANTS.get(), - **{url.rstrip("/"): token for url, token in grants.items() if url and token}}) + **{url.rstrip("/"): sent for url, sent in grants.items() if url and sent}}) try: yield finally: @@ -62,12 +62,11 @@ def authorized(grants: dict[str, str | None]) -> Iterator[None]: def request_headers(url: str, headers: dict[str, str] | None = None) -> dict[str, str]: - """What a call to ``url`` sends: ``headers`` when it names them, else the control token its grant gives + """What a call to ``url`` sends: ``headers`` when it names them, else the headers its grant gives (``authorized``), if any.""" if headers is not None: return headers - return next((control_headers(token) for base, token in _GRANTS.get().items() - if url == base or url.startswith(f"{base}/")), {}) + return next((sent for base, sent in _GRANTS.get().items() if url == base or url.startswith(f"{base}/")), {}) async def _rpc(base_url: str, method: str, params: dict, timeout: int, verify: bool, diff --git a/packages/agentenv-protocol/tests/test_control_token.py b/packages/agentenv-protocol/tests/test_control_token.py index 27cfb3d73..a083ea4e1 100644 --- a/packages/agentenv-protocol/tests/test_control_token.py +++ b/packages/agentenv-protocol/tests/test_control_token.py @@ -1,7 +1,7 @@ """The control token: with CONTROL_TOKEN_ENV set when an env mounts, everything it serves but its MCP endpoint and its card refuses a call without that token, or with another, before the route runs, and answers one with it; the MCP endpoint and the card stay open, and without the variable every route is open. The client sends a call's own headers, -or else the token a grant gives the address it calls.""" +or else the headers a grant gives the address it calls.""" from __future__ import annotations import contextlib @@ -105,14 +105,15 @@ async def test_without_a_token_every_route_is_open(monkeypatch): assert await _calls(rest, {}) == [200, 200, 200] -def test_a_call_sends_its_own_headers_or_else_the_token_its_grant_gives_an_address_under_the_envs(): +def test_a_call_sends_its_own_headers_or_else_the_ones_its_grant_gives_an_address_under_the_envs(): env = "http://desk:18765" - with client.authorized({env: "s3cret", "http://other:1": None, None: "orphan"}): + with client.authorized({env: client.control_headers("s3cret"), "http://other:1": {}, + None: client.control_headers("orphan")}): granted = [client.request_headers(url) for url in (env, f"{env}/svc/mail/agentenv", f"{env}0/x", "http://other:1/x")] own = client.request_headers(f"{env}/agentenv", {"X-Mine": "1"}) - with client.authorized({env: "rotated"}): + with client.authorized({env: client.control_headers("rotated")}): inner = client.request_headers(f"{env}/agentenv") outer = client.request_headers(f"{env}/agentenv") after = client.request_headers(f"{env}/agentenv") @@ -139,7 +140,7 @@ def handle(request: httpx.Request) -> httpx.Response: await client.invoke_extension("http://desk", card, "urn:agentenv:test-exec/v1", {}, method="run", headers=client.control_headers("own")) await client.get_data("http://desk", headers=client.control_headers("own")) - with client.authorized({"http://desk": "granted"}): + with client.authorized({"http://desk": client.control_headers("granted")}): await client.add_data("http://desk", []) await client.reset_data("http://desk", headers={}) diff --git a/src/agent_env/task/task.py b/src/agent_env/task/task.py index 4c2beb166..6840a58d8 100644 --- a/src/agent_env/task/task.py +++ b/src/agent_env/task/task.py @@ -17,6 +17,8 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Callable, ClassVar, Optional +from agentenv_protocol import client as protocol_v1 + from agent_env.config import run_env_ids from agent_env.plugins import _registration from agent_env.plugins._requirements import unmet_problems @@ -629,7 +631,8 @@ async def _run_one(step: TaskStep, idx: int, dispatch_epoch: int) -> None: started_mono = time.monotonic() started_utc = datetime.now(timezone.utc).isoformat() try: - await step.execute(context) + with protocol_v1.authorized({d.environment_url: d.control_headers for d in context.deployed_envs}): + await step.execute(context) except asyncio.CancelledError: raise except BaseException as exc: diff --git a/src/agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py b/src/agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py index 3df30016b..e9dfa989f 100644 --- a/src/agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +++ b/src/agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py @@ -11,8 +11,6 @@ import uuid from typing import ClassVar, Optional -from agentenv_protocol import client as protocol_v1 - from agent_env.task_step.context import PromptResponse, TaskStepContext from agent_env.entity.refs import EntityRef from agent_env.task_step.task_step import TaskStep, TaskStepDependency @@ -27,8 +25,9 @@ class EnvOutcomeVerifierTaskStep(TaskStep): """Runs the artifact's ``verify(mcp_url)``; a ``run`` parameter also gets the ``Run`` of ``prompt_id``, and an - ``env_url`` parameter the env's address, which the protocol client takes as it is: ``verify`` runs under a grant - of the env's control token, so its calls reach the env's framework-only routes as written.""" + ``env_url`` parameter the env's address, which the protocol client takes as it is: like every step, ``verify`` + runs under ``Task.run``'s grant of each deployed env's control token, so its calls reach the env's framework-only + routes as written.""" type: ClassVar[str] = "env_outcome_verifier" entity_refs = ( @@ -127,8 +126,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: keywords = {"env_url": deployed.environment_url} if "env_url" in parameters else {} if "run" in parameters: keywords["run"] = await load_run_context(context, self._graded_prompt(context)) - with protocol_v1.authorized({deployed.environment_url: deployed.control_token}): - results = await module.verify(deployed.mcp_url, **keywords) + results = await module.verify(deployed.mcp_url, **keywords) score = aggregate_score(results, self.score_aggregator) if "verifications" not in context.metadata: diff --git a/tst/unit/task/control_grant_test.py b/tst/unit/task/control_grant_test.py new file mode 100644 index 000000000..8e76ffbcc --- /dev/null +++ b/tst/unit/task/control_grant_test.py @@ -0,0 +1,74 @@ +"""Every step runs under a grant of each deployed env's control token: a protocol-client call the step makes to an env's +address carries that env's token unless the call names its own headers, an env without one is sent none, an env an +earlier step deployed is granted to the steps after it, and the grant ends with the step.""" + +import pytest +from agentenv_protocol import WELL_KNOWN_PATH +from agentenv_protocol import client as protocol_v1 + +from agent_env.config.runtime import Config +from agent_env.env.env import DeployedEnv +from agent_env.task import Task +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_step import TaskStep + +DESK = "http://desk:18765" +MAIL = "http://mail:8000" + + +def _deployed(env_id: str, url: str, token: str | None = None) -> DeployedEnv: + return DeployedEnv(env_id=env_id, env_version=1, mcp_url=f"{url}/mcp", environment_card_url=f"{url}{WELL_KNOWN_PATH}", + control_token=token) + + +class _Calls(TaskStep): + """Records what its calls to the desk, to the mail env and elsewhere send, and one to the desk with headers of its + own.""" + + type = "control_grant_test_calls" + entity_refs = () + seen: list = [] + + @classmethod + def from_dict(cls, data): + return cls(**cls._base_from_dict(data)) + + async def execute(self, context): + urls = (f"{DESK}/agentenv/ext/computer-exec/run", f"{MAIL}/agentenv", "http://elsewhere/agentenv") + _Calls.seen.append([protocol_v1.request_headers(url) for url in urls] + + [protocol_v1.request_headers(f"{DESK}/agentenv", {"X-Own": "1"})]) + return context + + +class _DeployDesk(TaskStep): + """Deploys the desk, with its token.""" + + type = "control_grant_test_deploy" + entity_refs = () + + @classmethod + def from_dict(cls, data): + return cls(**cls._base_from_dict(data)) + + async def execute(self, context): + context.deployed_envs.append(_deployed("desk", DESK, "s3cret")) + return context + + +@pytest.fixture(autouse=True) +def registries(monkeypatch): + steps = {**Config().task_step_registry(), _Calls.type: _Calls, _DeployDesk.type: _DeployDesk} + monkeypatch.setattr(Config, "task_step_registry", lambda self: steps) + monkeypatch.setattr(_Calls, "seen", []) + + +@pytest.mark.asyncio +async def test_a_step_calls_each_deployed_env_with_its_own_token_and_the_grant_ends_with_the_step(local_stores): + task = Task(id="t", version=None, steps=[_Calls(id="before", version=None), _DeployDesk(id="deploy", version=None), + _Calls(id="after", version=None)]) + + await task.run(context=TaskStepContext(deployed_envs=[_deployed("mail", MAIL)])) + + assert _Calls.seen == [[{}, {}, {}, {"X-Own": "1"}], + [{"Authorization": "Bearer s3cret"}, {}, {}, {"X-Own": "1"}]] + assert protocol_v1.request_headers(f"{DESK}/agentenv") == {} From 3dfecd0152679d1c27e8358153d51376a852c05d Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 08:28:58 -0800 Subject: [PATCH 15/16] refactor(computer): boot hands a computer its control token; callers pass the token, not its variable Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/utils/boot.py | 18 +++++++++++------ .../env/envs/computer/utils/composer.py | 6 ++---- .../env_providers/env_computer_provider.py | 20 ++++++++----------- tst/unit/env/envs/test_computer_env.py | 20 ++++++++++++------- 4 files changed, 35 insertions(+), 29 deletions(-) diff --git a/src/agent_env/env/envs/computer/utils/boot.py b/src/agent_env/env/envs/computer/utils/boot.py index d59ae6530..daa8fd269 100644 --- a/src/agent_env/env/envs/computer/utils/boot.py +++ b/src/agent_env/env/envs/computer/utils/boot.py @@ -96,19 +96,25 @@ def control_token() -> str: return secrets.token_urlsafe(CONTROL_TOKEN_BYTES) -async def boot(sandbox_provider: SandboxProvider, *, image: str, port: int, environment: dict[str, str], +def control_environment(token: Optional[str]) -> dict[str, str]: + """What hands a computer its control ``token`` in its boot environment; nothing without one, as for a computer an + older agent-env booted.""" + return {CONTROL_TOKEN_ENV: token} if token else {} + + +async def boot(sandbox_provider: SandboxProvider, *, image: str, port: int, environment: dict[str, str], token: str, network_policy: Optional[NetworkPolicy], timeout: int, attribution: Optional[Attribution], disk_size_gb: float = 10, cpu: Optional[float] = None, memory_mb: Optional[int] = None, ready_s: float = READY_TIMEOUT_S) -> Booted: - """``image`` booted with ``environment`` on ``sandbox_provider``, which the caller chose to run its arch natively, - its driver's ``port`` reached on the sandbox, once the driver says the computer is ready, asked with the control - token ``environment`` gives it; a boot that fails is torn down.""" + """``image`` booted with ``environment`` and its control ``token`` on ``sandbox_provider``, which the caller chose + to run its arch natively, its driver's ``port`` reached on the sandbox, once the driver, asked with that token, says + the computer is ready; a boot that fails is torn down.""" sandbox = await sandbox_provider.create_container( - image_name=image, port=port, env=environment, network_policy=network_policy, + image_name=image, port=port, env={**environment, **control_environment(token)}, network_policy=network_policy, disk_size_gb=disk_size_gb, timeout=timeout, attribution=dict(attribution or {}), **_size_kwargs(cpu, memory_mb)) try: url = sandbox.tunnel_urls[port] - return Booted(sandbox, url, await ready(url, ready_s, environment.get(CONTROL_TOKEN_ENV))) + return Booted(sandbox, url, await ready(url, ready_s, token)) except BaseException: await discard(sandbox) raise diff --git a/src/agent_env/env/envs/computer/utils/composer.py b/src/agent_env/env/envs/computer/utils/composer.py index ab11cfe32..195e2c9d6 100644 --- a/src/agent_env/env/envs/computer/utils/composer.py +++ b/src/agent_env/env/envs/computer/utils/composer.py @@ -27,7 +27,6 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any -from agentenv_protocol import CONTROL_TOKEN_ENV from agentenv_protocol.computer import apps as installed_apps from agentenv_protocol.computer.app import BUILD_SETTINGS, SOCKET from agentenv_protocol.computer.conformance import run_probe @@ -187,12 +186,11 @@ async def _probe(links: list[SandboxProvider], env: ComputerEnv, driver: Compute ``links`` that boots it, probed at ``full`` with that token within ``_PROBE_TIMEOUT_S`` and torn down; the report, the driver's card and the provider's name.""" token = boot.control_token() - environment = {**boot.settings_environment(env.computer, _values(env), environment_name=env.environment_name), - CONTROL_TOKEN_ENV: token} + environment = boot.settings_environment(env.computer, _values(env), environment_name=env.environment_name) problems = [] for link in links: try: - booted = await boot.boot(link, image=image, port=driver.port, environment=environment, + booted = await boot.boot(link, image=image, port=driver.port, environment=environment, token=token, network_policy=None, timeout=_PROBE_TTL_S, attribution=None, ready_s=boot.ready_timeout(env.composed_apps(), env.computer.os.type)) except Exception as e: diff --git a/src/agent_env/providers/env_providers/env_computer_provider.py b/src/agent_env/providers/env_providers/env_computer_provider.py index a0d991a12..3db560b1b 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -8,7 +8,6 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, ClassVar, Optional -from agentenv_protocol import CONTROL_TOKEN_ENV from agentenv_protocol.computer import COMPUTER_DISPLAY, COMPUTER_RECORDING from agent_env.artifact.artifacts.computer import ComputerArtifact @@ -18,6 +17,7 @@ from agent_env.env.envs.computer.utils.boot import ( app_settings_environment, boot, + control_environment, control_token, discard, ready, @@ -96,9 +96,8 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par values = _values(computer, values) policy = NetworkPolicy.from_dict(env.network) if env.network else None booted = await boot( - sandbox_provider, image=build["image"], port=env.driver.port, network_policy=policy, - environment=_environment(env, computer, values, params, token), timeout=ttl_seconds, - attribution=attribution, + sandbox_provider, image=build["image"], port=env.driver.port, network_policy=policy, token=token, + environment=_environment(env, computer, values, params), timeout=ttl_seconds, attribution=attribution, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, ready_s=ready_timeout(env.composed_apps(), env.computer.os.type)) self._container_sandboxes.append(booted.sandbox) @@ -137,8 +136,8 @@ async def reboot(self, env: ComputerEnv, deployed: DeployedSandboxEnv) -> None: sandbox = await sandbox_provider.get_sandbox(deployed.sandbox_id) try: await sandbox_provider.reset_container(sandbox, image_name=record["image"], port=env.driver.port, - env=_environment(env, computer, values, params, - deployed.control_token)) + env={**_environment(env, computer, values, params), + **control_environment(deployed.control_token)}) except NotImplementedError as e: # a reset the env supports, which this provider can't carry out raise RuntimeError(f"'{env.id}' can't be reset on {type(sandbox_provider).__name__}: {e}") from None card = await ready(deployed.environment_url, ready_timeout(env.composed_apps(), env.computer.os.type), @@ -164,14 +163,11 @@ def _values(computer: ComputerArtifact, values: dict) -> dict: return {**{key: setting.default for key, setting in computer.config.items()}, **values} -def _environment(env: ComputerEnv, computer: ComputerArtifact, values: dict, params: computer_params.ComputerParams, - token: Optional[str]) -> dict[str, str]: - """The computer's boot environment: its settings, its apps', and the control token its driver's framework-only - routes take, unless its record has none, as one an older agent-env booted has not.""" +def _environment(env: ComputerEnv, computer: ComputerArtifact, values: dict, + params: computer_params.ComputerParams) -> dict[str, str]: return {**settings_environment(computer, values, environment_name=env.environment_name, driver_settings=params.driver_settings), - **app_settings_environment(env.composed_apps(), params.app_settings, env.computer.os.type), - **({CONTROL_TOKEN_ENV: token} if token else {})} + **app_settings_environment(env.composed_apps(), params.app_settings, env.computer.os.type)} def _require_name(env: ComputerEnv, card: dict) -> None: diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 3f145d4aa..9aaf728a2 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -261,7 +261,7 @@ def test_the_probe_boots_a_build_at_the_envs_settings_with_a_token_of_its_own_as booted = [] async def fake_boot(link, **kwargs): - booted.append(kwargs["environment"]) + booted.append(kwargs) return boot.Booted(sandbox=None, url="http://computer", card=CARD) monkeypatch.setattr(boot, "boot", fake_boot) @@ -271,9 +271,10 @@ async def fake_boot(link, **kwargs): assert asyncio.run(_probe([SimpleNamespace()], env, driver, "img")) == ( "report", CARD, "SimpleNamespace") - assert {k: v for k, v in booted[0].items() if k.startswith("AGENTENV_SETTING_")} == { + assert {k: v for k, v in booted[0]["environment"].items() if k.startswith("AGENTENV_SETTING_")} == { "AGENTENV_SETTING_RESOLUTION": "1600x900", "AGENTENV_SETTING_TIMEZONE": "Europe/Paris"} - probed.assert_awaited_once_with("http://computer", "full", token=booted[0][CONTROL_TOKEN_ENV]) + assert len(booted[0]["token"]) >= 32 + probed.assert_awaited_once_with("http://computer", "full", token=booted[0]["token"]) def test_a_build_whose_probe_fails_or_that_never_gets_ready_fails_at_the_probe_and_isnt_deployed(base, driver, docker, @@ -552,7 +553,7 @@ async def test_a_computer_env_deploys_its_first_native_ready_build_with_its_sett assert (environment["AGENTENV_SETTING_RESOLUTION"], environment["AGENTENV_SETTING_TIMEZONE"]) == ("1600x900", "Asia/Tokyo") assert json.loads(environment["AGENTENV_COMPUTER_SETTINGS"]) == {"default_space": "norm"} assert environment["ENVIRONMENT_NAME"] == "desktop" and "AGENTENV_SETTING_THEME" not in environment - assert len(deployed.control_token) >= 32 and environment[CONTROL_TOKEN_ENV] == deployed.control_token + assert len(deployed.control_token) >= 32 and booted["token"] == deployed.control_token assert boots.calls == [(COMPUTER_DISPLAY, "get", None)] assert boots.headers == [{"Authorization": f"Bearer {deployed.control_token}"}] assert DeployedEnv.from_dict(dataclasses.asdict(deployed)).control_token == deployed.control_token @@ -673,16 +674,21 @@ async def test_a_computer_that_boots_at_another_size_than_its_display_fails_the_ @pytest.mark.asyncio -async def test_a_boot_that_never_gets_ready_takes_its_sandbox_and_work_folder_down(monkeypatch, tmp_path): +async def test_a_boot_hands_the_computer_its_token_and_one_never_ready_takes_its_sandbox_and_work_folder_down( + monkeypatch, tmp_path): monkeypatch.setenv("AGENT_ENV_LOCAL_SANDBOX_DIR", str(tmp_path)) sandbox = local_sandbox.LocalSandbox(exposed_ports=[18765], sandbox_id="local-unready") sandbox.terminate = AsyncMock() monkeypatch.setattr(boot, "ready", AsyncMock(side_effect=TimeoutError("not ready"))) + create = AsyncMock(return_value=sandbox) with pytest.raises(TimeoutError): - await boot.boot(SimpleNamespace(create_container=AsyncMock(return_value=sandbox)), image="img", port=18765, - environment={}, network_policy=None, timeout=60, attribution=None) + await boot.boot(SimpleNamespace(create_container=create), image="img", port=18765, + environment={"ENVIRONMENT_NAME": "desk"}, token="t0ken", network_policy=None, timeout=60, + attribution=None) + assert create.await_args.kwargs["env"] == {"ENVIRONMENT_NAME": "desk", CONTROL_TOKEN_ENV: "t0ken"} + assert boot.ready.await_args.args[2] == "t0ken" sandbox.terminate.assert_awaited_once() assert local_sandbox.LocalSandbox.find_work_dir("local-unready") is None From ed9e0bff6d1502d3aa19730979dcd5f9cad6b034 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 08:29:02 -0800 Subject: [PATCH 16/16] refactor(env): one rule for where a control token goes: the env's own address, as a step's grant DeployedEnv.control_headers_for(url) gives the token only to a URL at or under the env's address, through protocol_v1.granted_headers, the rule request_headers applies to a grant. Snapshot captures and service_state use it instead of checking for a gateway_url override. Co-Authored-By: Claude Opus 5.5 --- .../src/agentenv_protocol/client.py | 9 ++++++++- src/agent_env/env/env.py | 5 +++++ src/agent_env/env/legacy_protocol.py | 3 ++- src/agent_env/task_step/task_steps/snapshot_env.py | 6 +++--- tst/unit/env/test_env.py | 8 ++++++-- tst/unit/task_step/test_snapshot_env.py | 12 ++++++++++-- 6 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/client.py b/packages/agentenv-protocol/src/agentenv_protocol/client.py index e77622b38..dcddaf541 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/client.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/client.py @@ -66,7 +66,14 @@ def request_headers(url: str, headers: dict[str, str] | None = None) -> dict[str (``authorized``), if any.""" if headers is not None: return headers - return next((sent for base, sent in _GRANTS.get().items() if url == base or url.startswith(f"{base}/")), {}) + return granted_headers(url, _GRANTS.get()) + + +def granted_headers(url: str, grants: dict[str | None, dict[str, str]]) -> dict[str, str]: + """What ``grants``, env addresses and the headers that carry their control tokens, give a call to ``url``: those of + the address it is or is under, and none anywhere else.""" + return next((sent for base, sent in grants.items() + if base and (url == base.rstrip("/") or url.startswith(f"{base.rstrip('/')}/"))), {}) async def _rpc(base_url: str, method: str, params: dict, timeout: int, verify: bool, diff --git a/src/agent_env/env/env.py b/src/agent_env/env/env.py index 3a59f2d66..411cceb91 100644 --- a/src/agent_env/env/env.py +++ b/src/agent_env/env/env.py @@ -117,6 +117,11 @@ def control_headers(self) -> dict[str, str]: """What a call to the env's framework-only routes sends: its control token, when it has one.""" return protocol_v1.control_headers(self.control_token) + def control_headers_for(self, url: str) -> dict[str, str]: + """What a call to ``url`` sends of the env's control token: ``control_headers`` when ``url`` is the env's + address or under it, as a step's grant gives them, and none anywhere else, an override's address say.""" + return protocol_v1.granted_headers(url, {self.environment_url: self.control_headers}) + def supports(self, uri: str, method: str) -> bool: """Whether the stored env card offers `method` on the extension at `uri`; no network.""" return protocol_v1.find_extension_method(self.environment_card or {}, uri, method) is not None diff --git a/src/agent_env/env/legacy_protocol.py b/src/agent_env/env/legacy_protocol.py index 3721e2284..16899025b 100644 --- a/src/agent_env/env/legacy_protocol.py +++ b/src/agent_env/env/legacy_protocol.py @@ -105,7 +105,8 @@ async def service_state(deployed: Optional[DeployedEnv], gateway_url: Optional[s base_url = await v1_base_url(deployed, gateway_url, environment_name, mcp=True) if base_url is None: return await export_state(gateway_url, environment_name) - response = await protocol_v1.get_data(base_url, headers=deployed.control_headers if deployed is not None else None) + response = await protocol_v1.get_data(base_url, + headers=deployed.control_headers_for(base_url) if deployed is not None else None) if not response.parts: return {} if isinstance(response.parts[0], DataPart): diff --git a/src/agent_env/task_step/task_steps/snapshot_env.py b/src/agent_env/task_step/task_steps/snapshot_env.py index 52e99a482..48fc9d6dc 100644 --- a/src/agent_env/task_step/task_steps/snapshot_env.py +++ b/src/agent_env/task_step/task_steps/snapshot_env.py @@ -379,7 +379,7 @@ def _resolve_deployed_env(self, context: TaskStepContext): async def _capture_env_trajectory(self, entry: dict, context: TaskStepContext, env_id: str, deployed) -> None: """Stream the trajectory to a temp file, upload it verbatim (no envelope), and fill ``entry``.""" method, url = _trajectory_route(deployed, self.gateway_url) - headers = None if self.gateway_url else deployed.control_headers + headers = deployed.control_headers_for(url) if deployed is not None else None store = get_config().get_object_store() event_count = 0 timeout = httpx.Timeout(_TRAJECTORY_CAPTURE_BUDGET_SECONDS, connect=_CONNECT_TIMEOUT_SECONDS) @@ -407,7 +407,7 @@ async def _capture_env_trajectory(self, entry: dict, context: TaskStepContext, e async def _capture_env_changes(self, entry: dict, context: TaskStepContext, env_id: str, deployed) -> None: """Page the gateway's changes into a JSONL upload; fill ``entry`` with its url and attribution counts.""" method, url = _changes_route(deployed, self.gateway_url) - headers = None if self.gateway_url else deployed.control_headers + headers = deployed.control_headers_for(url) if deployed is not None else None counts = {"change_count": 0, "attributed_count": 0, "ambiguous_count": 0} timeout = httpx.Timeout(_CHANGES_CAPTURE_BUDGET_SECONDS, connect=_CONNECT_TIMEOUT_SECONDS) tmp = tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) @@ -501,7 +501,7 @@ async def _export_environment_to_file( base_url = await legacy_protocol.v1_base_url(deployed, gateway_url, environment_name, mcp=True) if base_url is not None: - headers = deployed.control_headers if deployed is not None else None + headers = deployed.control_headers_for(base_url) if deployed is not None else None card_base, card = await legacy_protocol.child_env_card(deployed, gateway_url, environment_name) if returns_file_tree(card or {}): upload = await asyncio.to_thread(_snapshot_upload, card or {}, timeout_seconds, diff --git a/tst/unit/env/test_env.py b/tst/unit/env/test_env.py index 7d6a4893f..03bda1b95 100644 --- a/tst/unit/env/test_env.py +++ b/tst/unit/env/test_env.py @@ -114,16 +114,20 @@ async def test_a_record_without_a_card_offers_nothing(sent): @pytest.mark.asyncio -async def test_a_record_with_a_control_token_sends_it_on_each_extension_call_and_keeps_it_through_storage( +async def test_a_record_with_a_control_token_sends_it_on_each_extension_call_only_to_its_address_and_keeps_it( composed, sent): deployed = _record(composed) deployed.control_token = "t0ken" + env = deployed.environment_url await deployed.invoke(EXT_STEP_URI, "step", {"action": "list_tools"}) assert [request.headers.get("authorization") for request in sent] == ["Bearer t0ken"] + assert [deployed.control_headers_for(url) for url in (env, f"{env}/svc/clock/agentenv", f"{env}0/x", + "https://override/trajectory")] == [ + {"Authorization": "Bearer t0ken"}] * 2 + [{}] * 2 assert DeployedEnv.from_dict(dataclasses.asdict(deployed)).control_token == "t0ken" - assert _record(composed).control_headers == {} + assert _record(composed).control_headers == {} and _record(composed).control_headers_for(env) == {} def test_child_env_cards_come_from_the_card_by_name(composed): diff --git a/tst/unit/task_step/test_snapshot_env.py b/tst/unit/task_step/test_snapshot_env.py index 473cf9af7..877244c37 100644 --- a/tst/unit/task_step/test_snapshot_env.py +++ b/tst/unit/task_step/test_snapshot_env.py @@ -236,6 +236,7 @@ def __init__(self, response=None, error=None): self.response = response self.error = error self.requests = [] + self.headers = [] def __call__(self, **kwargs): return self @@ -248,6 +249,7 @@ async def __aexit__(self, *exc): def stream(self, method, url, params=None, headers=None): self.requests.append((method, url, params)) + self.headers.append(headers) client = self class _CM: @@ -428,7 +430,7 @@ async def test_trajectory_capture_records_one_sentence_when_the_card_offers_no_t @pytest.mark.asyncio -async def test_trajectory_capture_streams_from_the_stored_cards_address_and_endpoint(monkeypatch): +async def test_trajectory_capture_streams_from_the_stored_cards_address_and_endpoint_with_its_token(monkeypatch): _wire_fake_snapshot(monkeypatch) client = _FakeAsyncClient(response=_FakeTrajectoryResponse([b"{}\n"])) _wire_fake_capture(monkeypatch, client) @@ -436,22 +438,28 @@ async def test_trajectory_capture_streams_from_the_stored_cards_address_and_endp deployed.environment_card_url = f"https://sandbox.example/sb-1{WELL_KNOWN_PATH}" deployed.environment_card = {"capabilities": {"extensions": [{ "uri": "urn:agentenv:trajectory/v1", "params": {"endpoint": "/env/trajectory", "methods": {"get": {"method": "GET"}}}}]}} + deployed.control_token = "t0ken" await _step(include_env_trajectory=True).execute(_ctx(deployed)) assert [(m, u) for m, u, _ in client.requests] == [("GET", "https://sandbox.example/sb-1/env/trajectory")] + assert client.headers == [{"Authorization": "Bearer t0ken"}] @pytest.mark.asyncio @pytest.mark.parametrize("deployments", [[], [_deployed()]], ids=["pure-override", "next-to-a-deployment"]) -async def test_trajectory_capture_with_a_gateway_url_override_reads_the_override(monkeypatch, deployments): +async def test_trajectory_capture_with_a_gateway_url_override_reads_the_override_without_a_token(monkeypatch, + deployments): _wire_fake_snapshot(monkeypatch) client = _FakeAsyncClient(response=_FakeTrajectoryResponse([b"{}\n"])) _wire_fake_capture(monkeypatch, client) + for deployed in deployments: + deployed.control_token = "t0ken" await _step(env_id="env-1", gateway_url="https://override/", include_env_trajectory=True).execute(_ctx(*deployments)) assert [(m, u) for m, u, _ in client.requests] == [("GET", "https://override/trajectory")] + assert "Authorization" not in (client.headers[0] or {}) # ── env changes capture ──────────────────────────────────────────────────────