From 94dcfc868ad5e263669c6681ab7d1cc8cbf68cdd Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 7 Nov 2026 08:41:16 -0800 Subject: [PATCH 1/3] fix(computer): leave a build unverified, not failed, when a setting it boots with doesn't resolve A deploy without a required env: or secret: setting was recorded as the build failing at the probe, though nothing was booted, so later deploys refused the build until it was rebuilt. The build now stays unverified with the reason, and a later compose probes it once the setting resolves. Co-Authored-By: Claude Opus 5.5 --- .../env/envs/computer/utils/probing.py | 23 +++++++++++--- tst/unit/env/envs/test_computer_env.py | 31 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/agent_env/env/envs/computer/utils/probing.py b/src/agent_env/env/envs/computer/utils/probing.py index 46cdfc2d5..3989a4b10 100644 --- a/src/agent_env/env/envs/computer/utils/probing.py +++ b/src/agent_env/env/envs/computer/utils/probing.py @@ -11,6 +11,7 @@ from agentenv_protocol.computer.conformance import run_probe from agent_env.artifact.artifacts.file import FileArtifact +from agent_env.config.errors import ConfigError from agent_env.env.env import Env from agent_env.env.envs.computer.utils import boot from agent_env.env.envs.computer.utils.composed import Record, failed, now @@ -53,16 +54,25 @@ async def probe_first_boot(env: ComputerEnv, form: str, arch: str, sandbox_provi return result +class Unprobed(Exception): + """The probe couldn't boot the build, for a setting it boots with that doesn't resolve here: which says nothing of + the build.""" + + async def checked(links: list[SandboxProvider], env: ComputerEnv, driver: ComputerDriverEnv, form: str, arch: str, result: dict) -> dict: """The build of ``result`` booted on the first of ``links`` that boots it and probed at ``full``: ready, with the driver's card, or failed at the probe; each with a summary of the probe and its report stored. A probe that ended on a transport error talking to the driver runs again on a fresh boot first, up to ``_PROBE_ATTEMPTS`` in all, the - record then keeping each attempt; one with a failed check is recorded at once.""" + record then keeping each attempt; one with a failed check is recorded at once. A build the probe couldn't boot for + a setting that doesn't resolve here stays as it was, unverified, saying why, so a compose once it resolves probes + it.""" attempts: list[dict] = [] while True: try: report, card, provider = await _probe(links, env, driver, result["image"], form) + except Unprobed as e: + return {**result, "reason": f"not probed: {e}"} except TimeoutError: outcome = failed(env, form, arch, "probe", result["key"], f"the probe didn't finish within {_PROBE_TIMEOUT_S} s") @@ -141,10 +151,15 @@ async def _probe(links: list[SandboxProvider], env: ComputerEnv, driver: Compute form: str = "container") -> tuple[ProbeReport, dict, str]: """``image``, of ``form``, booted at the env's settings and its apps' defaults with a control token of its own, as a deploy with no ``env_params`` 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 within ``_PROBE_TIMEOUT_S`` and torn down; the report, the driver's card and the provider's name. Raises + Unprobed, booting nothing, when a setting it boots with doesn't resolve here.""" token = boot.control_token() - environment = {**boot.settings_environment(env.computer, env.computer_values(), environment_name=env.computer_name), - **boot.app_settings_environment(env.composed_apps(), env.app_defaults(), env.computer.os.type)} + try: + environment = { + **boot.settings_environment(env.computer, env.computer_values(), environment_name=env.computer_name), + **boot.app_settings_environment(env.composed_apps(), env.app_defaults(), env.computer.os.type)} + except ConfigError as e: # an env: or secret: reference that doesn't resolve here, or no secret store to resolve it + raise Unprobed(f"a setting it boots with didn't resolve here: {e}") from e problems = [] for link in links: try: diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index a8a363820..2561703ec 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -25,6 +25,7 @@ from agentenv_protocol.computer import COMPUTER_DISPLAY, COMPUTER_READY, COMPUTER_RECORDING from agentenv_protocol.computer.app import COMPUTER_APP from agentenv_protocol.computer.app_runtime import HOLD_BOOT_LAUNCH_ENV +from agentenv_protocol.computer.conformance import CheckResult, ProbeReport from agentenv_protocol.tool_access import DISABLE_TOOL_EXTENSION_URI, ENABLE_TOOL_EXTENSION_URI from click.testing import CliRunner @@ -305,6 +306,36 @@ async def fake_boot(link, **kwargs): probed.assert_awaited_once_with("http://computer", "full", token=booted[0]["token"]) +def test_a_build_the_probe_cant_boot_for_a_setting_that_doesnt_resolve_stays_unverified_until_a_compose_where_it_does( + base, driver, docker, tmp_path, monkeypatch): + monkeypatch.delenv("MAIL_TOKEN", raising=False) + monkeypatch.setattr(probing, "_probe", _probe) + booted = [] + + async def fake_boot(link, **kwargs): + booted.append(kwargs["environment"]) + return boot.Booted(sandbox=None, url="http://computer", card=CARD) + + monkeypatch.setattr(boot, "boot", fake_boot) + monkeypatch.setattr(boot, "discard", AsyncMock()) + monkeypatch.setattr(probing, "run_probe", AsyncMock(return_value=ProbeReport.of("full", "desk", [CheckResult( + check="P0", name="the card", status="passed", extensions=[CARD_EXTENSION])], [CARD_EXTENSION]))) + mail = _app(base, driver, tmp_path / "mail", compose=False, defaults={"token": "env:MAIL_TOKEN"}) + env = _put(base, driver, apps=[mail]) + unprobed = Env.get(env.id).metadata["composed"]["container"]["arm64"] + left = composer.pending(Env.get(env.id)) + monkeypatch.setenv("MAIL_TOKEN", "t0ken") + composer.compose(Env.get(env.id), left) + probed = Env.get(env.id).metadata["composed"]["container"]["arm64"] + + assert (unprobed["status"], unprobed["reason"]) == ("unverified", "not probed: a setting it boots with didn't " + "resolve here: Unresolved env:MAIL_TOKEN reference (no value " + "and no default)") + assert left == [("container", "arm64")] + assert probed["status"] == "ready" and probed["image"] == unprobed["image"] + assert len(booted) == 1 and json.loads(booted[0]["AGENTENV_APP_SETTINGS"])["mail"]["token"] == "t0ken" + + def test_a_build_whose_probe_fails_or_that_never_gets_ready_fails_at_the_probe_and_isnt_deployed(base, driver, docker, monkeypatch): docker.probe_fails["arm64"] = "the click never landed" From bebb0174b8557bdd44e8e639512f0da9502444ad Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 7 Nov 2026 09:36:46 -0800 Subject: [PATCH 2/3] fix(computer): a deploy probes a container left unverified before booting it A container left unverified, because a setting it boots with didn't resolve when it was probed, was refused by every deploy that doesn't compose (`env deploy`, a stored task's run) until `env build`. A deploy now composes it again first when its provider runs it natively, reusing its image and probing it, and deploys it only once ready; a deploy whose setting still doesn't resolve is refused with that container's reason. Disks and VM bundles are unchanged: their first boot probes them. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/agent_env/computers/README.md | 5 +- src/agent_env/env/envs/computer/env.py | 8 ++- .../env/envs/computer/utils/composer.py | 28 ++++++-- .../env_providers/env_computer_provider.py | 5 +- tst/unit/env/envs/test_computer_env.py | 70 +++++++++++++++---- 6 files changed, 94 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c0eae13a6..a1875d1b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ and deserialized through a registry. | Package | Contents | |---|---| -| `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset`, `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`, one site: a server image, `backend_image` in its env.toml, serving the pages and API, ready when its card's `website/v1` `ready` answers a `WebsiteReadiness` that says so, as `agentenv_protocol.website` (`website_readiness`, `env_readiness`) defines a website env's health for the deploy and `DeployedEnv.readiness()` alike, or, for a card without `ready`, when its `health` path answers 200, which is deprecated; the gateway container's `env/gateway/constants.py` mirrors the URI and op, a test keeping them equal; a separate frontend image is deprecated, warned of at put and optional in a bundle, and a stored `backend_docker_image_artifact` reads as the server image), `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`), a run only the forms its sandbox's deploys boot (`booted_forms`: on each link, the first form it boots natively, by the deploy's own rule, `boot.first_native`; a compose of a disk adds its container), and a deploy composes first what it boots that no compose started (`ComputerEnv.compose_unstarted`); 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 (`utils/probing.py`) 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, over its env.toml's `[settings]` and a computer env's `settings` beside it in `apps`, each by top-level field (`params.app_settings`; checked at put, a required one left to the deploy; the deploy record keeps the merged values as `app_settings`, secrets withheld); 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. Its site route, `/website//`, never forwards a site's data plane or card (`/agentenv/…`, `/.well-known/agent-env.json`), which agentenv reaches through `/svc//`; a site with no frontend image is served by its server image there. Its CDP route, `/browser//cdp//` (`env/gateway/browser_proxy.py`), reaches the website browser behind the `/svc` child ``, found from that child's card (it, or an app among its children, offering `browser/v1` `cdp` with `/cdp/` in its `computer-app/v1` `agent_routes`): it relays the browser's discovery and WebSocket both ways, each leg pinged every 20 s, and every CDP address the browser hands out, in `json/version`, `json/list` and its `cdp` answer through `/svc//`, comes back at the gateway, everything before `/cdp//` becoming the address the caller reached the gateway at and `/browser/`; each connection is a `browser_connection` trajectory event, without its token. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | +| `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset`, `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`, one site: a server image, `backend_image` in its env.toml, serving the pages and API, ready when its card's `website/v1` `ready` answers a `WebsiteReadiness` that says so, as `agentenv_protocol.website` (`website_readiness`, `env_readiness`) defines a website env's health for the deploy and `DeployedEnv.readiness()` alike, or, for a card without `ready`, when its `health` path answers 200, which is deprecated; the gateway container's `env/gateway/constants.py` mirrors the URI and op, a test keeping them equal; a separate frontend image is deprecated, warned of at put and optional in a bundle, and a stored `backend_docker_image_artifact` reads as the server image), `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`), a run only the forms its sandbox's deploys boot (`booted_forms`: on each link, the first form it boots natively, by the deploy's own rule, `boot.first_native`; a compose of a disk adds its container), and a deploy composes first what it boots that no compose started, and a container it runs natively that was built but not probed, as one whose probe a setting that didn't resolve stopped, booting it only once ready (`ComputerEnv.compose_unstarted`); 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 (`utils/probing.py`) 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, over its env.toml's `[settings]` and a computer env's `settings` beside it in `apps`, each by top-level field (`params.app_settings`; checked at put, a required one left to the deploy; the deploy record keeps the merged values as `app_settings`, secrets withheld); 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. Its site route, `/website//`, never forwards a site's data plane or card (`/agentenv/…`, `/.well-known/agent-env.json`), which agentenv reaches through `/svc//`; a site with no frontend image is served by its server image there. Its CDP route, `/browser//cdp//` (`env/gateway/browser_proxy.py`), reaches the website browser behind the `/svc` child ``, found from that child's card (it, or an app among its children, offering `browser/v1` `cdp` with `/cdp/` in its `computer-app/v1` `agent_routes`): it relays the browser's discovery and WebSocket both ways, each leg pinged every 20 s, and every CDP address the browser hands out, in `json/version`, `json/list` and its `cdp` answer through `/svc//`, comes back at the gateway, everything before `/cdp//` becoming the address the caller reached the gateway at and `/browser/`; each connection is a `browser_connection` trajectory event, without its token. 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/`, among them `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 namespaces, `@local` and `@hf` (`is_local_id`), each with a documents file of its own: while the CLI or a `build_app` server runs, a local id and what a 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/`. | diff --git a/src/agent_env/computers/README.md b/src/agent_env/computers/README.md index c5da91ac8..c45dab62f 100644 --- a/src/agent_env/computers/README.md +++ b/src/agent_env/computers/README.md @@ -50,7 +50,10 @@ names another, never on the sandbox it deploys on. A compose of a disk also comp unless that's built. A deploy that boots a form no compose has started, a disk named by `env_params` included, composes it first on that same provider, saying which, reusing what's built, as every build is reused by its key; deploys of one env at once compose it once, the others -waiting for it, a deploy that finds it building included. In `agent-env run` it says why and each stage as it goes, and +waiting for it, a deploy that finds it building included. A container built but left unverified though the deploy's +sandbox runs it natively, as one is when a setting its probe boots with doesn't resolve, is composed again the same way: +its image reused and probed, and booted only once ready; a deploy that still has no ready build names why each such +container wasn't probed. In `agent-env run` it says why and each stage as it goes, and a deploy waiting for another's compose says what it waits for and since when. A local run doesn't compose a registered env's form at deploy where a configured store isn't the per-user one, since the run's fence refuses its record: the deploy refuses for want of a ready build and names `agent-env env build`. diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index 93a6502e5..2fe499a64 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -209,7 +209,8 @@ def computer_artifact(self) -> ComputerArtifact: return ComputerArtifact.get(self.metadata["computer"]["id"], self.metadata["computer"]["version"]) async def compose_unstarted(self, sandbox_provider: SandboxProvider, form: Optional[str] = None) -> None: - """Compose first what a deploy on ``sandbox_provider`` boots that no compose started, once for deploys at once, + """Compose first what a deploy on ``sandbox_provider`` boots that no compose started, or a container it runs + natively that was built but not probed (``composer.unprobed``), once for deploys at once, on the env's default sandbox provider, this machine unless the config names another, never on the deploy's, unless the run's fence would refuse the record (rules in ``computers/README.md``). A cancelled deploy holds the lock until its compose's thread, which no cancel stops, ends, so the next deploy waits for what it builds.""" @@ -235,8 +236,9 @@ async def waiting(seconds: float) -> None: composed = self.metadata.get("composed", {}) booting, building = (" or ".join(type(link).__name__ for link in provider.links) for provider in (sandbox_provider, on.provider())) - why = (f"an agent-env that composes differently built {them}" if any( - stale(composed.get(name, {}).get(arch)) for name, arch in pairs) + records = [composed.get(name, {}).get(arch) for name, arch in pairs] + why = (f"an agent-env that composes differently built {them}" if any(map(stale, records)) + else f"no compose probed {them} on {booting} yet" if all(records) else f"no compose built {them} for {booting} yet") say(f"composing {_pairs(pairs)} before this deploy on {building} ({why}); later deploys reuse {them}") composing = asyncio.ensure_future(asyncio.to_thread(composer.compose, self, pairs, on)) diff --git a/src/agent_env/env/envs/computer/utils/composer.py b/src/agent_env/env/envs/computer/utils/composer.py index 6caa6d94c..eb0353dba 100644 --- a/src/agent_env/env/envs/computer/utils/composer.py +++ b/src/agent_env/env/envs/computer/utils/composer.py @@ -187,16 +187,34 @@ def left(form: str, arch: str, record: dict | None) -> bool: def unstarted(env: ComputerEnv, sandbox_provider: SandboxProvider, form: str | None = None) -> list[tuple[str, str]]: """The pairs a deploy on ``sandbox_provider`` boots that no compose has started, as a run for another sandbox - leaves them, or that an agent-env that composes differently recorded.""" - return _pairs(env, booted_forms(env, sandbox_provider, form), - lambda form, arch, record: record is None or stale(record)) + leaves them, that an agent-env that composes differently recorded, or that are ``unprobed`` there.""" + return _pairs(env, booted_forms(env, sandbox_provider, form), lambda form, arch, record: ( + record is None or stale(record) or _unprobed(env, sandbox_provider, form, arch, record))) def unstarted_or_building(env: ComputerEnv, sandbox_provider: SandboxProvider, form: str | None = None) -> list[tuple[str, str]]: """Those, and the ones a compose is building now, which a deploy waits for.""" - return _pairs(env, booted_forms(env, sandbox_provider, form), - lambda form, arch, record: record is None or stale(record) or record.get("status") == "building") + return _pairs(env, booted_forms(env, sandbox_provider, form), lambda form, arch, record: ( + record is None or stale(record) or record.get("status") == "building" + or _unprobed(env, sandbox_provider, form, arch, record))) + + +def unprobed(env: ComputerEnv, sandbox_provider: SandboxProvider, + form: str | None = None) -> list[tuple[str, str, dict]]: + """Each container a deploy on ``sandbox_provider`` boots that was built but not probed though the deploy runs it + natively, as (form, arch, record): one whose probe couldn't boot it for a setting that didn't resolve, say. A deploy + composes it again first, which reuses its image and probes it, since a container deploys only once ready.""" + composed = env.metadata.get("composed", {}) + return [(name, arch, composed[name][arch]) for name, arch in _pairs( + env, booted_forms(env, sandbox_provider, form), + lambda form, arch, record: _unprobed(env, sandbox_provider, form, arch, record))] + + +def _unprobed(env: ComputerEnv, sandbox_provider: SandboxProvider, form: str, arch: str, record: dict | None) -> bool: + # A disk or VM bundle left unverified deploys already, its first boot probing it (probing.probe_first_boot). + return (form == "container" and record is not None and record.get("status") == "unverified" and not stale(record) + and bool(boot.native_links(sandbox_provider, arch, form, env.stored_id, env.computer.os.type))) def booted_forms(env: ComputerEnv, sandbox_provider: SandboxProvider, form: str | None = None) -> list[str]: 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 7df383a65..c16bbbca9 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -88,8 +88,11 @@ async def deploy( "deploy_env names in env_params.sites; give them there to deploy it any other way") await env.compose_unstarted(sandbox_provider, params.form) if not (builds := env.deployable_builds(params.form)): + why = "".join(f"; {name}/{arch}: {record.get('reason', 'not probed')}" + for name, arch, record in composer.unprobed(env, sandbox_provider, params.form)) raise DeployRefused(f"{env.id} has no ready build to deploy; its builds are " - f"{env.build_statuses() or 'none yet'}; compose it with: agent-env env build --id {env.id}") + f"{env.build_statuses() or 'none yet'}{why}; compose it with: agent-env env build --id " + f"{env.id}") if params.site_routing and not (builds := [build for build in builds if build[0] != "disk"]): raise DeployRefused(f"{env.id}: env_params.site_routing installs into the computer's container, and a disk " "computer runs inside a launcher container it can't reach; deploy a container form") diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 2561703ec..8102c5431 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -309,19 +309,9 @@ async def fake_boot(link, **kwargs): def test_a_build_the_probe_cant_boot_for_a_setting_that_doesnt_resolve_stays_unverified_until_a_compose_where_it_does( base, driver, docker, tmp_path, monkeypatch): monkeypatch.delenv("MAIL_TOKEN", raising=False) - monkeypatch.setattr(probing, "_probe", _probe) - booted = [] - - async def fake_boot(link, **kwargs): - booted.append(kwargs["environment"]) - return boot.Booted(sandbox=None, url="http://computer", card=CARD) - - monkeypatch.setattr(boot, "boot", fake_boot) - monkeypatch.setattr(boot, "discard", AsyncMock()) - monkeypatch.setattr(probing, "run_probe", AsyncMock(return_value=ProbeReport.of("full", "desk", [CheckResult( - check="P0", name="the card", status="passed", extensions=[CARD_EXTENSION])], [CARD_EXTENSION]))) - mail = _app(base, driver, tmp_path / "mail", compose=False, defaults={"token": "env:MAIL_TOKEN"}) - env = _put(base, driver, apps=[mail]) + booted = _probing_for_real(monkeypatch) + env = _put(base, driver, apps=[_app(base, driver, tmp_path / "mail", compose=False, + defaults={"token": "env:MAIL_TOKEN"})]) unprobed = Env.get(env.id).metadata["composed"]["container"]["arm64"] left = composer.pending(Env.get(env.id)) monkeypatch.setenv("MAIL_TOKEN", "t0ken") @@ -336,6 +326,23 @@ async def fake_boot(link, **kwargs): assert len(booted) == 1 and json.loads(booted[0]["AGENTENV_APP_SETTINGS"])["mail"]["token"] == "t0ken" +def _probing_for_real(monkeypatch) -> list[dict]: + """Gate 3's own probe, which resolves the settings it boots with, over a boot and a probe that pass: the + environment of each boot it makes.""" + booted = [] + + async def fake_boot(link, **kwargs): + booted.append(kwargs["environment"]) + return boot.Booted(sandbox=None, url="http://computer", card=CARD) + + monkeypatch.setattr(probing, "_probe", _probe) + monkeypatch.setattr(boot, "boot", fake_boot) + monkeypatch.setattr(boot, "discard", AsyncMock()) + monkeypatch.setattr(probing, "run_probe", AsyncMock(return_value=ProbeReport.of("full", "desk", [CheckResult( + check="P0", name="the card", status="passed", extensions=[CARD_EXTENSION])], [CARD_EXTENSION]))) + return booted + + def test_a_build_whose_probe_fails_or_that_never_gets_ready_fails_at_the_probe_and_isnt_deployed(base, driver, docker, monkeypatch): docker.probe_fails["arm64"] = "the click never landed" @@ -759,6 +766,43 @@ async def test_a_deploy_composes_again_what_an_agent_env_that_composes_different assert booted["image"] == record["arm64"]["image"] +@pytest.mark.asyncio +async def test_a_deploy_probes_a_container_left_unprobed_once_its_setting_resolves_and_boots_it_only_once_ready( + base, driver, docker, boots, tmp_path, monkeypatch): + monkeypatch.delenv("MAIL_TOKEN", raising=False) + probed = _probing_for_real(monkeypatch) + env = _put(base, driver, display={"width": 1600, "height": 900}, apps=[_app( + base, driver, tmp_path / "mail", compose=False, defaults={"token": "env:MAIL_TOKEN"})]) + unprobed = Env.get(env.id).metadata["composed"]["container"]["arm64"] + monkeypatch.setenv("MAIL_TOKEN", "t0ken") + + deployed = await Env.get(env.id).deploy() + + build = Env.get(env.id).metadata["composed"]["container"]["arm64"] + (booted,) = boots.boots + assert unprobed["status"] == "unverified" and build["status"] == "ready" + assert build["image"] == unprobed["image"] == booted["image"] == deployed.metadata["computer"]["image"] + assert len(probed) == 1 and json.loads(probed[0]["AGENTENV_APP_SETTINGS"])["mail"]["token"] == "t0ken" + + +@pytest.mark.asyncio +async def test_a_deploy_refuses_a_container_still_unprobed_for_a_setting_that_doesnt_resolve_saying_why( + base, driver, docker, boots, tmp_path, monkeypatch): + monkeypatch.delenv("MAIL_TOKEN", raising=False) + probed = _probing_for_real(monkeypatch) + env = _put(base, driver, apps=[_app(base, driver, tmp_path / "mail", compose=False, + defaults={"token": "env:MAIL_TOKEN"})]) + + with pytest.raises(DeployRefused) as refused: + await Env.get(env.id).deploy() + + assert str(refused.value) == ( + f"{env.id} has no ready build to deploy; its builds are {{'container/amd64': 'unverified', 'container/arm64': " + "'unverified'}; container/arm64: not probed: a setting it boots with didn't resolve here: Unresolved " + f"env:MAIL_TOKEN reference (no value and no default); compose it with: agent-env env build --id {env.id}") + assert probed == [] and boots.boots == [] + + @pytest.mark.asyncio async def test_a_reset_ends_the_envs_recordings_then_boots_a_fresh_container_where_the_last_one_ran( base, driver, docker, boots, monkeypatch): From dbfad92f53b2bdda7e3bfd0cd58d659a835db32f Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 7 Nov 2026 09:57:17 -0800 Subject: [PATCH 3/3] fix(computer): say why a native container is unprobed even when another arch's build is ready A ready build of an arch the deploy's provider can't run let the deploy past the refusal that names an unprobed container's reason, and it then failed only with "runs none of its ready builds natively", hiding the missing setting. When no ready build runs natively and a native container is unprobed, the deploy is refused with that container's reason. Co-Authored-By: Claude Opus 5.5 --- .../env_providers/env_computer_provider.py | 12 +++++++--- tst/unit/env/envs/test_computer_env.py | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) 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 c16bbbca9..bf4897315 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -29,6 +29,7 @@ discard, first_native, listed, + native_links, ready, ready_timeout, recorded_app_settings, @@ -87,9 +88,14 @@ async def deploy( raise DeployRefused(f"{env.id}: env_params.site_routing reaches the run's websites, which a run's " "deploy_env names in env_params.sites; give them there to deploy it any other way") await env.compose_unstarted(sandbox_provider, params.form) - if not (builds := env.deployable_builds(params.form)): - why = "".join(f"; {name}/{arch}: {record.get('reason', 'not probed')}" - for name, arch, record in composer.unprobed(env, sandbox_provider, params.form)) + builds = env.deployable_builds(params.form) + unprobed = composer.unprobed(env, sandbox_provider, params.form) + # A ready build only of an arch this provider can't run mustn't hide why its native one is still unprobed. + if unprobed and not any(native_links(sandbox_provider, arch, form, env.stored_id, env.computer.os.type) + for form, arch, _ in builds): + builds = [] + if not builds: + why = "".join(f"; {name}/{arch}: {record.get('reason', 'not probed')}" for name, arch, record in unprobed) raise DeployRefused(f"{env.id} has no ready build to deploy; its builds are " f"{env.build_statuses() or 'none yet'}{why}; compose it with: agent-env env build --id " f"{env.id}") diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 8102c5431..6267129c8 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -803,6 +803,28 @@ async def test_a_deploy_refuses_a_container_still_unprobed_for_a_setting_that_do assert probed == [] and boots.boots == [] +@pytest.mark.asyncio +async def test_a_deploy_says_why_its_native_container_is_unprobed_though_another_arch_it_cant_run_is_ready( + base, driver, docker, boots, tmp_path, monkeypatch): + monkeypatch.delenv("MAIL_TOKEN", raising=False) + probed = _probing_for_real(monkeypatch) + env = _put(base, driver, apps=[_app(base, driver, tmp_path / "mail", compose=False, + defaults={"token": "env:MAIL_TOKEN"})]) + stored = Env.get(env.id) + composed = stored.metadata["composed"] + composed["container"]["amd64"] = {**composed["container"]["amd64"], "status": "ready"} + stored.update_metadata({**stored.metadata, "composed": composed}) + + with pytest.raises(DeployRefused) as refused: + await Env.get(env.id).deploy() + + assert str(refused.value) == ( + f"{env.id} has no ready build to deploy; its builds are {{'container/amd64': 'ready', 'container/arm64': " + "'unverified'}; container/arm64: not probed: a setting it boots with didn't resolve here: Unresolved " + f"env:MAIL_TOKEN reference (no value and no default); compose it with: agent-env env build --id {env.id}") + assert probed == [] and boots.boots == [] + + @pytest.mark.asyncio async def test_a_reset_ends_the_envs_recordings_then_boots_a_fresh_container_where_the_last_one_ran( base, driver, docker, boots, monkeypatch):