From 57f6048111ca77b4c3973282bf481be4e108e758 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 10:27:11 -0800 Subject: [PATCH 01/15] feat(multi): a multi env names its website browser, and a browser computer runs on a sandbox of its own browser_envs on a multi env names its website browser: left out, the run's selector picks one as before; [] is none; one entry deploys, two and a non-browser env are refused at put. An MCP server env such as playwright-mcp runs among the gateway's MCP servers; a browser computer such as chromium deploys as one more computer child, sized by child_envs, its tools under its name, routing the synthetic sites unless its entry says, and its network held to the gateway's host and the live sites' hosts where the provider enforces an allowlist (reach_only). The gateway no longer reads an image off whatever the selector names, and a lone website env refuses a browser computer as the run's browser. CLI multi put --browser/--no-browser; the bundle plan and browser steps see it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- src/agent_env/browsers/README.md | 6 +- src/agent_env/bundle/plan.py | 21 +- src/agent_env/cli/env/multi.py | 24 +- src/agent_env/env/envs/_multi_computers.py | 55 ++- src/agent_env/env/envs/computer/env.py | 12 +- src/agent_env/env/envs/multi_env.py | 160 +++++++-- .../env_providers/env_computer_provider.py | 34 +- .../env_providers/env_gateway_provider.py | 75 +++- .../task_step/task_steps/browser/base.py | 6 +- .../computer/multi_env_browser_test.py | 184 ++++++++++ tst/unit/bundle/computer_env_folder_test.py | 15 + tst/unit/bundle/toml_refs_test.py | 3 + tst/unit/env/envs/test_multi_env_computers.py | 336 +++++++++++++++++- .../env_gateway_provider_test.py | 3 +- tst/unit/task_step/test_browser_sign_in.py | 2 +- 16 files changed, 842 insertions(+), 98 deletions(-) create mode 100644 tst/integration/computer/multi_env_browser_test.py diff --git a/AGENTS.md b/AGENTS.md index 5af2559e5..ae55114c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ or one of them only where it is used. deploy runs on; built-in `default`, `default-db` and `playwright-mcp`, the Playwright MCP website browser a deploy with websites adds. An id set in code, by `configure(...)` or the config attribute (as `agent-env env deploy --gateway` does), beats the file. Within a run, its `user_overrides` (`gateway_env_id`, `service_db_env_id`, - `website_browser_env_id`) beat both. + `website_browser_env_id`) beat both. A multi env's `browser_envs` names its own website browser instead, `[]` none. - Secrets never live in the file: use `secret:KEY` or `env:NAME` references, resolved through `[stores.secret]`. - agent-env has no stage concept: "dev" versus "prod" is which file `AGENT_ENV_CONFIG` names. A @@ -121,7 +121,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; its env.toml's `ready_timeout_s`, how long it may take from its server's container starting to ready, is that container's health check `start_period` and what the deploy waits, or its card's `ready` timeout if longer, and its `shm_mb` is the container's `shm_size`, each left to the gateway and Docker without them; 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; a live one, `live = true`, is a real site whose server serves none of its pages, its `live`, `domains` and `allow_hosts` read at put from its server's `@website(...)` (`utils/card_naming.py` `WebsiteSource`) unless given, its `settings`' secrets only as references the gateway resolves as it writes the site's container (`WEBSITE_SETTINGS_ENV`); the gateway gives it no site route, a computer's site table leaves it out, a computer whose network is an allowlist is let reach its names (`MultiEnv.live_hosts`), a browser alone's forward port takes them straight to the real site (`site_proxy.py` `direct`), as the browser base's stand-in proxy takes any host, refusing nothing, each proxy logging each such connection as a JSON line, and a run that deploys one is marked live, `reproducible: false`), `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.]`, or for one distribution `[install."/"]`, which replaces its OS type's there, as its `known_failures` do (`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, its `/dev/shm` the most its apps' Linux installs ask for (`shm_mb`, in MiB, which its composed computer and the deploy record keep; `--shm-size` on a container, `AGENTENV_SHM_MB` read by a Linux disk's init, passed only to a provider whose `SHARED_MEMORY` says it gives that much, else the sandbox's default, which the deploy logs), 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; `@linux(distribution=...)` or `@install("/", spec)` for one distribution, which replaces its OS type's there) 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 and computers; its website browser is the one its `browser_envs` names, none for `[]`, else the run's when it has websites: an MCP server env such as `playwright-mcp` runs among the gateway's MCP servers, and a browser computer such as `chromium` on a sandbox of its own as one more computer child, sized by its `child_envs` entry, its tools under its name and `_`, routing the multi env's synthetic sites unless that entry's `site_routing` says, its network held to the gateway's host and the live sites' hosts (`reach_only`) where the provider enforces an allowlist, else allowing every host, as logged; a website env deployed alone refuses a browser computer as the run's browser), `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; its env.toml's `ready_timeout_s`, how long it may take from its server's container starting to ready, is that container's health check `start_period` and what the deploy waits, or its card's `ready` timeout if longer, and its `shm_mb` is the container's `shm_size`, each left to the gateway and Docker without them; 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; a live one, `live = true`, is a real site whose server serves none of its pages, its `live`, `domains` and `allow_hosts` read at put from its server's `@website(...)` (`utils/card_naming.py` `WebsiteSource`) unless given, its `settings`' secrets only as references the gateway resolves as it writes the site's container (`WEBSITE_SETTINGS_ENV`); the gateway gives it no site route, a computer's site table leaves it out, a computer whose network is an allowlist is let reach its names (`MultiEnv.live_hosts`), a browser alone's forward port takes them straight to the real site (`site_proxy.py` `direct`), as the browser base's stand-in proxy takes any host, refusing nothing, each proxy logging each such connection as a JSON line, and a run that deploys one is marked live, `reproducible: false`), `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.]`, or for one distribution `[install."/"]`, which replaces its OS type's there, as its `known_failures` do (`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, its `/dev/shm` the most its apps' Linux installs ask for (`shm_mb`, in MiB, which its composed computer and the deploy record keep; `--shm-size` on a container, `AGENTENV_SHM_MB` read by a Linux disk's init, passed only to a provider whose `SHARED_MEMORY` says it gives that much, else the sandbox's default, which the deploy logs), 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; `@linux(distribution=...)` or `@install("/", spec)` for one distribution, which replaces its OS type's there) 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/browsers/README.md b/src/agent_env/browsers/README.md index b9b9ed31c..10add9dae 100644 --- a/src/agent_env/browsers/README.md +++ b/src/agent_env/browsers/README.md @@ -19,7 +19,11 @@ alone only; `locale`; `time_zone`; and (`apps = [{ env = "chromium", settings = { start_urls = ["https://example.test/"] } }]`), and a deploy overrides any of them (`env_params.app_settings = {chromium = {start_urls = [...]}}`). Its data is its profile, `~/.config/chromium`, which the image has none of, so each deploy and each reset starts with a clean one. Name it from another bundle as `chromium`, -the alias `aliases.toml` gives it, or by its id, `@local/agentenv-framework/browsers/chromium`. +the alias `aliases.toml` gives it, or by its id, `@local/agentenv-framework/browsers/chromium`. A multi env names it as +its website browser with `browser_envs = ["chromium"]`, in place of the run's `playwright-mcp`: it then deploys on a +sandbox of its own beside the multi env's computers, sized by the deploy step's `child_envs.chromium`, its tools served +under `chromium_`, routing the multi env's synthetic sites through the gateway, and its network allowing the gateway's +host and the live sites' hosts alone where the sandbox provider enforces an allowlist. A task changes a browser's state with the steps in `agent_env.task_step.task_steps.browser`, each a call to one `browser/v1` operation on the browser in the env `env_id`, the one `browser` names when the env has several: diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 8bde4f3a3..948aeee16 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -920,6 +920,8 @@ def _check_multi_names(self, closure: set[_Key], store_latest: dict[tuple[Entity if name is None: continue group = ref.where.partition("[")[0] + if group == "browser_envs": # a browser computer runs as a computer, any other browser as an MCP server + group = "computer_envs" if self._is_computer(ref, store_latest) else "mcp_server_envs" named.setdefault(group, set()).add(name) if group == "computer_envs": computers.append(name) @@ -1028,8 +1030,10 @@ def _server_names(self, ref: Reference, store_latest: dict[tuple[EntityKind, str for child in children: if child.kind is not EntityKind.ENV: continue - held = (self._server_names(child, store_latest) if child.where.startswith("computer_envs[") else - {self._environment_name(child, store_latest)}) + computer = child.where.startswith("computer_envs[") or ( + child.where.startswith("browser_envs[") and self._is_computer(child, store_latest)) + held = (self._server_names(child, store_latest) if computer + else {self._environment_name(child, store_latest)}) names |= held if held is not None else {None} else: names = {self.accepted.get(_key(ref.local), {}).get("environment_name")} @@ -1044,8 +1048,10 @@ def _server_names(self, ref: Reference, store_latest: dict[tuple[EntityKind, str except (NotFoundError, ValueError, KeyError, TypeError): 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]), - *(name for computer in env.computer_envs + browsers = env.browser_envs or [] + return {*(child.environment_name for child in [*env.mcp_server_envs, *env.website_envs, + *(b for b in browsers if not isinstance(b, ComputerEnv))]), + *(name for computer in [*env.computer_envs, *(b for b in browsers if isinstance(b, ComputerEnv))] for name in (computer.environment_name, *(app.environment_name for app in computer.composed_apps())))} if isinstance(env, ComputerEnv): return {env.environment_name, *(app.environment_name for app in env.composed_apps())} @@ -1081,6 +1087,13 @@ def _domains(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], in return list(self.accepted.get(_key(ref.local), {}).get("domains") or []) return list(getattr(self._store_entity(ref, store_latest), "domains", None) or []) + def _is_computer(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int] | None) -> bool: + """Whether the env ``ref`` names is a computer env, the bundle's own by its type, a store one's as stored.""" + if isinstance(ref.local, BundleEntry): + cls = get_env_registry().get(ref.local.type) + return cls is not None and issubclass(cls, ComputerEnv) + return isinstance(self._store_entity(ref, store_latest), ComputerEnv) + def _store_entity(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int] | None) -> Any: """The store's entity ``ref`` names at the version the plan read, or None when that's unknown.""" if store_latest is None: diff --git a/src/agent_env/cli/env/multi.py b/src/agent_env/cli/env/multi.py index 489ee1baa..e17fc91e3 100644 --- a/src/agent_env/cli/env/multi.py +++ b/src/agent_env/cli/env/multi.py @@ -29,22 +29,32 @@ def multi(): @click.option("--computer", "computers", multiple=True, help="ComputerEnv id[:version], deployed on a sandbox of its own; its tools are served under its " "environment_name and '_'") +@click.option("--browser", "browsers", multiple=True, + help="Website browser env id[:version]: an MCP server env serving one, such as playwright-mcp, or a " + "computer env with a website browser app, such as chromium, deployed on a sandbox of its own " + "(default: the run's website_browser_env_id when there are websites)") +@click.option("--no-browser", is_flag=True, default=False, help="Deploy no website browser, even with websites") @click.option("--metadata", "metadata_pairs", multiple=True, help="Metadata key=value pair (repeatable)") @click.option("--name", default=None, help="Name agents see this env's MCP server under, e.g. crm -> mcp__crm__ (default: env + 4 random digits per deploy)") @env_provider_type_option("What deploys the env: 'gateway' (a gateway in front of its servers and websites, whatever their own " "env_provider_type), or the type of an installed agent_env.env_providers plugin, which deploys them all", "multi") @click.option("--validate", "run_validation", is_flag=True, default=False, help="Validate the environment after registering it") def put(env_id: str, mcp_servers: tuple[str, ...], websites: tuple[str, ...], computers: tuple[str, ...], - metadata_pairs: tuple[str, ...], name: str | None, env_provider_type: str, run_validation: bool): + browsers: tuple[str, ...], no_browser: bool, metadata_pairs: tuple[str, ...], name: str | None, + env_provider_type: str, run_validation: bool): """Create a MultiEnv from MCPServerEnv, WebsiteEnv and ComputerEnv ids.""" if not mcp_servers and not websites and not computers: click.echo("Error: At least one of --mcp-server, --website or --computer must be provided.", err=True) raise SystemExit(1) + if browsers and no_browser: + click.echo("Error: --browser and --no-browser can't both be given.", err=True) + raise SystemExit(1) mcp_server_envs = _fetch(mcp_servers, "mcp_server", "MCPServerEnv") website_envs = _fetch(websites, "website", "WebsiteEnv") computer_envs = _fetch(computers, COMPUTER_ENV_TYPE, "ComputerEnv") + browser_envs = [] if no_browser else _fetch(browsers, None, "website browser env") if browsers else None user_metadata = {} for pair in metadata_pairs: @@ -57,7 +67,8 @@ def put(env_id: str, mcp_servers: tuple[str, ...], websites: tuple[str, ...], co try: refusal = MultiEnv(id=env_id, version=None, mcp_server_envs=mcp_server_envs, website_envs=website_envs, name=name, - env_provider_type=env_provider_type, computer_envs=computer_envs).save_refusal() + env_provider_type=env_provider_type, computer_envs=computer_envs, + browser_envs=browser_envs).save_refusal() except ValueError as e: refusal = str(e) if refusal: @@ -65,7 +76,8 @@ def put(env_id: str, mcp_servers: tuple[str, ...], websites: tuple[str, ...], co raise SystemExit(1) click.echo("Creating MultiEnv...") multi_env = MultiEnv.put(id=env_id, mcp_server_envs=mcp_server_envs, website_envs=website_envs, metadata=metadata if metadata else None, name=name, - env_provider_type=env_provider_type, computer_envs=computer_envs) + env_provider_type=env_provider_type, computer_envs=computer_envs, + browser_envs=browser_envs) click.echo(f"Created MultiEnv: id={multi_env.id} version={multi_env.version} env_provider_type={multi_env.env_provider_type}") if run_validation: click.echo("\nValidating environment...") @@ -73,14 +85,14 @@ def put(env_id: str, mcp_servers: tuple[str, ...], websites: tuple[str, ...], co click.echo(f"Validation task: {instance_id}") -def _fetch(refs: tuple[str, ...], env_type: str, label: str) -> list[Env]: - """The env each of ``refs`` (id[:version]) names, exiting unless each is an ``env_type`` env.""" +def _fetch(refs: tuple[str, ...], env_type: str | None, label: str) -> list[Env]: + """The env each of ``refs`` (id[:version]) names, exiting unless each is an ``env_type`` env, when one is named.""" envs = [] for ref in refs: env_id, version = parse_env_ref(ref) click.echo(f"Fetching {label}: id={env_id} version={version or 'latest'}...") env = Env.get(env_id, version=version) - if env.type != env_type: + if env_type is not None and env.type != env_type: click.echo(f"Error: {env_id} is type '{env.type}', expected '{env_type}'", err=True) raise SystemExit(1) envs.append(env) diff --git a/src/agent_env/env/envs/_multi_computers.py b/src/agent_env/env/envs/_multi_computers.py index d956c9f6d..38e88ebd3 100644 --- a/src/agent_env/env/envs/_multi_computers.py +++ b/src/agent_env/env/envs/_multi_computers.py @@ -117,14 +117,16 @@ def refusal(computer: ComputerEnv, entry: dict, sandbox_type: str | None, websit class Computers: - """A multi env's computers, and those of each deployment a later deploy replaced, which ``close`` ends too. + """A multi env's computers, its website browser when that is a computer, and those of each deployment a later + deploy replaced, which ``close`` ends too. A deploy's computers are its own, as its provider and sandbox are (``deploy_through_provider``): ``begin`` gives it fresh ones when these already have a deployment and sets the earlier ones aside, so its failure closes only what it made and ``rollback`` puts them back; ``commit`` keeps them for ``close``.""" def __init__(self, envs: list[ComputerEnv], owner: str) -> None: - self.envs = envs + self.envs = envs # the multi env's own, as it stores them + self.browser: Optional[ComputerEnv] = None # the deploy's website browser, a computer of its own self._owner = owner # the multi env's id, for what's logged self._routes: dict[str, Optional[list[str]]] = {} # each computer routing sites: the names it routes, or all self._entries: dict = {} # the deploy's child_envs entries, by computer @@ -132,27 +134,36 @@ def __init__(self, envs: list[ComputerEnv], owner: str) -> None: self._replaced: list[ComputerEnv] = [] self._earlier: Optional[tuple[list[ComputerEnv], list[ComputerEnv]]] = None - def begin(self) -> None: - self._earlier = (self.envs, self._replaced) + @property + def every(self) -> list[ComputerEnv]: + """Each computer a deploy deploys: the multi env's own, then its website browser.""" + return [*self.envs, *([self.browser] if self.browser is not None else [])] + + def begin(self, browser: Optional[ComputerEnv] = None) -> None: + """Start a deploy of the multi env's computers and ``browser``, its website browser when that is a computer.""" + self._earlier = (self.envs, self.browser, self._replaced) if any(env._deployed is not None for env in self.envs): self.envs = [Env.get(env.id, env.version) for env in self.envs] + self.browser = Env.get(browser.id, browser.version) if browser is not None and browser._deployed else browser self._replaced = [] def rollback(self) -> None: - (self.envs, self._replaced), self._earlier = self._earlier, None + (self.envs, self.browser, self._replaced), self._earlier = self._earlier, None def commit(self) -> None: - (earlier, replaced), self._earlier = self._earlier, None - self._replaced = [*replaced, *(earlier if self.envs is not earlier else [])] + (envs, browser, replaced), self._earlier = self._earlier, None + kept = {id(env) for env in self.every} + self._replaced = [*replaced, *(env for env in [*envs, *([browser] if browser else [])] + if env._deployed is not None and id(env) not in kept)] def holding(self, environment_name: str) -> Optional[ComputerEnv]: """The computer a load of ``environment_name`` reaches: the one of that name, or with an app of it.""" - return next((env for env in self.envs if env.holds(environment_name)), None) + return next((env for env in self.every if env.holds(environment_name)), None) async def load(self, environments: list[EnvironmentArtifact]) -> None: """Load each computer's of ``environments`` into it, in its own order, the computers at once (``each``).""" await each(self._owner, "load", [(f"computer {env.environment_name!r}", env.load_environments(held)) - for env in self.envs + for env in self.every if (held := [e for e in environments if env.holds(e.environment_name)])]) def resets(self, deployed: DeployedEnv) -> list[tuple[str, Awaitable[None]]]: @@ -160,14 +171,15 @@ def resets(self, deployed: DeployedEnv) -> list[tuple[str, Awaitable[None]]]: resets alone; none is awaited yet.""" records = getattr(deployed, "child_envs", None) or {} return [(env.environment_name, env.reset(DeployedEnv.from_dict(records[env.environment_name]))) - for env in self.envs if env.environment_name in records] + for env in self.every if env.environment_name in records] def deployer(self, child_envs: Optional[dict], *, sandbox_type: str | None, ttl_seconds: int, attribution: Optional[Attribution], websites: Mapping[str, str], live_hosts: Iterable[str] = () ) -> Callable[[Optional[str]], Awaitable[list[ExternalChild]]]: """The gateway's ``children``: every computer deployed as its child_envs entry says, its tools served under its name and ``_``, and its network allowing ``reach``, the gateway's address when the gateway has one yet, and the - ``live_hosts`` of the multi env's live websites; then each as the gateway fronts it. A computer routing the multi env's ``websites`` (their environment names by env id) + ``live_hosts`` of the multi env's live websites, the website browser's those hosts alone; then each as the + gateway fronts it. A computer routing the multi env's ``websites`` (their environment names by env id) boots with its boot apps held, for ``finish`` to route them once the gateway is up. A gateway deploy tried again deploys them again, after closing the earlier try's.""" tries = 0 @@ -186,15 +198,15 @@ async def deploy(reach: Optional[str]) -> list[ExternalChild]: results = await asyncio.gather(*(env.deploy( ttl_seconds=ttl_seconds, attribution=attribution, tool_prefix=f"{env.environment_name}_", reach=reach or "", sites_later=env.environment_name in self._routes, - **({"live_hosts": live} if live else {}), + **({"live_hosts": live} if live else {}), **({"reach_only": True} if env is self.browser else {}), **deploy_options((child_envs or {}).get(env.environment_name), sandbox_type)) - for env in self.envs), return_exceptions=True) + for env in self.every), return_exceptions=True) if failed := [result for result in results if isinstance(result, BaseException)]: for error in failed[1:]: logger.warning(f"multi env '{self._owner}': another computer failed to deploy too: {error!r}") raise failed[0] return [ExternalChild(env.environment_name, record.mcp_url, record.environment_url) - for env, record in zip(self.envs, results)] + for env, record in zip(self.every, results)] return deploy async def finish(self, deployed: DeployedEnv) -> None: @@ -205,7 +217,7 @@ async def finish(self, deployed: DeployedEnv) -> None: if self._routes: # Selected by the name each website is served as, its environment_name, which its card's name needn't be. routed = {name: await sites.deployed_sites([deployed], services=only) for name, only in self._routes.items()} - if problems := [f"computer {env.environment_name!r}: {problem}" for env in self.envs + if problems := [f"computer {env.environment_name!r}: {problem}" for env in self.every if env.environment_name in routed for problem in start_tab_problems( env, self._entries.get(env.environment_name), [ *(name for site in routed[env.environment_name] @@ -215,7 +227,7 @@ async def finish(self, deployed: DeployedEnv) -> None: await sites.require_ready([deployed]) await each(self._owner, "route its sites", [(f"computer {env.environment_name!r}", env.route_sites(routed[env.environment_name])) - for env in self.envs if env.environment_name in routed]) + for env in self.every if env.environment_name in routed]) self.record(deployed) def record(self, deployed: DeployedEnv) -> None: @@ -225,23 +237,28 @@ def record(self, deployed: DeployedEnv) -> None: if not isinstance(deployed, DeployedGatewayEnv): raise TypeError(f"multi env '{self._owner}': its computers are recorded in a gateway's record, not a " f"{type(deployed).__name__}") - records = {env.environment_name: env._deployed for env in self.envs} + records = {env.environment_name: env._deployed for env in self.every} deployed.child_envs = {name: dataclasses.asdict(record) for name, record in records.items()} for name, record in records.items(): deployed.sandbox_ids.setdefault(record.sandbox_type, {})[name] = record.sandbox_id async def reattach(self, deployed: DeployedEnv) -> None: - """Each computer ``deployed``, the multi env's record, holds, reattached from its own record.""" + """Each computer ``deployed``, the multi env's record, holds, reattached from its own record: the multi env's + own, and its website browser, the record of a name none of them has.""" records = getattr(deployed, "child_envs", None) or {} reattached = await asyncio.gather(*(type(env).from_deployed_env(DeployedEnv.from_dict(records[env.environment_name])) for env in self.envs if env.environment_name in records)) by_name = {env.environment_name: env for env in reattached} self.envs = [by_name.get(env.environment_name, env) for env in self.envs] + own = {env.environment_name for env in self.envs} + if browser := next((record for name, record in records.items() if name not in own), None): + record = DeployedEnv.from_dict(browser) + self.browser = await type(Env.get(record.env_id, record.env_version)).from_deployed_env(record) async def close(self) -> None: """Close each computer, and those of the deployments a later deploy replaced, their recordings ended first; one that fails to close is logged.""" - envs = [*self._replaced, *self.envs] + envs = [*self._replaced, *self.every] results = await asyncio.gather(*(env.close() for env in envs), return_exceptions=True) for env, result in zip(envs, results): if isinstance(result, Exception): diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index cc4bf169e..a3bc3c693 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -287,17 +287,19 @@ async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, *, attribution: Optional[Attribution] = None, env_params: Optional[dict] = None, tool_prefix: str = "", reach: str = "", - sites_later: bool = False, live_hosts: tuple[str, ...] = ()) -> DeployedEnv: + sites_later: bool = False, live_hosts: tuple[str, ...] = (), + reach_only: bool = False) -> DeployedEnv: """With ``tool_prefix``, its driver serves its tools under it, with ``reach``, a network that restricts its - egress allows that URL's host too, and the ``live_hosts`` of the run's live websites, and with ``sites_later`` - it routes the sites ``env_params.site_routing`` reaches when ``route_sites`` gives them: what a multi env gives - each of its computers.""" + egress allows that URL's host too, and the ``live_hosts`` of the run's live websites, with ``reach_only`` a + network that allows every host is held to those hosts, and with ``sites_later`` it routes the sites + ``env_params.site_routing`` reaches when ``route_sites`` gives them: what a multi env gives each of its + computers, and its website browser.""" return await deploy_through_provider( self, environment_name=self.environment_name, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type, then=self._give_tools, disk_size_gb=disk_size_gb, gateway_mode=gateway_mode, cpu=cpu, memory_mb=memory_mb, env_state_type=env_state_type, env_state_instance_id=env_state_instance_id, attribution=attribution, env_params=env_params, tool_prefix=tool_prefix, reach=reach, sites_later=sites_later, - live_hosts=tuple(live_hosts)) + live_hosts=tuple(live_hosts), reach_only=reach_only) async def route_sites(self, sites: list[dict]) -> None: """Route ``sites`` on this computer, deployed with ``sites_later``, launch its held apps and finish its boot; diff --git a/src/agent_env/env/envs/multi_env.py b/src/agent_env/env/envs/multi_env.py index 1b8a0bbb5..058446c88 100644 --- a/src/agent_env/env/envs/multi_env.py +++ b/src/agent_env/env/envs/multi_env.py @@ -35,6 +35,7 @@ def _snapshot_after_load_default() -> bool: from agent_env.env.env import LoadEnvironmentUniverseArtifactResult, LoadFileArtifactUniverseResult from agent_env.providers.env_state import DatabaseStateProvider +from agent_env.config import get_config, website_browser_env_id from agent_env.entity.refs import EntityRef from agent_env.env.env import DeployedEnv, Env, gateway_url_of from agent_env.env.gateway import GatewayMode @@ -51,6 +52,7 @@ def _snapshot_after_load_default() -> bool: from agent_env.env.envs.service_db import DATABASE_SERVICE_NAME from agent_env.providers.env_providers.constants import DOCKER_COMPOSE_PATH from agent_env.providers.env_state.local_postgres import INIT_SCRIPT_MOUNT_PATH +from agent_env.store.base import NotFoundError from agent_env.store.ids import derive_id from agent_env.store.image_store.oci_registry_credentials import image_reference_problem from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_CONTAINER @@ -61,6 +63,26 @@ def _snapshot_after_load_default() -> bool: # A child_envs entry's options: a deploy_env step's for that env alone, less the multi env's (lifetime, gateway, state). CHILD_ENV_OPTIONS = ("sandbox_type", "cpu", "memory_mb", "disk_size_gb", "env_params") + + +def browser_problems(envs: list[Env]) -> list[str]: + """Why ``envs``, a multi env's ``browser_envs``, can't be its website browsers: more than one, as a multi env runs + one, or an env that isn't a website browser env, an MCP server env serving one as ``playwright-mcp`` does or a + computer env with a website browser app, as ``chromium`` is.""" + problems = [] + if len(envs) > 1: + problems.append(f"browser_envs names {len(envs)} envs, and a multi env runs one website browser") + computer = get_env_registry()[COMPUTER_ENV_TYPE] + for i, env in enumerate(envs): + if isinstance(env, computer) and not env.serves_website_browser(): + problems.append(f"browser_envs[{i}]: computer env {env.id!r} has no website browser among its apps") + elif not isinstance(env, (MCPServerEnv, computer)): + problems.append(f"browser_envs[{i}]: {env.id!r} is a {env.type} env, not a website browser env: an MCP " + "server env serving one, as playwright-mcp is, or a computer env with a website browser " + "app, as chromium is") + return problems + + # The servicedb's image line in a deploy's compose file, as the local Postgres state renders it. _SERVICE_DB_IMAGE = re.compile(rf"^( {re.escape(DATABASE_SERVICE_NAME)}:\n image: ).*$", re.MULTILINE) @@ -83,12 +105,13 @@ class MultiEnv(Env): "provider its env_provider_type names") toml_refs: ClassVar[tuple[EntityRef, ...]] = (EntityRef.env("mcp_server_envs[]", env_type=MCPServerEnv.type), EntityRef.env("website_envs[]", env_type=WebsiteEnv.type), - EntityRef.env("computer_envs[]", env_type=COMPUTER_ENV_TYPE)) + EntityRef.env("computer_envs[]", env_type=COMPUTER_ENV_TYPE), + EntityRef.env("browser_envs[]")) toml_keys: ClassVar[dict[str, type]] = {"mcp_server_envs": list, "website_envs": list, "computer_envs": list, - "name": str, "env_provider_type": str} + "browser_envs": list, "name": str, "env_provider_type": str} def __init__(self, id: str, version: Optional[int], mcp_server_envs: list[MCPServerEnv], website_envs: list[WebsiteEnv] | None = None, metadata: Optional[dict[str, str]] = None, name: Optional[str] = None, env_provider_type: str = "gateway", - computer_envs: list[ComputerEnv] | None = None): + computer_envs: list[ComputerEnv] | None = None, browser_envs: list[Env] | None = None): super().__init__(id, version, metadata=metadata) if name is not None and (not name or any(ch.isspace() for ch in name)): raise ValueError("name must be non-empty with no whitespace") @@ -105,14 +128,12 @@ def __init__(self, id: str, version: Optional[int], mcp_server_envs: list[MCPSer for name in dict.fromkeys(env.domains)]): raise sites.SiteConflict(f"multi env {id!r}: its websites claim names twice: {'; '.join(conflicts)}") self._computers = _multi_computers.Computers(computer_envs or [], owner=id) - servers = [env.environment_name for env in (*self.mcp_server_envs, *self.website_envs)] - computers = [env.environment_name for env in self.computer_envs] - if problems := [*_multi_computers.name_problems(computers, - mcp_servers=[env.environment_name for env in self.mcp_server_envs], - websites=[env.environment_name for env in self.website_envs]), - *_multi_computers.app_name_problems({env.environment_name: [app.environment_name - for app in env.composed_apps()] - for env in self.computer_envs}, [*servers, *computers])]: + # None leaves the browser to the run, as for every env with websites; [] is none. + self.browser_envs = list(browser_envs) if browser_envs is not None else None + problems = browser_problems(self.browser_envs or []) + if not problems: + problems = self._name_problems(self._browser_computer(self.browser_envs[0] if self.browser_envs else None)) + if problems: raise ValueError(f"multi env {id!r}: {'; '.join(problems)}") self._sandbox = None self._gateway_url = None @@ -127,6 +148,43 @@ def computer_envs(self) -> list[ComputerEnv]: """Its computers, each on a sandbox of its own, which the gateway fronts by its name: after a deploy, that deploy's.""" return self._computers.envs + def _name_problems(self, browser: Optional[ComputerEnv]) -> list[str]: + """What's wrong with the names of its computers, and ``browser``, its website browser when that is a computer, + beside its other child envs' (``_multi_computers.name_problems``, ``app_name_problems``).""" + computer_envs = [*self.computer_envs, *([browser] if browser is not None else [])] + servers = [env.environment_name for env in (*self.mcp_server_envs, *self.website_envs)] + computers = [env.environment_name for env in computer_envs] + return [*_multi_computers.name_problems(computers, + mcp_servers=[env.environment_name for env in self.mcp_server_envs], + websites=[env.environment_name for env in self.website_envs]), + *_multi_computers.app_name_problems({env.environment_name: [app.environment_name + for app in env.composed_apps() + if app is not env] # an app deployed alone + for env in computer_envs}, [*servers, *computers])] + + def browser_env(self) -> Optional[Env]: + """The website browser a deploy adds: the one ``browser_envs`` names, none when it names none, and without it + the run's (``website_browser_env_id``) when it has websites, as every env with websites gets. The run's is None + while the store doesn't hold it yet: an infra env a run may set up later, which the gateway looks up, as it + always has.""" + if self.browser_envs is not None: + return self.browser_envs[0] if self.browser_envs else None + if not self.website_envs: + return None + try: + return Env.get(website_browser_env_id(get_config())) + except NotFoundError: + return None + + def browser_computer(self) -> Optional[ComputerEnv]: + """Its website browser (``browser_env``) when that is a computer, which runs on a sandbox of its own beside its + computers rather than among the gateway's MCP servers.""" + return self._browser_computer(self.browser_env()) + + @staticmethod + def _browser_computer(browser: Optional[Env]) -> Optional[ComputerEnv]: + return browser if isinstance(browser, get_env_registry()[COMPUTER_ENV_TYPE]) else None + def to_dict(self) -> dict: base = super().to_dict() base["mcp_server_envs"] = [ @@ -139,6 +197,9 @@ def to_dict(self) -> dict: ] if self.computer_envs: base["computer_envs"] = [{"id": env.id, "type": env.type, "version": env.version} for env in self.computer_envs] + if self.browser_envs is not None: + base["browser_envs"] = [{"id": env.id, "type": env.type, "version": env.version} + for env in self.browser_envs] if self.name: base["name"] = self.name base["env_provider_type"] = self.env_provider_type @@ -155,18 +216,24 @@ def from_dict(cls, data: dict) -> "MultiEnv": for ref in data.get("website_envs", []) ] computer_envs = [Env.get(ref["id"], version=ref["version"]) for ref in data.get("computer_envs", [])] + browser_envs = ([Env.get(ref["id"], version=ref["version"]) for ref in data["browser_envs"]] + if data.get("browser_envs") is not None else None) return cls(id=data["id"], version=data.get("version"), mcp_server_envs=mcp_server_envs, website_envs=website_envs, metadata=data.get("metadata", {}), - name=data.get("name"), env_provider_type=data.get("env_provider_type", "gateway"), computer_envs=computer_envs) + name=data.get("name"), env_provider_type=data.get("env_provider_type", "gateway"), + computer_envs=computer_envs, browser_envs=browser_envs) @classmethod def from_toml(cls, data: dict, ctx: AuthoringContext) -> MultiEnv: - """Write the env authored as ``data`` (its env.toml, with ``mcp_server_envs``, ``website_envs`` and - ``computer_envs`` resolved to env ids) under ``ctx.id`` and return it, unless saving it would be refused.""" + """Write the env authored as ``data`` (its env.toml, with ``mcp_server_envs``, ``website_envs``, + ``computer_envs`` and ``browser_envs`` resolved to env ids) under ``ctx.id`` and return it, unless saving it + would be refused.""" fields = cls.accept_toml(data, ctx) computer = get_env_registry()[COMPUTER_ENV_TYPE] env = dict(mcp_server_envs=[ctx.env(ref, MCPServerEnv) for ref in fields.get("mcp_server_envs", [])], website_envs=[ctx.env(ref, WebsiteEnv) for ref in fields.get("website_envs", [])], computer_envs=[ctx.env(ref, computer) for ref in fields.get("computer_envs", [])], + browser_envs=([ctx.env(ref) for ref in fields["browser_envs"]] if "browser_envs" in fields + else None), name=fields.get("name"), env_provider_type=fields["env_provider_type"]) try: refusal = cls(id=ctx.id, version=None, **env).save_refusal() @@ -183,6 +250,8 @@ def to_toml(self, out: ExportContext) -> dict: toml["website_envs"] = [out.ref(env) for env in self.website_envs] if self.computer_envs: toml["computer_envs"] = [out.ref(env) for env in self.computer_envs] + if self.browser_envs is not None: + toml["browser_envs"] = [out.ref(env) for env in self.browser_envs] if self.name: toml["name"] = self.name return {**toml, **self.provider_toml(out, self.env_provider_type)} @@ -203,13 +272,15 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None, child_envs: Optional[dict] = None) -> DeployedEnv: - if refusal := self.child_envs_refusal(child_envs, sandbox_type=sandbox_type): + browser = self.browser_env() + computer = self._browser_computer(browser) + if refusal := self._child_envs_refusal(child_envs, sandbox_type, browser): raise ValueError(refusal) - self._computers.begin() - computers = dict(children=self._computers.deployer(child_envs, sandbox_type=sandbox_type, ttl_seconds=ttl_seconds, - attribution=attribution, websites=self._websites(), - live_hosts=self.live_hosts()), - then=self._computers.finish) if self.computer_envs else {} + self._computers.begin(computer) + deployer = self._computers.deployer(self._entries(child_envs, computer), sandbox_type=sandbox_type, + ttl_seconds=ttl_seconds, attribution=attribution, websites=self._websites(), + live_hosts=self.live_hosts()) + computers = dict(children=deployer, then=self._computers.finish) if self._computers.every else {} try: deployed_env = await deploy_through_provider( self, environment_name=None, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type, @@ -227,6 +298,21 @@ async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gatew _hand_record_to_child_envs(self, deployed_env) return deployed_env + def _entries(self, child_envs: Optional[dict], browser: Optional[ComputerEnv]) -> Optional[dict]: + """``child_envs``, with ``browser``, its website browser when that is a computer, routing all its synthetic + websites unless its entry's ``env_params`` say whether it routes them (``site_routing``), as the browser an MCP + server runs reaches them all.""" + synthetic = any(not env.live for env in self.website_envs) + if browser is None or not synthetic or not isinstance(child_envs, (dict, type(None))): + return child_envs + entry = (child_envs or {}).get(browser.environment_name) + params = entry.get("env_params") if isinstance(entry, dict) else None + if not isinstance(entry, (dict, type(None))) or not isinstance(params, (dict, type(None))) or \ + "site_routing" in (params or {}): + return child_envs + routing = {**(entry or {}), "env_params": {**(params or {}), "site_routing": True}} + return {**(child_envs or {}), browser.environment_name: routing} + def _websites(self) -> dict[str, str]: """Its websites' environment names by env id, the ids a computer's ``site_routing`` may name.""" return {env.id: env.environment_name for env in self.website_envs} @@ -277,15 +363,25 @@ def deploy_refusal(self, *, sandbox_type: str | None = None, child_envs: Optiona or self.child_envs_refusal(child_envs, sandbox_type=sandbox_type)) def child_envs_refusal(self, child_envs: Optional[dict], *, sandbox_type: str | None = None) -> str | None: - """Why a deploy would refuse ``child_envs``, each child env's options by its environment name, or None. A computer - is checked as its entry, or no entry, would deploy it on a sandbox of its own, ``sandbox_type`` (the multi env's) - unless the entry names one; any other child env runs on the multi env's sandbox and takes no entry.""" + """Why a deploy would refuse ``child_envs``, each child env's options by its environment name, or None. A + computer, and its website browser when that is one (``browser_env``), is checked as its entry, or no entry, + would deploy it on a sandbox of its own, ``sandbox_type`` (the multi env's) unless the entry names one, the + browser routing its synthetic websites unless its entry says whether it does; any other child env runs on the + multi env's sandbox and takes no entry.""" + return self._child_envs_refusal(child_envs, sandbox_type, self.browser_env()) + + def _child_envs_refusal(self, child_envs: Optional[dict], sandbox_type: str | None, + browser: Optional[Env]) -> str | None: if child_envs and not isinstance(child_envs, dict): return f"child_envs is a table of child env names, not {type(child_envs).__name__}" + computer = self._browser_computer(browser) + entries = self._entries(child_envs, computer) or {} child_envs = child_envs or {} - computers = {env.environment_name: env for env in self.computer_envs} - names = list(dict.fromkeys(env.environment_name for env in (*self.mcp_server_envs, *self.website_envs, *self.computer_envs))) - problems = [] + computers = {env.environment_name: env for env in [*self.computer_envs, *([computer] if computer else [])]} + names = list(dict.fromkeys(env.environment_name for env in (*self.mcp_server_envs, *self.website_envs, + *computers.values()))) + # A browser browser_envs names had its names checked at put; the run's is checked here. + problems = self._name_problems(computer) if computer is not None and self.browser_envs is None else [] for name, entry in child_envs.items(): extra = sorted(map(str, set(entry) - set(CHILD_ENV_OPTIONS))) if isinstance(entry, dict) else [] if name not in names: @@ -297,20 +393,20 @@ def child_envs_refusal(self, child_envs: Optional[dict], *, sandbox_type: str | elif name not in computers: problems.append(f"child_envs.{name}: {name} runs on the multi env's own sandbox, which the step's own " "fields set, so it takes no entry") - for name, computer in computers.items(): - entry = child_envs.get(name) + for name, env in computers.items(): + entry = entries.get(name) if entry is None or (isinstance(entry, dict) and set(entry) <= set(CHILD_ENV_OPTIONS)): - if refusal := _multi_computers.refusal(computer, entry or {}, sandbox_type, self._websites()): + if refusal := _multi_computers.refusal(env, entry or {}, sandbox_type, self._websites()): problems.append(f"child_envs.{name}: {refusal}" if name in child_envs else refusal) if (names := self._start_tab_names(entry)) is not None: problems += [f"child_envs.{name}: {problem}" - for problem in _multi_computers.start_tab_problems(computer, entry, names)] + for problem in _multi_computers.start_tab_problems(env, entry, names)] return "; ".join(problems) or None @classmethod async def from_deployed_env(cls, deployed: DeployedEnv) -> MultiEnv: env = await cls._attached(deployed) - if env.computer_envs: + if getattr(deployed, "child_envs", None): await env._computers.reattach(deployed) return env @@ -512,7 +608,7 @@ async def _load_one(idx: int, artifact): for i, name, e in failed ) ) - if self.computer_envs: + if self._computers.every: # Every time, never skipped as a resumed service is: the changelog a resume reads doesn't see a computer's # files. await self._computers.load(environments if environments is not None 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 1322c06ba..86efbeb67 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -43,7 +43,7 @@ ) from agent_env.env.gateway.constants import WELL_KNOWN_PATH from agent_env.providers.env_providers.env_provider import DeployRefused, _SandboxEnvironmentProvider -from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy +from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy from agent_env.providers.sandbox_providers.sandbox_provider import build_sandbox_provider from agent_env.utils.progress import current_progress @@ -84,12 +84,14 @@ async def deploy( reach: str = "", sites_later: bool = False, live_hosts: tuple[str, ...] = (), + reach_only: bool = False, ) -> DeployedSandboxEnv: """The env's first ready build, in its base's order of forms and arches (or of ``env_params.form``), that the sandbox provider runs natively, booted, once what it boots that no compose started is composed; returns the record once the driver is ready with the env's display. With ``tool_prefix``, its driver serves its tools under it; with ``reach``, a URL this process reaches, a network that restricts the computer's egress allows its host too, and the - ``live_hosts`` of the run's live websites. With + ``live_hosts`` of the run's live websites; with ``reach_only``, a network that allows every host is held to + those hosts alone, where the provider can enforce that. With ``sites_later``, a computer routing sites, whose sites aren't up yet, boots with its boot apps held and returns once its driver answers; ``route_sites`` routes them and finishes its boot.""" from agent_env.env.envs.computer.env import ComputerEnv @@ -124,7 +126,7 @@ async def deploy( attempt = functools.partial(self._boot, env, builds, params, values, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, attribution=attribution, tool_prefix=tool_prefix, reach=reach, sites_later=sites_later, - live_hosts=tuple(live_hosts)) + live_hosts=tuple(live_hosts), reach_only=reach_only) return await self._run(sandbox_provider, env.id, attempt) async def route_sites(self, env: ComputerEnv, record: DeployedSandboxEnv, sites: list[dict]) -> None: @@ -146,7 +148,8 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par values: dict, sandbox_provider: SandboxProvider, *, ttl_seconds: int, disk_size_gb: float, cpu: float | None, memory_mb: int | None, attribution: Optional[Attribution], tool_prefix: str = "", reach: str = "", - sites_later: bool = False, live_hosts: tuple[str, ...] = ()) -> DeployedSandboxEnv: + sites_later: bool = False, live_hosts: tuple[str, ...] = (), + reach_only: bool = False) -> DeployedSandboxEnv: """One attempt on one sandbox provider: the first build it runs natively, booted and readied; its record.""" from agent_env.env.envs.computer.env import ComputerEnv @@ -171,8 +174,7 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par values = _values(computer, values) app_settings = recorded_app_settings(env.composed_apps(), computer_params.app_settings(env, params.app_settings), platform_of(env.computer.os)) - policy = _reaching(NetworkPolicy.from_dict(env.network) if env.network else None, sandbox_provider, reach, - live_hosts) + policy = _network(env, sandbox_provider, reach, live_hosts, reach_only) shm_mb = env.shm_mb() booted = await boot( sandbox_provider, image=build["image"], port=env.driver.port, network_policy=policy, token=token, @@ -346,6 +348,26 @@ async def end_recordings(record: DeployedSandboxEnv, reason: str) -> None: logger.warning(f"'{record.env_id}': its recordings weren't ended before its computer went: {e}") +def _network(env: ComputerEnv, sandbox_provider: SandboxProvider, reach: str, live_hosts: tuple[str, ...], + reach_only: bool) -> Optional[NetworkPolicy]: + """The computer's network: its own, also allowing ``reach``'s host and ``live_hosts`` when it lists the hosts + allowed (``_reaching``), and with ``reach_only`` one that allows every host held to those hosts alone. A provider + that can't enforce that, or a gateway whose address isn't known yet, leaves it allowing every host, as logged.""" + own = NetworkPolicy.from_dict(env.network) if env.network else None + if not reach_only or (own is not None and own.restricts_egress): + return _reaching(own, sandbox_provider, reach, live_hosts) + if not (reach and urlsplit(sandbox_provider.url_from_sandbox(reach)).hostname): + logger.warning(f"{env.id}: the gateway's address isn't known when it boots on {type(sandbox_provider).__name__}" + ", so its network allows every host, not the gateway's alone") + return own + held = _reaching(NetworkPolicy(mode=NetworkMode.ALLOWLIST), sandbox_provider, reach, live_hosts) + if not sandbox_provider.supports_network_policy(held): + logger.warning(f"{env.id}: {type(sandbox_provider).__name__} can't hold a network to the hosts " + f"{', '.join(held.allow_hosts)}, so its network allows every host; use it for development only") + return own + return held + + def _reaching(policy: Optional[NetworkPolicy], sandbox_provider: SandboxProvider, url: str, live_hosts: tuple[str, ...] = ()) -> Optional[NetworkPolicy]: """``policy``, also allowing ``url``'s host as the sandbox provider's sandboxes reach it, and ``live_hosts``, when diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index 68de777ca..a98709ad5 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -82,6 +82,7 @@ from agent_env.artifact import DockerImageArtifact from agent_env.env.env import DeployedEnv, Env + from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.website import WebsiteEnv from agent_env.providers.env_state import DatabaseStateProvider, EnvStateInstance, LocalPostgresStoreSpec @@ -93,6 +94,8 @@ _ENV_NETWORK = "env-network" # The network the website browser shares with the gateway and the sites, and with nothing else in the env. _SITES_NETWORK = "website-network" +# A gateway deploy's website browser when its caller names none: the run's, for one with websites. +_THE_RUNS = object() # How much of a service's output a failed deploy's error carries: enough for the end of a traceback. _LOG_TAIL_LINES = 30 @@ -667,7 +670,7 @@ async def deploy( mcp_server_images=topology.mcp_server_images, website_configs=topology.website_configs, website_images=topology.website_images, mcp_server_name=topology.mcp_server_name, gateway_mode=gateway_mode, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, attribution=attribution, - children=children, + children=children, website_browser=topology.website_browser, control_token=new_control_token(), ) return DeployedGatewayEnv( @@ -791,10 +794,11 @@ async def _deploy_via_vm( sidecars: list[SidecarConfig] | None = None, mcp_server_name: str | None = None, children: "Callable[[Optional[str]], Awaitable[list[ExternalChild]]] | None" = None, + website_browser: "MCPServerEnv | None | object" = _THE_RUNS, control_token: str | None = None, ) -> DeployedGateway: from agent_env.env.env import Env - from agent_env.config import gateway_env_id, get_config, website_browser_env_id + from agent_env.config import gateway_env_id, get_config website_configs = website_configs or [] website_images = website_images or [] @@ -803,16 +807,10 @@ async def _deploy_via_vm( config = get_config() gateway_env = Env.get(gateway_env_id(config)) - # Auto-include the website browser's MCP server when websites are present - if website_configs: - website_browser_env = Env.get(website_browser_env_id(config)) - browser_server = MCPServerConfig( - image=website_browser_env.docker_image_artifact.image_name, - environment_name=website_browser_env.environment_name, - networks=(_SITES_NETWORK,), - ) - mcp_servers = list(mcp_servers) + [browser_server] - mcp_server_images = list(mcp_server_images) + [website_browser_env.docker_image_artifact] + if website_browser is _THE_RUNS: + website_browser = run_website_browser() if website_configs else None + if website_browser is not None: + mcp_servers, mcp_server_images = _with_browser(mcp_servers, mcp_server_images, website_browser) # Local Postgres also preloads its servicedb image set (servicedb + pgweb + db-mcp); an # external store has no co-deployed containers, and image pull dominates VM deploy latency. @@ -1446,9 +1444,11 @@ async def _deploy_gateway( control_token: str | None = None, attribution: Optional[Attribution] = None, children: "Callable[[Optional[str]], Awaitable[list[ExternalChild]]] | None" = None, + website_browser: "MCPServerEnv | None | object" = _THE_RUNS, ) -> DeployedGateway: """One gateway deploy on one (unchained) sandbox provider: a VM on one that creates VMs, else a container per - server on its private network.""" + server on its private network. ``website_browser`` runs among its MCP servers, on the network it shares with + the sites; by default the run's (``run_website_browser``) when there are websites, which only a VM runs.""" from agent_env.providers.env_state import LocalPostgresStateProvider, build_state_provider # Every attempt starts from the caller's store: a failed attempt's close() cleared it. @@ -1466,6 +1466,8 @@ async def _deploy_gateway( raise ValueError("existing_sandbox is only supported for VM-mode providers") if sidecars: raise NotImplementedError("sidecars are only supported for VM-mode gateway deploys") + if website_browser is not _THE_RUNS and website_browser is not None: + mcp_servers, mcp_server_images = _with_browser(mcp_servers, mcp_server_images, website_browser) return await self._deploy_via_containers( sandbox_provider, mcp_servers, mcp_server_images, gateway_port=gateway_port, website_configs=website_configs, gateway_mode=gateway_mode, @@ -1480,7 +1482,8 @@ async def _deploy_gateway( gateway_port=gateway_port, website_configs=website_configs, website_images=website_images, gateway_mode=gateway_mode, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, existing_sandbox=existing_sandbox, sidecars=sidecars, - mcp_server_name=mcp_server_name, children=children, control_token=control_token, **deploy_kwargs, + mcp_server_name=mcp_server_name, children=children, website_browser=website_browser, + control_token=control_token, **deploy_kwargs, ) except BaseException: await self._terminate_new_sandbox(earlier_sandbox=earlier_sandbox, existing_sandbox=existing_sandbox) @@ -1574,6 +1577,7 @@ class _GatewayTopology: website_images: list[DockerImageArtifact] | None mcp_server_name: str | None # a MultiEnv's name; None draws one names_gateway_on_vm: bool # a MultiEnv's record names its gateway sandbox even on a VM + website_browser: "MCPServerEnv | None | object" # the browser it runs among its MCP servers, or _THE_RUNS def website_config(env: WebsiteEnv) -> WebsiteConfig: @@ -1586,19 +1590,55 @@ def website_config(env: WebsiteEnv) -> WebsiteConfig: settings=dict(env.settings), ready_timeout_s=env.ready_timeout_s, shm_mb=env.shm_mb) +def run_website_browser() -> "MCPServerEnv": + """The run's website browser (``website_browser_env_id``), which a gateway with websites runs among its MCP servers + when its env names none: an MCP server env, as ``playwright-mcp`` is. A browser computer runs on a sandbox of its + own, which only a multi env gives it, so one named here is refused.""" + from agent_env.config import get_config, website_browser_env_id + from agent_env.env.env import Env + from agent_env.env.envs.computer import COMPUTER_ENV_TYPE + from agent_env.env.registry import get_env_registry + + browser = Env.get(website_browser_env_id(get_config())) + if isinstance(browser, get_env_registry()[COMPUTER_ENV_TYPE]): + raise ValueError(f"the website browser {browser.id!r} is a {browser.type} env, which runs on a sandbox of " + "its own beside a multi env's websites, not among a gateway's MCP servers: deploy the website " + "in a multi env, or name an MCP server env such as playwright-mcp as the browser") + return browser + + +def _with_browser(mcp_servers: list[MCPServerConfig], images: list[DockerImageArtifact], + browser: "MCPServerEnv") -> tuple[list[MCPServerConfig], list[DockerImageArtifact]]: + """``mcp_servers`` and their ``images`` with the website browser ``browser`` among them, on the network it shares + with the gateway and the sites.""" + server = MCPServerConfig(image=browser.docker_image_artifact.image_name, environment_name=browser.environment_name, + networks=(_SITES_NETWORK,)) + return [*mcp_servers, server], [*images, browser.docker_image_artifact] + + +def _named_browser_server(browser_envs: list[Env]) -> "MCPServerEnv | None": + """The website browser a multi env's ``browser_envs`` names, when that is an MCP server env its gateway runs.""" + from agent_env.env.envs.mcp_server import MCPServerEnv + + return next((env for env in browser_envs if isinstance(env, MCPServerEnv)), None) + + def _gateway_topology(env: Env) -> _GatewayTopology: """What a gateway fronts for env: a MultiEnv's MCP servers and websites (whatever their own env_provider_type) under its - name, a WebsiteEnv's website, or else the env's one MCP server.""" + name and the website browser it names or the run's, a WebsiteEnv's website and the run's browser, or else the env's + one MCP server.""" from agent_env.env.envs.multi_env import MultiEnv from agent_env.env.envs.website import WebsiteEnv is_multi = isinstance(env, MultiEnv) if is_multi: servers, websites, name = env.mcp_server_envs, env.website_envs, env.name + browser = _named_browser_server(env.browser_envs) if env.browser_envs is not None else ( + None if env.browser_computer() is not None else _THE_RUNS) elif isinstance(env, WebsiteEnv): - servers, websites, name = [], [env], None + servers, websites, name, browser = [], [env], None, _THE_RUNS else: - servers, websites, name = [env], [], None + servers, websites, name, browser = [env], [], None, None return _GatewayTopology( mcp_servers=[MCPServerConfig(image=s.docker_image_artifact.image_name, environment_name=s.environment_name) for s in servers], mcp_server_images=[s.docker_image_artifact for s in servers], @@ -1607,6 +1647,7 @@ def _gateway_topology(env: Env) -> _GatewayTopology: for image in (w.server_docker_image_artifact, w.frontend_docker_image_artifact) if image] or None, mcp_server_name=name, names_gateway_on_vm=is_multi, + website_browser=browser, ) diff --git a/src/agent_env/task_step/task_steps/browser/base.py b/src/agent_env/task_step/task_steps/browser/base.py index 87f982ab0..913b24ee4 100644 --- a/src/agent_env/task_step/task_steps/browser/base.py +++ b/src/agent_env/task_step/task_steps/browser/base.py @@ -88,9 +88,11 @@ def _record(self, context: TaskStepContext, **fields: Any) -> None: def stored_browsers(env: Env) -> list[str]: """The names of the website browsers a stored env deploys: a computer env's browser apps, the app itself for one - deployed alone, a multi env's computers' browser apps; none for any other env.""" + deployed alone, a multi env's computers' browser apps and the browser computer its browser_envs names; none for + any other env.""" if isinstance(env, MultiEnv): - return [name for computer in env.computer_envs for name in stored_browsers(computer)] + computers = [*env.computer_envs, *(env.browser_envs or [])] + return [name for computer in computers for name in stored_browsers(computer)] if not isinstance(env, ComputerEnv): return [] return [app.environment_name for app in env.composed_apps() if app.declaration.browser] diff --git a/tst/integration/computer/multi_env_browser_test.py b/tst/integration/computer/multi_env_browser_test.py new file mode 100644 index 000000000..db3a7a717 --- /dev/null +++ b/tst/integration/computer/multi_env_browser_test.py @@ -0,0 +1,184 @@ +"""agentenv's chromium as a multi env's website browser, on the local backend: ``browser_envs = [chromium]`` deploys it +on a sandbox of its own as one more computer child, beside a synthetic site, with no playwright-mcp. It routes the site, +so its start tab loads it by name through the gateway; an agent drives it through the gateway's one /mcp with its tools +under ``chromium_``; its CDP address, asked through ``/svc/chromium/``, names the gateway and reaches the site by name; +reset_env brings it back after its container is killed and after the gateway restarts; and local Docker, which can't +hold its network to the gateway, runs it allowing every host and says so. ``browser_envs = []`` deploys no browser. +Needs Docker and the local registry on localhost:5000.""" + +import asyncio +import json +import logging +import subprocess +import uuid + +import httpx +import pytest +from agentenv_protocol import client as protocol_v1 +from agentenv_protocol.website_browser import BROWSER +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + +from agent_env.env import Env +from agent_env.env.env import DeployedEnv +from agent_env.env.envs.multi_env import MultiEnv +from agent_env.env.envs.playwright_mcp import PLAYWRIGHT_MCP_ENVIRONMENT_NAME +from agent_env.env.envs.website import WebsiteEnv +from agent_env.store.routing import namespace_routing +from agent_env.task.teardown import teardown_run +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep +from agent_env.task_step.task_steps.reset_env import ResetEnvTaskStep +from tst.integration.computer.browser_computer_test import _cdp_agent_loads +from tst.integration.computer.multi_env_computers_test import _containers, _gateway, _image +from tst.integration.store.local_parallel_deploy_test import _seed_bootstrap_envs + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +SITE_URL = "https://shop.test/api/health" +CHROMIUM = {"sandbox_type": "local", + "env_params": {"form": "container", "app_settings": {"chromium": {"start_urls": [SITE_URL]}}}} +#: How long the browser may take to answer again after a restart or a reset; how often it's asked meanwhile. +WAIT_S, POLL_S = 120.0, 2.0 + + +@pytest.fixture(scope="module") +def shop(browser_computer): + """The gateway's and service-db's envs, and a website whose backend is the small agentenv website, named + shop.test, under a local id, since a multi env naming chromium's local id must be local too.""" + with namespace_routing(): + _seed_bootstrap_envs() + uid = uuid.uuid4().hex[:8] + return WebsiteEnv.put(id=f"@local/~/browsing-{uid}/shop", environment_name="shop", domains=["shop.test"], + backend_docker_image_artifact=_image("agentenv_website", f"browsing-shop-{uid}")) + + +def _office(shop, browser_envs) -> MultiEnv: + return MultiEnv.put(id=f"@local/~/browsing-{uuid.uuid4().hex[:8]}/office", mcp_server_envs=[], + website_envs=[shop], browser_envs=browser_envs, name="office") + + +async def _drive(deployed) -> dict: + """What an agent sees through the gateway's /mcp: the tools it lists, a screenshot by chromium's name, and the + tabs after it opens the site by name.""" + async with streamable_http_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} + shot = await session.call_tool("chromium_computer_screenshot", {}) + opened = await session.call_tool("chromium_computer_navigate", {"url": SITE_URL}) + tabs = await session.call_tool("chromium_computer_tabs", {"action": "list"}) + return {"tools": tools, "shot": not shot.isError and any(part.type == "image" for part in shot.content), + "opened": not opened.isError, + "tabs": " ".join(part.text for part in tabs.content if part.type == "text")} + + +async def _answers_again(deployed) -> bool: + """Whether chromium's screenshot answers through the gateway, asked again until it does or ``WAIT_S`` passes.""" + deadline = asyncio.get_running_loop().time() + WAIT_S + while asyncio.get_running_loop().time() < deadline: + try: + async with streamable_http_client(deployed.mcp_url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + if not (await session.call_tool("chromium_computer_screenshot", {})).isError: + return True + except Exception: # noqa: BLE001 - the gateway or the browser restarting + pass + await asyncio.sleep(POLL_S) + return False + + +async def _cdp(deployed, browser: DeployedEnv) -> dict: + """The CDP address the browser gives, asked through the gateway's ``/svc/chromium/`` route with the browser's own + control token, as agentenv asks a computer child.""" + app = next(child for child in browser.environment_card["children_environments"] if child["name"] == "chromium") + path = protocol_v1.find_extension_method(app, BROWSER, "cdp")["endpoint"] + async with httpx.AsyncClient(timeout=180) as http: + answer = await http.post(f"{deployed.gateway_url}/svc/chromium{path}", json={"user": "ada"}, + headers=browser.control_headers) + assert answer.status_code == 200, answer.text + return answer.json() + + +async def _discovery(http_url: str) -> dict: + async with httpx.AsyncClient(timeout=60) as http: + answer = await http.get(f"{http_url.rstrip('/')}/json/version") + assert answer.status_code == 200, answer.text + return answer.json() + + +@pytest.mark.asyncio +async def test_chromium_as_a_multi_envs_browser_runs_on_its_own_sandbox_reached_through_the_gateway( + browser_computer, shop, caplog): + caplog.set_level(logging.WARNING, logger="agent_env.providers.env_providers.env_computer_provider") + with namespace_routing(): + office = _office(shop, [Env.get(browser_computer.id)]) + step = DeployEnvTaskStep(id="office", version=None, env_id=office.id, sandbox_type="local", ttl_seconds=900, + child_envs={"chromium": CHROMIUM}) + assert step.preflight() == [] + context = TaskStepContext() + try: + [deployed] = (await step.execute(context)).deployed_envs + browser = DeployedEnv.from_dict(deployed.child_envs["chromium"]) + stated = {child.environment_name for child in protocol_v1.children(deployed.environment_card)} + driven = await _drive(deployed) + address = await _cdp(deployed, browser) + discovered = await _discovery(address["http_url"]) + seen = await _cdp_agent_loads(address["ws_url"], [SITE_URL]) + + subprocess.run(["docker", "kill", *_containers(browser.sandbox_id)], capture_output=True) + await ResetEnvTaskStep(id="reset", version=None, env_id=office.id, fail_task_on_error=True).execute(context) + after_kill = await _answers_again(deployed) + after_kill_tabs = (await _drive(deployed))["tabs"] + + subprocess.run(["docker", "restart", _gateway(deployed)], capture_output=True, check=True) + through_restart = await _answers_again(deployed) + await ResetEnvTaskStep(id="reset-2", version=None, env_id=office.id, + fail_task_on_error=True).execute(context) + after_restart = await _answers_again(deployed) + up = _containers(browser.sandbox_id) + finally: + report = await teardown_run(context) + + assert not report.failed and up and _containers(browser.sandbox_id) == [] + # chromium is a computer child on a sandbox of its own, and no playwright-mcp runs. + assert set(deployed.child_envs) == {"chromium"} + assert deployed.sandbox_ids["local"] == {"chromium": browser.sandbox_id} + assert "chromium" in stated and PLAYWRIGHT_MCP_ENVIRONMENT_NAME not in stated, stated + computer = deployed.child_envs["chromium"]["metadata"]["computer"] + assert computer["sites"]["names"]["routes"] == {"shop": ["shop.test"]} + [start_tab] = computer["start_tabs"]["chromium"] + assert (start_tab["url"], start_tab["site"], start_tab["outcome"]) == (SITE_URL, "shop", "loaded"), start_tab + # An agent drives it through the gateway, every tool under chromium_. + assert {"chromium_computer_screenshot", "chromium_computer_navigate"} <= driven["tools"], driven + assert not any(name.startswith("computer_") for name in driven["tools"]), driven + assert driven["shot"] and driven["opened"] and SITE_URL in driven["tabs"], driven + # Its CDP address names the gateway, its discovery answer too, and an agent there reaches the site by name. + token = address["http_url"].split("/cdp/")[1].strip("/") + assert address["http_url"] == f"{deployed.gateway_url}/browser/chromium/cdp/{token}/", address + assert browser.environment_url.rstrip("/") not in json.dumps([address, discovered]), (address, discovered) + assert discovered["webSocketDebuggerUrl"].startswith( + f"ws{deployed.gateway_url.removeprefix('http')}/browser/chromium/cdp/"), discovered + assert seen[SITE_URL]["error"] is None and seen[SITE_URL].get("ready") == "complete", seen + # Through a kill and a gateway restart, a reset brings it back. + assert after_kill and SITE_URL in after_kill_tabs, after_kill_tabs + assert after_restart, through_restart + # Local Docker can't hold its network to the gateway, so it says it allows every host. + assert "can't hold a network to the hosts" in caplog.text, caplog.text + + +@pytest.mark.asyncio +async def test_a_multi_env_naming_no_browser_deploys_none(browser_computer, shop): + with namespace_routing(): + office = _office(shop, []) + context = TaskStepContext() + try: + [deployed] = (await DeployEnvTaskStep(id="office", version=None, env_id=office.id, sandbox_type="local", + ttl_seconds=900).execute(context)).deployed_envs + stated = {child.environment_name for child in protocol_v1.children(deployed.environment_card)} + finally: + report = await teardown_run(context) + + assert not report.failed and not deployed.child_envs + assert "shop" in stated and PLAYWRIGHT_MCP_ENVIRONMENT_NAME not in stated and "chromium" not in stated, stated diff --git a/tst/unit/bundle/computer_env_folder_test.py b/tst/unit/bundle/computer_env_folder_test.py index 0221673de..9f4b9392c 100644 --- a/tst/unit/bundle/computer_env_folder_test.py +++ b/tst/unit/bundle/computer_env_folder_test.py @@ -279,3 +279,18 @@ def test_a_multi_env_whose_computers_share_an_apps_name_is_refused_before_any_wr f"envs/office: computer_envs: app 'mail' of computer '{name}' has the name of another of its child envs, and " "loads and snapshots find an environment by its name" for name in ("desktop", "laptop")] assert composed == [] + + +def test_a_multi_envs_browser_computer_is_told_apart_from_its_computers_by_name_as_they_are(tmp_path, composed): + root = layout(tmp_path / "desk", { + "envs/desktop/env.toml": DESKTOP, "envs/laptop/env.toml": 'environment_name = "desktop"\n' + DESKTOP, + "envs/office/env.toml": 'type = "multi"\ncomputer_envs = ["desktop"]\nbrowser_envs = ["laptop"]\n', + "tasks/t.json": json.dumps([{"id": "load", "type": "load_artifact", "env_id": "office", + "artifact_id": "notes"}]), + "artifacts/notes/notes.txt": "a note\n"}) + with pytest.raises(BundleError) as refused: + _plan(root) + + assert ("envs/office: computer_envs: 'desktop' names 2 of its child envs, and the gateway knows a computer by its " + "name alone") in refused.value.problems, refused.value + assert composed == [] diff --git a/tst/unit/bundle/toml_refs_test.py b/tst/unit/bundle/toml_refs_test.py index 82816e2ee..1594703aa 100644 --- a/tst/unit/bundle/toml_refs_test.py +++ b/tst/unit/bundle/toml_refs_test.py @@ -14,6 +14,7 @@ from agent_env.bundle import parse_bundle from agent_env.bundle.authoring import AuthoringContext from agent_env.bundle.parse import _EVAL_KEYS, CONFIG_FILES +from agent_env.env.env import Env from agent_env.env.envs.computer.env import ComputerEnv from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.multi_env import MultiEnv @@ -58,6 +59,8 @@ def _image(id): # A computer's name is its tool prefix, too short for a sentinel; it deploys as it would alone, which isn't checked here. ComputerEnv: lambda id: MagicMock(spec=ComputerEnv, id=id, type=ComputerEnv.type, version=1, environment_name="desktop", **{"deploy_refusal.return_value": None}), + # A multi env's browser_envs takes any website browser env: an MCP server env serving one stands in. + Env: lambda id: MCPServerEnv(id, 1, docker_image_artifact=_image(f"{id}-image"), environment_name=id), } diff --git a/tst/unit/env/envs/test_multi_env_computers.py b/tst/unit/env/envs/test_multi_env_computers.py index a70468067..751d291a9 100644 --- a/tst/unit/env/envs/test_multi_env_computers.py +++ b/tst/unit/env/envs/test_multi_env_computers.py @@ -1,12 +1,15 @@ """A multi env's computers: each deploys on a sandbox of its own, as it would alone, before the gateway starts, which then fronts it by its name; its tools are served under that name, its record is kept in the multi env's, and closing the multi -env closes it.""" +env closes it. Its website browser, which ``browser_envs`` names or the run's: an MCP server env runs among the +gateway's MCP servers, and a browser computer as one more computer, routing the synthetic sites, its network held to the +gateway.""" from __future__ import annotations import asyncio import dataclasses import inspect +import logging import re from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -14,19 +17,25 @@ import pytest from click.testing import CliRunner +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.cli.env.multi import multi +from agent_env.config.runtime import WEBSITE_BROWSER_OVERRIDE, run_env_ids from agent_env.env.env import DeployedEnv, DeployedGatewayEnv, DeployedSandboxEnv from agent_env.env.envs import _deployment, _multi_computers from agent_env.env.envs.computer import COMPUTER_ENV_TYPE from agent_env.env.envs.computer.env import ComputerEnv +from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.multi_env import MultiEnv from agent_env.env.gateway import ExternalChild from agent_env.env.gateway.constants import WELL_KNOWN_PATH from agent_env.env.store import EnvInstanceStore -from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider +from agent_env.providers.env_providers import env_computer_provider, env_gateway_provider +from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider, MCPServerConfig from agent_env.providers.env_providers.env_provider import EnvironmentProvider from agent_env.providers.env_state import LocalPostgresStateProvider +from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy from agent_env.task.teardown import env_sandbox_ids +from agent_env.task_step.task_steps.browser.base import stored_browsers from tst.util.sandbox_providers import mock_provider GATEWAY = "https://gw.example" @@ -682,3 +691,326 @@ async def test_a_computer_that_fails_to_route_its_sites_fails_the_deploy_and_clo await _multi(desktop, websites=["shop"]).deploy(child_envs={"desktop": {"env_params": {"site_routing": True}}}) assert gateway.log[-2:] == [("close", "desktop"), ("close", "gateway")] + + +class FakeBrowser(FakeComputer, ComputerEnv): + """A browser computer as a multi env uses one: chromium deployed alone, its one app itself.""" + + def __init__(self, name: str = "chromium", *, browser: bool = True, **kwargs): + super().__init__(name, **kwargs) + self.declaration = SimpleNamespace(browser=browser) + + def composed_apps(self): + return [self] + + def app_defaults(self): + return {self.environment_name: {}} + + def serves_website_browser(self): + return self.declaration.browser + + +def _image(id: str) -> DockerImageArtifact: + return DockerImageArtifact(id=id, version=1, description=id, image_name=f"registry.example/{id}:v1", + tar_gz_s3_url=f"s3://bucket/{id}.tar.gz") + + +def _playwright() -> MCPServerEnv: + return MCPServerEnv("playwright-mcp", 1, docker_image_artifact=_image("playwright-image"), + environment_name="playwright-mcp") + + +def _site(name: str, live: bool = False) -> MagicMock: + return MagicMock(id=f"{name}-site", environment_name=name, domains=[f"{name}.test"], live=live, + allow_hosts=[f"api.{name}.test"] if live else []) + + +def _browsing(*computers, browser_envs=None, websites=("shop",), live=()) -> MultiEnv: + return MultiEnv(id="office", version=1, mcp_server_envs=[], + website_envs=[*(_site(name) for name in websites), *(_site(name, live=True) for name in live)], + computer_envs=list(computers), browser_envs=browser_envs) + + +@pytest.fixture +def routes(monkeypatch): + """The sites the gateway's record names, by environment_name, and their readiness, as a deploy reads them.""" + monkeypatch.setattr(_multi_computers.sites, "deployed_sites", + AsyncMock(side_effect=lambda records, services=None: [{"name": "shop", "domains": ["shop.test"], + "url": f"{GATEWAY}/website/shop/"}])) + monkeypatch.setattr(_multi_computers.sites, "require_ready", AsyncMock()) + + +# --- its website browser: the field --- + +def test_browser_envs_is_written_only_when_set_and_none_and_empty_read_back_as_they_were(): + chromium = FakeBrowser() + assert "browser_envs" not in _browsing().to_dict() + assert _browsing(browser_envs=[]).to_dict()["browser_envs"] == [] + doc = _browsing(browser_envs=[chromium]).to_dict() + assert doc["browser_envs"] == [{"id": "chromium-computer", "type": "computer", "version": 1}] + + with patch("agent_env.env.env.Env.get", side_effect=lambda id, version=None: _site("shop") if id == "shop-site" + else FakeBrowser()): + assert MultiEnv.from_dict(_browsing().to_dict()).browser_envs is None + assert MultiEnv.from_dict(_browsing(browser_envs=[]).to_dict()).browser_envs == [] + [read] = MultiEnv.from_dict(doc).browser_envs + assert read.id == "chromium-computer" + + +def test_a_browser_computer_is_kept_out_of_computer_envs(): + office = _browsing(FakeComputer("desktop"), browser_envs=[FakeBrowser()]) + assert [env.environment_name for env in office.computer_envs] == ["desktop"] + assert office.to_dict()["computer_envs"] == [{"id": "desktop-computer", "type": "computer", "version": 1}] + + +@pytest.mark.parametrize("browsers, problem", [ + (lambda: [FakeBrowser(), _playwright()], "browser_envs names 2 envs, and a multi env runs one website browser"), + (lambda: [FakeBrowser("desktop", browser=False)], + "browser_envs[0]: computer env 'desktop-computer' has no website browser among its apps"), + (lambda: [_site("blog")], "browser_envs[0]: 'blog-site' is a"), +], ids=["two", "a-computer-without-a-browser", "not-a-browser-env"]) +def test_browser_envs_takes_one_website_browser_env(browsers, problem): + with pytest.raises(ValueError, match=f"^multi env 'office': {re.escape(problem)}"): + _browsing(browser_envs=browsers()) + + +@pytest.mark.parametrize("browser", [FakeBrowser, _playwright], ids=["browser-computer", "mcp-server"]) +def test_browser_envs_takes_a_browser_computer_or_an_mcp_server_serving_one(browser): + assert _browsing(browser_envs=[browser()]).browser_env().id in ("chromium-computer", "playwright-mcp") + + +def test_a_browser_computer_needs_a_name_no_other_computer_has(): + with pytest.raises(ValueError, match="'chromium' names 2 of its child envs"): + _browsing(FakeComputer("chromium"), browser_envs=[FakeBrowser()]) + + +# --- its website browser: which one --- + +def test_the_browser_is_the_one_named_none_for_an_empty_list_and_else_the_runs_when_there_are_websites(local_stores): + chromium = FakeBrowser() + assert _browsing(browser_envs=[chromium]).browser_env() is chromium + assert _browsing(browser_envs=[]).browser_env() is None + assert _browsing(websites=()).browser_env() is None + with patch("agent_env.env.env.Env.get", return_value=chromium) as get, \ + run_env_ids({WEBSITE_BROWSER_OVERRIDE: "chromium-computer"}): + assert _browsing().browser_env() is chromium and _browsing().browser_computer() is chromium + get.assert_called_with("chromium-computer") + + +def test_a_runs_browser_the_store_doesnt_hold_yet_is_left_for_the_gateway_to_look_up(local_stores): + office = _browsing() + assert office.browser_env() is None and office.browser_computer() is None + assert env_gateway_provider._gateway_topology(office).website_browser is env_gateway_provider._THE_RUNS + + +@pytest.mark.parametrize("browser_envs, runs, expected", [ + (None, _playwright, "the run's"), + (None, FakeBrowser, None), + ([], _playwright, None), + ("chromium", _playwright, None), + ("playwright", FakeBrowser, "playwright-mcp"), +], ids=["runs-mcp-server", "runs-computer", "none", "named-computer", "named-mcp-server"]) +def test_the_gateway_runs_an_mcp_server_browser_and_leaves_a_browser_computer_to_its_own_sandbox(browser_envs, runs, + expected): + named = {"chromium": [FakeBrowser()], "playwright": [_playwright()]}[browser_envs] \ + if isinstance(browser_envs, str) else browser_envs + office = _browsing(browser_envs=named) + with patch("agent_env.env.env.Env.get", return_value=runs()), \ + patch.object(env_gateway_provider, "website_config", return_value=MagicMock()): + browser = env_gateway_provider._gateway_topology(office).website_browser + if expected == "the run's": + assert browser is env_gateway_provider._THE_RUNS + else: + assert getattr(browser, "id", None) == expected + + +def test_a_lone_website_with_a_browser_computer_as_the_runs_browser_is_refused_naming_a_multi_env(): + with patch("agent_env.env.env.Env.get", return_value=FakeBrowser()), \ + pytest.raises(ValueError, match="'chromium-computer' is a computer env, which runs on a sandbox of its own " + "beside a multi env's websites"): + env_gateway_provider.run_website_browser() + + +def test_a_browser_mcp_server_joins_the_gateways_mcp_servers_on_the_sites_network(): + servers, images = env_gateway_provider._with_browser([], [], _playwright()) + assert servers == [MCPServerConfig(image="registry.example/playwright-image:v1", environment_name="playwright-mcp", + networks=(env_gateway_provider._SITES_NETWORK,))] + assert [image.id for image in images] == ["playwright-image"] + + +@pytest.mark.asyncio +async def test_a_named_browser_mcp_server_runs_in_container_mode_too(): + gp, provider = EnvironmentGatewayProvider(), MagicMock(links=None) + with patch.object(gp, "in_vm", return_value=False), patch.object(gp, "sandbox_problem", return_value=None), \ + patch.object(gp, "_deploy_via_containers", AsyncMock()) as containers: + await gp._deploy_gateway(provider, state_instance=None, mcp_servers=[], + mcp_server_images=[], website_browser=_playwright()) + assert [server.environment_name for server in containers.await_args.args[1]] == ["playwright-mcp"] + + +# --- its website browser: a browser computer's deploy --- + +@pytest.mark.asyncio +async def test_a_browser_computer_deploys_as_a_computer_child_routing_the_sites_held_to_the_gateway(gateway, providers, + routes): + chromium, desktop = FakeBrowser(), FakeComputer("desktop") + + deployed = await _browsing(desktop, browser_envs=[chromium], live=("github",)).deploy( + sandbox_type="modal_vm", child_envs={"chromium": {"sandbox_type": "modal", "cpu": 2, "memory_mb": 4096}}) + + assert chromium.deploys == [{"ttl_seconds": 10800, "attribution": None, "tool_prefix": "chromium_", + "reach": GATEWAY, "sites_later": True, + "live_hosts": ("github.test", "api.github.test"), "reach_only": True, + "sandbox_type": "modal", "cpu": 2, "memory_mb": 4096, + "env_params": {"site_routing": True}}] + assert "reach_only" not in desktop.deploys[0] and desktop.deploys[0]["sites_later"] is False + assert gateway.started == [[ExternalChild("desktop", "https://desktop.example/mcp", "https://desktop.example"), + ExternalChild("chromium", "https://chromium.example/mcp", "https://chromium.example")]] + assert set(deployed.child_envs) == {"desktop", "chromium"} + assert chromium.log[-1] == ("routed", "chromium", ["shop"]) + + +@pytest.mark.asyncio +async def test_the_runs_browser_computer_takes_the_same_path(gateway, providers, routes): + chromium = FakeBrowser() + with patch("agent_env.env.env.Env.get", return_value=chromium), \ + run_env_ids({WEBSITE_BROWSER_OVERRIDE: "chromium-computer"}): + deployed = await _browsing().deploy() + assert chromium.deploys[0]["reach_only"] is True and set(deployed.child_envs) == {"chromium"} + + +@pytest.mark.asyncio +async def test_a_browser_entry_saying_whether_it_routes_the_sites_is_kept(gateway, providers): + chromium = FakeBrowser() + await _browsing(browser_envs=[chromium]).deploy(child_envs={"chromium": {"env_params": {"site_routing": False}}}) + assert chromium.deploys[0]["env_params"] == {"site_routing": False} and chromium.deploys[0]["sites_later"] is False + + +@pytest.mark.asyncio +async def test_a_browser_beside_live_sites_alone_routes_nothing(gateway, providers): + chromium = FakeBrowser() + await _browsing(browser_envs=[chromium], websites=(), live=("github",)).deploy() + assert "env_params" not in chromium.deploys[0] and chromium.deploys[0]["live_hosts"] == ("github.test", + "api.github.test") + + +@pytest.mark.asyncio +async def test_no_browser_deploys_no_child_and_gives_the_gateway_none(gateway, providers): + office = _browsing(browser_envs=[]) + await office.deploy() + assert gateway.options[0]["children"] is None + assert env_gateway_provider._gateway_topology(_browsing(browser_envs=[], websites=())).website_browser is None + + +def test_a_browser_computers_entry_is_checked_as_any_computers_and_an_mcp_server_browsers_is_refused(providers): + office = _browsing(browser_envs=[FakeBrowser(refusal="no native build")]) + assert office.child_envs_refusal({"chromium": {"cpu": 2}}) == "child_envs.chromium: no native build" + assert office.child_envs_refusal({"chromium": {"ttl_seconds": 60}}).startswith("child_envs.chromium: an entry is") + assert _browsing(browser_envs=[_playwright()]).child_envs_refusal({"playwright-mcp": {"cpu": 2}}).startswith( + "child_envs.playwright-mcp: env 'office' has no child env named 'playwright-mcp'") + + +def test_the_runs_browser_computer_is_refused_a_name_a_computer_has(providers): + with patch("agent_env.env.env.Env.get", return_value=FakeBrowser()), \ + run_env_ids({WEBSITE_BROWSER_OVERRIDE: "chromium-computer"}): + assert "'chromium' names 2 of its child envs" in _browsing(FakeComputer("chromium")).child_envs_refusal(None) + + +# --- its website browser: after the deploy --- + +@pytest.mark.asyncio +async def test_the_browser_is_reattached_reset_and_closed_from_its_own_record(gateway, providers, routes, monkeypatch): + deployed = await _browsing(FakeComputer("desktop"), browser_envs=[FakeBrowser()]).deploy() + stored = DeployedEnv.from_dict({**dataclasses.asdict(deployed), "instance_id": "inst-1"}) + monkeypatch.setattr(EnvironmentGatewayProvider, "_reattach", AsyncMock()) + stored_office = _browsing(FakeComputer("desktop"), browser_envs=[FakeBrowser()]) + + with patch("agent_env.env.env.Env.get", side_effect=lambda id, version=None: stored_office if id == "office" + else FakeBrowser()), \ + patch.object(FakeBrowser, "from_deployed_env", + classmethod(lambda cls, record: _reattached(cls, record)), create=True): + env = await MultiEnv.from_deployed_env(stored) + assert env._computers.browser._deployed == _record("chromium") + + log = [] + env._computers.browser.log = log + monkeypatch.setattr(MultiEnv, "_attached", classmethod(AsyncMock(return_value=_browsing(websites=())))) + await env.reset(stored) + assert ("reset", "chromium", "sb-chromium") in log + await env.close() + assert ("close", "chromium") in log + + +async def _reattached(cls, record): + browser = cls() + browser._deployed = record + return browser + + +@pytest.mark.asyncio +async def test_a_redeploy_keeps_the_earlier_browser_for_close(gateway, providers, routes): + chromium = FakeBrowser() + office = _browsing(browser_envs=[chromium]) + await office.deploy() + with patch("agent_env.env.env.Env.get", return_value=FakeBrowser(log=chromium.log)): + await office.deploy() + assert office._computers.browser is not chromium + await office.close() + assert chromium.log.count(("close", "chromium")) == 2 # the earlier deployment's browser and the later one's + + +# --- its website browser: its network --- + +def _provider(enforces: bool = True) -> SimpleNamespace: + return SimpleNamespace(url_from_sandbox=lambda url: url, supports_network_policy=lambda policy: enforces) + + +def test_a_reach_only_network_allows_the_gateways_host_and_the_live_hosts_alone(): + env = SimpleNamespace(id="chromium-computer", network=None) + policy = env_computer_provider._network(env, _provider(), GATEWAY, ("github.test",), reach_only=True) + assert policy == NetworkPolicy(mode=NetworkMode.ALLOWLIST, allow_hosts=("gw.example", "github.test")) + + +@pytest.mark.parametrize("enforces, reach, why", [ + (False, GATEWAY, "can't hold a network to the hosts gw.example"), + (True, "", "the gateway's address isn't known"), +], ids=["provider-cant-enforce", "no-gateway-address"]) +def test_a_reach_only_network_the_provider_cant_hold_allows_every_host_and_says_so(enforces, reach, why, caplog): + env = SimpleNamespace(id="chromium-computer", network=None) + with caplog.at_level(logging.WARNING, logger=env_computer_provider.logger.name): + assert env_computer_provider._network(env, _provider(enforces), reach, (), reach_only=True) is None + assert why in caplog.text + + +def test_a_computers_own_network_is_widened_as_before_reach_only_or_not(): + own = {"mode": "allowlist", "allow_hosts": ["pypi.org"]} + env = SimpleNamespace(id="desktop-computer", network=own) + for reach_only in (False, True): + assert env_computer_provider._network(env, _provider(), GATEWAY, (), reach_only).allow_hosts == ( + "pypi.org", "gw.example") + alone = SimpleNamespace(id="x", network=None) + assert env_computer_provider._network(alone, _provider(), GATEWAY, (), False) is None + + +# --- its website browser: elsewhere --- + +def test_a_named_browser_computer_counts_among_a_multi_envs_browsers(): + assert stored_browsers(_browsing(FakeComputer("desktop"), browser_envs=[FakeBrowser()])) == ["chromium"] + assert stored_browsers(_browsing(browser_envs=[_playwright()])) == [] + + +def test_cli_put_takes_a_browser_or_none(): + chromium = FakeBrowser() + slack = MCPServerEnv("slack", 1, docker_image_artifact=_image("slack-image"), environment_name="slack") + for args, expected in ((["--browser", "chromium-computer"], [chromium]), (["--no-browser"], []), ([], None)): + with patch("agent_env.cli.env.multi.Env.get", + side_effect=lambda id, version=None: chromium if id == "chromium-computer" else slack), \ + patch("agent_env.cli.env.multi.detect_base_metadata", return_value={}), \ + patch("agent_env.cli.env.multi.MultiEnv.put", return_value=MagicMock(id="office", version=1)) as put: + result = CliRunner().invoke(multi, ["put", "--id", "office", "--mcp-server", "slack", *args]) + assert result.exit_code == 0, result.output + assert put.call_args.kwargs["browser_envs"] == expected + + result = CliRunner().invoke(multi, ["put", "--id", "office", "--mcp-server", "slack", "--browser", "b", + "--no-browser"]) + assert result.exit_code == 1 and "--browser and --no-browser can't both be given" in result.output diff --git a/tst/unit/providers/env_providers/env_gateway_provider_test.py b/tst/unit/providers/env_providers/env_gateway_provider_test.py index cf352ca62..1a986b74f 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -2042,7 +2042,8 @@ async def test_create_gateway_hands_the_deploy_path_every_argument(attribution, [call] = vm.await_args_list assert call.args == (provider, args.pop("mcp_servers"), args.pop("mcp_server_images")) - assert call.kwargs == {**args, "attribution": forwarded, "children": None} + assert call.kwargs == {**args, "attribution": forwarded, "children": None, + "website_browser": env_gateway_provider._THE_RUNS} assert call.kwargs["attribution"] is not attribution diff --git a/tst/unit/task_step/test_browser_sign_in.py b/tst/unit/task_step/test_browser_sign_in.py index da24cb79f..b10556f74 100644 --- a/tst/unit/task_step/test_browser_sign_in.py +++ b/tst/unit/task_step/test_browser_sign_in.py @@ -201,5 +201,5 @@ def test_a_multi_env_deploys_its_computers_browsers(): computer = ComputerEnv.__new__(ComputerEnv) computer.composed_apps = lambda: [app] office = MultiEnv.__new__(MultiEnv) - office._computers = SimpleNamespace(envs=[computer]) + office._computers, office.browser_envs = SimpleNamespace(envs=[computer]), None assert stored_browsers(office) == ["chromium"] From 6138547cf345aca436ef1cdd72624b4c4e7f54c3 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 10:45:57 -0800 Subject: [PATCH 02/15] fix(multi): a reset of the multi env as stored resets the website browser its record holds reset_env reads the multi env from the store, whose browser slot is empty, so the browser computer was skipped and stayed down. The reset now finds it in the record, the record of a name none of the multi env's own computers has, as reattaching does. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/_multi_computers.py | 20 +++++++++++++------ tst/unit/env/envs/test_multi_env_computers.py | 14 +++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/agent_env/env/envs/_multi_computers.py b/src/agent_env/env/envs/_multi_computers.py index 38e88ebd3..0e893921f 100644 --- a/src/agent_env/env/envs/_multi_computers.py +++ b/src/agent_env/env/envs/_multi_computers.py @@ -168,10 +168,19 @@ async def load(self, environments: list[EnvironmentArtifact]) -> None: def resets(self, deployed: DeployedEnv) -> list[tuple[str, Awaitable[None]]]: """Each computer ``deployed``, the multi env's record, holds, by name, with its reset from its own record, as it - resets alone; none is awaited yet.""" + resets alone: the multi env's own, and its website browser, which a multi env read from the store finds in the + record; none is awaited yet.""" records = getattr(deployed, "child_envs", None) or {} + browser = self.browser or self._recorded_browser(records) return [(env.environment_name, env.reset(DeployedEnv.from_dict(records[env.environment_name]))) - for env in self.every if env.environment_name in records] + for env in [*self.envs, *([browser] if browser else [])] if env.environment_name in records] + + def _recorded_browser(self, records: Mapping[str, dict]) -> Optional[ComputerEnv]: + """The website browser ``records``, a multi env's computers' records, hold beside its own computers: the record + of a name none of them has, its env at the version it deployed.""" + own = {env.environment_name for env in self.envs} + record = next((record for name, record in records.items() if name not in own), None) + return Env.get(record["env_id"], record["env_version"]) if record else None def deployer(self, child_envs: Optional[dict], *, sandbox_type: str | None, ttl_seconds: int, attribution: Optional[Attribution], websites: Mapping[str, str], live_hosts: Iterable[str] = () @@ -250,10 +259,9 @@ async def reattach(self, deployed: DeployedEnv) -> None: for env in self.envs if env.environment_name in records)) by_name = {env.environment_name: env for env in reattached} self.envs = [by_name.get(env.environment_name, env) for env in self.envs] - own = {env.environment_name for env in self.envs} - if browser := next((record for name, record in records.items() if name not in own), None): - record = DeployedEnv.from_dict(browser) - self.browser = await type(Env.get(record.env_id, record.env_version)).from_deployed_env(record) + if (browser := self._recorded_browser(records)) is not None: + self.browser = await type(browser).from_deployed_env(DeployedEnv.from_dict( + records[browser.environment_name])) async def close(self) -> None: """Close each computer, and those of the deployments a later deploy replaced, their recordings ended first; one diff --git a/tst/unit/env/envs/test_multi_env_computers.py b/tst/unit/env/envs/test_multi_env_computers.py index 751d291a9..7bbbd8ff3 100644 --- a/tst/unit/env/envs/test_multi_env_computers.py +++ b/tst/unit/env/envs/test_multi_env_computers.py @@ -941,6 +941,20 @@ async def test_the_browser_is_reattached_reset_and_closed_from_its_own_record(ga assert ("close", "chromium") in log +@pytest.mark.asyncio +async def test_a_reset_of_the_multi_env_as_stored_resets_the_browser_its_record_holds(gateway, providers, routes, + monkeypatch): + deployed = await _browsing(FakeComputer("desktop"), browser_envs=[FakeBrowser()]).deploy() + stored = DeployedEnv.from_dict({**dataclasses.asdict(deployed), "instance_id": "inst-1"}) + log = [] + monkeypatch.setattr(MultiEnv, "_attached", classmethod(AsyncMock(return_value=_browsing(websites=())))) + + with patch("agent_env.env.env.Env.get", return_value=FakeBrowser(log=log)): + await _browsing(FakeComputer("desktop", log=log), browser_envs=[FakeBrowser()]).reset(stored) + + assert sorted(log) == [("reset", "chromium", "sb-chromium"), ("reset", "desktop", "sb-desktop")] + + async def _reattached(cls, record): browser = cls() browser._deployed = record From 3388d26f7753eb9b6d60e1410ee13c2d976abd54 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 10:59:02 -0800 Subject: [PATCH 03/15] feat(multi): on modal_vm, a browser held to the gateway allows every host until its image can be pulled under an allowlist A Modal VM's Docker pulls a container's image through the sandbox's own allowlist, which leaves the registry out, so chromium held to the gateway couldn't pull itself there. SandboxProvider.PULLS_UNDER_ALLOWLIST says which providers can; modal_vm can't, and a reach_only network falls back to allowing every host there, as logged. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +-- src/agent_env/browsers/README.md | 3 +- .../env_providers/env_computer_provider.py | 12 +++++-- .../sandbox_providers/modal_vm_sandbox.py | 1 + .../sandbox_providers/sandbox_provider.py | 3 ++ tst/unit/env/envs/test_multi_env_computers.py | 33 ++++++++++++++----- 6 files changed, 42 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ae55114c7..9570a80b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,12 +121,12 @@ 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 and computers; its website browser is the one its `browser_envs` names, none for `[]`, else the run's when it has websites: an MCP server env such as `playwright-mcp` runs among the gateway's MCP servers, and a browser computer such as `chromium` on a sandbox of its own as one more computer child, sized by its `child_envs` entry, its tools under its name and `_`, routing the multi env's synthetic sites unless that entry's `site_routing` says, its network held to the gateway's host and the live sites' hosts (`reach_only`) where the provider enforces an allowlist, else allowing every host, as logged; a website env deployed alone refuses a browser computer as the run's browser), `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; its env.toml's `ready_timeout_s`, how long it may take from its server's container starting to ready, is that container's health check `start_period` and what the deploy waits, or its card's `ready` timeout if longer, and its `shm_mb` is the container's `shm_size`, each left to the gateway and Docker without them; 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; a live one, `live = true`, is a real site whose server serves none of its pages, its `live`, `domains` and `allow_hosts` read at put from its server's `@website(...)` (`utils/card_naming.py` `WebsiteSource`) unless given, its `settings`' secrets only as references the gateway resolves as it writes the site's container (`WEBSITE_SETTINGS_ENV`); the gateway gives it no site route, a computer's site table leaves it out, a computer whose network is an allowlist is let reach its names (`MultiEnv.live_hosts`), a browser alone's forward port takes them straight to the real site (`site_proxy.py` `direct`), as the browser base's stand-in proxy takes any host, refusing nothing, each proxy logging each such connection as a JSON line, and a run that deploys one is marked live, `reproducible: false`), `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.]`, or for one distribution `[install."/"]`, which replaces its OS type's there, as its `known_failures` do (`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, its `/dev/shm` the most its apps' Linux installs ask for (`shm_mb`, in MiB, which its composed computer and the deploy record keep; `--shm-size` on a container, `AGENTENV_SHM_MB` read by a Linux disk's init, passed only to a provider whose `SHARED_MEMORY` says it gives that much, else the sandbox's default, which the deploy logs), 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; `@linux(distribution=...)` or `@install("/", spec)` for one distribution, which replaces its OS type's there) 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 and computers; its website browser is the one its `browser_envs` names, none for `[]`, else the run's when it has websites: an MCP server env such as `playwright-mcp` runs among the gateway's MCP servers, and a browser computer such as `chromium` on a sandbox of its own as one more computer child, sized by its `child_envs` entry, its tools under its name and `_`, routing the multi env's synthetic sites unless that entry's `site_routing` says, its network held to the gateway's host and the live sites' hosts (`reach_only`) where the provider enforces an allowlist and can pull a container's image under one, else allowing every host, as logged; a website env deployed alone refuses a browser computer as the run's browser), `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; its env.toml's `ready_timeout_s`, how long it may take from its server's container starting to ready, is that container's health check `start_period` and what the deploy waits, or its card's `ready` timeout if longer, and its `shm_mb` is the container's `shm_size`, each left to the gateway and Docker without them; 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; a live one, `live = true`, is a real site whose server serves none of its pages, its `live`, `domains` and `allow_hosts` read at put from its server's `@website(...)` (`utils/card_naming.py` `WebsiteSource`) unless given, its `settings`' secrets only as references the gateway resolves as it writes the site's container (`WEBSITE_SETTINGS_ENV`); the gateway gives it no site route, a computer's site table leaves it out, a computer whose network is an allowlist is let reach its names (`MultiEnv.live_hosts`), a browser alone's forward port takes them straight to the real site (`site_proxy.py` `direct`), as the browser base's stand-in proxy takes any host, refusing nothing, each proxy logging each such connection as a JSON line, and a run that deploys one is marked live, `reproducible: false`), `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.]`, or for one distribution `[install."/"]`, which replaces its OS type's there, as its `known_failures` do (`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, its `/dev/shm` the most its apps' Linux installs ask for (`shm_mb`, in MiB, which its composed computer and the deploy record keep; `--shm-size` on a container, `AGENTENV_SHM_MB` read by a Linux disk's init, passed only to a provider whose `SHARED_MEMORY` says it gives that much, else the sandbox's default, which the deploy logs), 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; `@linux(distribution=...)` or `@install("/", spec)` for one distribution, which replaces its OS type's there) 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/`. | | `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), `agent37` (no 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`, `WEBSOCKETS`; `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). | +| `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), `agent37` (no 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`, `WEBSOCKETS`, `PULLS_UNDER_ALLOWLIST`, off for `modal_vm`, whose VM's Docker pulls a container's image through the sandbox's allowlist; `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). | | `a2a_agent/` | The `A2AAgent` entity (`a2a_agent`), its stores and the validator steps. The protocol package provides the agent-side framework. | | `openenv/` | `build_app(bundle)`: the [OpenEnv](https://github.com/openenv/OpenEnv) server for a bundle's tasks, each session playing one episode through the Episode API, with the envs' MCP tools, an added `end_turn` tool and the task's verifier score as the reward. It prepares bundles as `agent-env run` does (`prepare_bundle(played=True)`, without the agent the client plays) and keeps namespace routing on for the app's life. `openenv` comes with the `openenv` extra, a floor with no cap; this package and `training/` are the only ones that import it. Each task's description in the Task API names its `toolset` (the played agent's envs, role, and whether it gets `bash`). With `token`, every request and WebSocket but `/health` must carry `Authorization: Bearer `, for a server others reach. | | `training/` | One module per outside trainer, each behind its own extra, and the modules they share; [Training helpers](#training-helpers) lists them. | diff --git a/src/agent_env/browsers/README.md b/src/agent_env/browsers/README.md index 10add9dae..bde3e38cf 100644 --- a/src/agent_env/browsers/README.md +++ b/src/agent_env/browsers/README.md @@ -23,7 +23,8 @@ the alias `aliases.toml` gives it, or by its id, `@local/agentenv-framework/brow its website browser with `browser_envs = ["chromium"]`, in place of the run's `playwright-mcp`: it then deploys on a sandbox of its own beside the multi env's computers, sized by the deploy step's `child_envs.chromium`, its tools served under `chromium_`, routing the multi env's synthetic sites through the gateway, and its network allowing the gateway's -host and the live sites' hosts alone where the sandbox provider enforces an allowlist. +host and the live sites' hosts alone where the sandbox provider enforces an allowlist and pulls its image under one +(not yet `modal_vm`, which runs it allowing every host, as logged). A task changes a browser's state with the steps in `agent_env.task_step.task_steps.browser`, each a call to one `browser/v1` operation on the browser in the env `env_id`, the one `browser` names when the env has several: 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 86efbeb67..0cebf78b2 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -174,7 +174,7 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par values = _values(computer, values) app_settings = recorded_app_settings(env.composed_apps(), computer_params.app_settings(env, params.app_settings), platform_of(env.computer.os)) - policy = _network(env, sandbox_provider, reach, live_hosts, reach_only) + policy = _network(env, sandbox_provider, reach, live_hosts, reach_only, form) shm_mb = env.shm_mb() booted = await boot( sandbox_provider, image=build["image"], port=env.driver.port, network_policy=policy, token=token, @@ -349,10 +349,11 @@ async def end_recordings(record: DeployedSandboxEnv, reason: str) -> None: def _network(env: ComputerEnv, sandbox_provider: SandboxProvider, reach: str, live_hosts: tuple[str, ...], - reach_only: bool) -> Optional[NetworkPolicy]: + reach_only: bool, form: str = "container") -> Optional[NetworkPolicy]: """The computer's network: its own, also allowing ``reach``'s host and ``live_hosts`` when it lists the hosts allowed (``_reaching``), and with ``reach_only`` one that allows every host held to those hosts alone. A provider - that can't enforce that, or a gateway whose address isn't known yet, leaves it allowing every host, as logged.""" + that can't enforce that, or can't pull a container's image under it (``PULLS_UNDER_ALLOWLIST``), or a gateway + whose address isn't known yet, leaves it allowing every host, as logged.""" own = NetworkPolicy.from_dict(env.network) if env.network else None if not reach_only or (own is not None and own.restricts_egress): return _reaching(own, sandbox_provider, reach, live_hosts) @@ -365,6 +366,11 @@ def _network(env: ComputerEnv, sandbox_provider: SandboxProvider, reach: str, li logger.warning(f"{env.id}: {type(sandbox_provider).__name__} can't hold a network to the hosts " f"{', '.join(held.allow_hosts)}, so its network allows every host; use it for development only") return own + if form == "container" and not sandbox_provider.PULLS_UNDER_ALLOWLIST: + logger.warning(f"{env.id}: {type(sandbox_provider).__name__} pulls a container's image through its " + f"network, which an allowlist of {', '.join(held.allow_hosts)} would cut off, so its network " + "allows every host") + return own return held diff --git a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py index 99312ab53..2ce91d7a2 100644 --- a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py @@ -299,6 +299,7 @@ class ModalVmSandboxProvider(SandboxProvider): EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.modal.host", "*.w.modal.host") WEBSOCKETS = True + PULLS_UNDER_ALLOWLIST = False @classmethod def supports_network_policy(cls, policy: NetworkPolicy) -> bool: diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 1cc76fcca..6ac153027 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -252,6 +252,9 @@ def from_config(cls, **config: Any) -> Self: # provider. Derived for a provider class that doesn't declare it (``__init_subclass__``): on for the default VM # path, off for a container-only backend and for a class that overrides either method. SHARED_MEMORY: ClassVar[bool] = False + # Whether a container it runs under a network that allowlists hosts can still pull its image: off where the pull + # goes through the sandbox's own allowlist, which leaves the registry out, as a Modal VM's Docker does. + PULLS_UNDER_ALLOWLIST: ClassVar[bool] = True def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) diff --git a/tst/unit/env/envs/test_multi_env_computers.py b/tst/unit/env/envs/test_multi_env_computers.py index 7bbbd8ff3..3d020bd55 100644 --- a/tst/unit/env/envs/test_multi_env_computers.py +++ b/tst/unit/env/envs/test_multi_env_computers.py @@ -33,6 +33,9 @@ from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider, MCPServerConfig from agent_env.providers.env_providers.env_provider import EnvironmentProvider from agent_env.providers.env_state import LocalPostgresStateProvider +from agent_env.providers.sandbox_providers.local_docker.provider import LocalSandboxProvider +from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandboxProvider +from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandboxProvider from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy from agent_env.task.teardown import env_sandbox_ids from agent_env.task_step.task_steps.browser.base import stored_browsers @@ -975,8 +978,9 @@ async def test_a_redeploy_keeps_the_earlier_browser_for_close(gateway, providers # --- its website browser: its network --- -def _provider(enforces: bool = True) -> SimpleNamespace: - return SimpleNamespace(url_from_sandbox=lambda url: url, supports_network_policy=lambda policy: enforces) +def _provider(enforces: bool = True, pulls: bool = True) -> SimpleNamespace: + return SimpleNamespace(url_from_sandbox=lambda url: url, supports_network_policy=lambda policy: enforces, + PULLS_UNDER_ALLOWLIST=pulls) def test_a_reach_only_network_allows_the_gateways_host_and_the_live_hosts_alone(): @@ -985,17 +989,30 @@ def test_a_reach_only_network_allows_the_gateways_host_and_the_live_hosts_alone( assert policy == NetworkPolicy(mode=NetworkMode.ALLOWLIST, allow_hosts=("gw.example", "github.test")) -@pytest.mark.parametrize("enforces, reach, why", [ - (False, GATEWAY, "can't hold a network to the hosts gw.example"), - (True, "", "the gateway's address isn't known"), -], ids=["provider-cant-enforce", "no-gateway-address"]) -def test_a_reach_only_network_the_provider_cant_hold_allows_every_host_and_says_so(enforces, reach, why, caplog): +@pytest.mark.parametrize("enforces, pulls, reach, why", [ + (False, True, GATEWAY, "can't hold a network to the hosts gw.example"), + (True, False, GATEWAY, "pulls a container's image through its network"), + (True, True, "", "the gateway's address isn't known"), +], ids=["provider-cant-enforce", "pull-under-allowlist", "no-gateway-address"]) +def test_a_reach_only_network_the_provider_cant_hold_allows_every_host_and_says_so(enforces, pulls, reach, why, + caplog): env = SimpleNamespace(id="chromium-computer", network=None) with caplog.at_level(logging.WARNING, logger=env_computer_provider.logger.name): - assert env_computer_provider._network(env, _provider(enforces), reach, (), reach_only=True) is None + assert env_computer_provider._network(env, _provider(enforces, pulls), reach, (), reach_only=True) is None assert why in caplog.text +def test_a_disk_held_to_the_gateway_is_not_let_off_by_a_provider_that_cant_pull_a_container_under_one(): + env = SimpleNamespace(id="chromium-computer", network=None) + policy = env_computer_provider._network(env, _provider(pulls=False), GATEWAY, (), reach_only=True, form="disk") + assert policy == NetworkPolicy(mode=NetworkMode.ALLOWLIST, allow_hosts=("gw.example",)) + + +def test_modal_vm_says_it_cant_pull_a_container_under_an_allowlist_and_others_say_they_can(): + assert not ModalVmSandboxProvider.PULLS_UNDER_ALLOWLIST + assert ModalSandboxProvider.PULLS_UNDER_ALLOWLIST and LocalSandboxProvider.PULLS_UNDER_ALLOWLIST + + def test_a_computers_own_network_is_widened_as_before_reach_only_or_not(): own = {"mode": "allowlist", "allow_hosts": ["pypi.org"]} env = SimpleNamespace(id="desktop-computer", network=own) From a282ce26618558ee5d8219adb4722e4db9bdfc51 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 12:55:03 -0800 Subject: [PATCH 04/15] fix(gateway): a CDP address behind a tunnel that says no scheme takes the scheme the deploy reaches the gateway at Modal's tunnel terminates TLS and sends no X-Forwarded-Proto, so the gateway handed out http:// CDP addresses there, which don't connect. The deploy now tells the gateway its tunnel URL (AGENTENV_GATEWAY_PUBLIC_URL), and a request to that host takes its scheme; X-Forwarded-Proto still wins, and any other host keeps the connection's own. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/agent_env/env/gateway/browser_proxy.py | 22 +++++++---- src/agent_env/env/gateway/constants.py | 3 ++ src/agent_env/env/gateway/entrypoint.py | 5 ++- src/agent_env/env/gateway/gateway.py | 7 +++- .../env_providers/env_gateway_provider.py | 5 +++ tst/unit/env/gateway/browser_route_test.py | 39 +++++++++++++++++-- .../env_gateway_provider_test.py | 15 ++++++- 8 files changed, 82 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9570a80b4..1d8b080da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,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 and computers; its website browser is the one its `browser_envs` names, none for `[]`, else the run's when it has websites: an MCP server env such as `playwright-mcp` runs among the gateway's MCP servers, and a browser computer such as `chromium` on a sandbox of its own as one more computer child, sized by its `child_envs` entry, its tools under its name and `_`, routing the multi env's synthetic sites unless that entry's `site_routing` says, its network held to the gateway's host and the live sites' hosts (`reach_only`) where the provider enforces an allowlist and can pull a container's image under one, else allowing every host, as logged; a website env deployed alone refuses a browser computer as the run's browser), `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; its env.toml's `ready_timeout_s`, how long it may take from its server's container starting to ready, is that container's health check `start_period` and what the deploy waits, or its card's `ready` timeout if longer, and its `shm_mb` is the container's `shm_size`, each left to the gateway and Docker without them; 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; a live one, `live = true`, is a real site whose server serves none of its pages, its `live`, `domains` and `allow_hosts` read at put from its server's `@website(...)` (`utils/card_naming.py` `WebsiteSource`) unless given, its `settings`' secrets only as references the gateway resolves as it writes the site's container (`WEBSITE_SETTINGS_ENV`); the gateway gives it no site route, a computer's site table leaves it out, a computer whose network is an allowlist is let reach its names (`MultiEnv.live_hosts`), a browser alone's forward port takes them straight to the real site (`site_proxy.py` `direct`), as the browser base's stand-in proxy takes any host, refusing nothing, each proxy logging each such connection as a JSON line, and a run that deploys one is marked live, `reproducible: false`), `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.]`, or for one distribution `[install."/"]`, which replaces its OS type's there, as its `known_failures` do (`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, its `/dev/shm` the most its apps' Linux installs ask for (`shm_mb`, in MiB, which its composed computer and the deploy record keep; `--shm-size` on a container, `AGENTENV_SHM_MB` read by a Linux disk's init, passed only to a provider whose `SHARED_MEMORY` says it gives that much, else the sandbox's default, which the deploy logs), 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; `@linux(distribution=...)` or `@install("/", spec)` for one distribution, which replaces its OS type's there) 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 and computers; its website browser is the one its `browser_envs` names, none for `[]`, else the run's when it has websites: an MCP server env such as `playwright-mcp` runs among the gateway's MCP servers, and a browser computer such as `chromium` on a sandbox of its own as one more computer child, sized by its `child_envs` entry, its tools under its name and `_`, routing the multi env's synthetic sites unless that entry's `site_routing` says, its network held to the gateway's host and the live sites' hosts (`reach_only`) where the provider enforces an allowlist and can pull a container's image under one, else allowing every host, as logged; a website env deployed alone refuses a browser computer as the run's browser), `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; its env.toml's `ready_timeout_s`, how long it may take from its server's container starting to ready, is that container's health check `start_period` and what the deploy waits, or its card's `ready` timeout if longer, and its `shm_mb` is the container's `shm_size`, each left to the gateway and Docker without them; 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; a live one, `live = true`, is a real site whose server serves none of its pages, its `live`, `domains` and `allow_hosts` read at put from its server's `@website(...)` (`utils/card_naming.py` `WebsiteSource`) unless given, its `settings`' secrets only as references the gateway resolves as it writes the site's container (`WEBSITE_SETTINGS_ENV`); the gateway gives it no site route, a computer's site table leaves it out, a computer whose network is an allowlist is let reach its names (`MultiEnv.live_hosts`), a browser alone's forward port takes them straight to the real site (`site_proxy.py` `direct`), as the browser base's stand-in proxy takes any host, refusing nothing, each proxy logging each such connection as a JSON line, and a run that deploys one is marked live, `reproducible: false`), `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.]`, or for one distribution `[install."/"]`, which replaces its OS type's there, as its `known_failures` do (`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, its `/dev/shm` the most its apps' Linux installs ask for (`shm_mb`, in MiB, which its composed computer and the deploy record keep; `--shm-size` on a container, `AGENTENV_SHM_MB` read by a Linux disk's init, passed only to a provider whose `SHARED_MEMORY` says it gives that much, else the sandbox's default, which the deploy logs), 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; `@linux(distribution=...)` or `@install("/", spec)` for one distribution, which replaces its OS type's there) 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/`, its scheme the one `X-Forwarded-Proto` names, else, for a request to the host the deploy reaches the gateway at (`AGENTENV_GATEWAY_PUBLIC_URL`, the gateway's tunnel URL on a VM), that URL's, as Modal's tunnel sends none; 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/env/gateway/browser_proxy.py b/src/agent_env/env/gateway/browser_proxy.py index 2d08ddd03..432777b65 100644 --- a/src/agent_env/env/gateway/browser_proxy.py +++ b/src/agent_env/env/gateway/browser_proxy.py @@ -99,14 +99,19 @@ def rewritten(body: Any, fields: frozenset[str], base: str, key: str) -> Any: return body -def public_base(connection: Request | WebSocket) -> str | None: +def public_base(connection: Request | WebSocket, public_url: str | None = None) -> str | None: """The address the client reached the gateway at, scheme, host and port: the Host it sent, with the scheme a - TLS-terminating tunnel in front says it was reached by (``X-Forwarded-Proto``), else the connection's own. - None for a Host that isn't a host and port. A rewrite with it goes back only to the client that sent it.""" + TLS-terminating tunnel in front says it was reached by (``X-Forwarded-Proto``), else ``public_url``'s, the + address the deploy reaches the gateway at, when the Host is its host, else the connection's own. None for a Host + that isn't a host and port. A rewrite with it goes back only to the client that sent it.""" host = connection.headers.get("host", "") if not HOST.fullmatch(host): return None proto = connection.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower() + known = urlsplit(public_url) if public_url else None + if proto not in ("http", "https") and known and known.scheme in ("http", "https") \ + and known.netloc.lower() == host.lower(): + proto = known.scheme if proto not in ("http", "https"): proto = "https" if connection.url.scheme in ("https", "wss") else "http" return f"{proto}://{host}" @@ -126,12 +131,15 @@ class BrowserRoutes: """The ``/browser//cdp//`` route over a gateway's ``/svc`` children, and the rewrite of a browser's ``cdp`` answer on their ``/svc`` route. ``log_event`` writes a trajectory event, ``attribution`` names a role's caller for one, and ``role_of`` reads the role a connection's ``AgentEnv-Role`` header names. The headers named in - ``withheld`` (lower case), the sandbox provider's credentials, reach no browser.""" + ``withheld`` (lower case), the sandbox provider's credentials, reach no browser; ``public_url`` is the address the + deploy reaches the gateway at (``public_base``).""" def __init__(self, rest_proxy_urls: dict[str, str], timeout: httpx.Timeout, log_event: Callable[[dict], Awaitable[Any]], attribution: Callable[[str], dict], - role_of: Callable[[Request | WebSocket], str], *, withheld: frozenset[bytes] = frozenset()): + role_of: Callable[[Request | WebSocket], str], *, withheld: frozenset[bytes] = frozenset(), + public_url: str | None = None): self._urls = rest_proxy_urls + self._public_url = public_url self._withheld = withheld self._timeout = timeout self._log_event = log_event @@ -167,7 +175,7 @@ async def discovery(self, request: Request) -> Response: browser = await self.browser(key) if isinstance(browser, Response): return browser - base = public_base(request) + base = public_base(request, self._public_url) if base is None: return _error(400, "the Host header isn't a host and port") headers = client_headers(request, self._withheld) @@ -188,7 +196,7 @@ async def cdp_answer(self, request: Request, base_url: str, role: str) -> Respon browser = await self.browser(key) if not isinstance(browser, Browser) or path != browser.cdp_path: return None - base = public_base(request) + base = public_base(request, self._public_url) if base is None: return _error(400, "the Host header isn't a host and port") try: diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index 37af9456c..eeaa53817 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -150,6 +150,9 @@ def sources(names: list[str]) -> str: # Sent by a computer's site proxy, which serves a website's route at the root of the site's own name. AGENT_ENV_SITE_ROOT_HEADER = "AgentEnv-Site-Root" GATEWAY_PROVIDER_HEADERS_ENV = "AGENTENV_PROVIDER_REQUEST_HEADERS" +# The address the deploy reaches the gateway at, whose scheme a client reaching it at that host used when a +# TLS-terminating tunnel in front sends no X-Forwarded-Proto, as Modal's doesn't. +GATEWAY_PUBLIC_URL_ENV = "AGENTENV_GATEWAY_PUBLIC_URL" # MCP request `_meta` keys the gateway stamps on every child tools/call: the caller's role, and on the MCP path a # key naming the inbound session, so a child can keep per-caller state behind the gateway's one shared session. AGENT_ENV_ROLE_META_KEY = "agentenv.io/role" diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 1437372ff..32cc08f1c 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -8,8 +8,8 @@ import httpx from . import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode, InternalMCPServer -from .constants import (CONTROL_TOKEN_ENV, DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, MCP_TRANSPORT, - WELL_KNOWN_PATH) +from .constants import (CONTROL_TOKEN_ENV, DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, GATEWAY_PUBLIC_URL_ENV, + MCP_TRANSPORT, WELL_KNOWN_PATH) from .gateway import Gateway, card_url logger = logging.getLogger(__name__) @@ -218,5 +218,6 @@ def parse_name_url_map(env_str: str) -> dict[str, str]: service_db_url=service_db_url, control_token=os.environ.get(CONTROL_TOKEN_ENV), provider_headers=os.environ.get(GATEWAY_PROVIDER_HEADERS_ENV, "").split(","), + public_url=os.environ.get(GATEWAY_PUBLIC_URL_ENV) or None, ) gateway.run() diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 3b257b056..0daf80925 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -142,9 +142,11 @@ def __init__( service_db_url: str | None = None, control_token: str | None = None, provider_headers: Iterable[str] = (), + public_url: str | None = None, ): """`provider_headers` names the headers the sandbox provider in front of the gateway is sent, its credentials, - which the gateway takes off every request it forwards to a service behind it.""" + which the gateway takes off every request it forwards to a service behind it; `public_url`, the address the + deploy reaches it at, gives the scheme of a CDP address it hands a client that reached it there.""" self.host = host self.port = port self.server_name = server_name @@ -155,7 +157,8 @@ def __init__( self._site_reserved: dict[str, frozenset[str]] = {} self._withheld = frozenset(name.strip().lower().encode() for name in provider_headers if name.strip()) self._browsers = BrowserRoutes(self.rest_proxy_urls, self.REST_PROXY_TIMEOUT, self._log_event, - self._attribution, self._request_role, withheld=self._withheld) + self._attribution, self._request_role, withheld=self._withheld, + public_url=public_url) for name in _TOKEN_LOGGERS: # each names a CDP address's path, its token in it, at INFO log = logging.getLogger(name) if not any(isinstance(f, TokenRedaction) for f in log.filters): diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index a98709ad5..392db0259 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -35,6 +35,7 @@ from agent_env.env.gateway.constants import ( DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, + GATEWAY_PUBLIC_URL_ENV, STATE_TOOL_NAME_CLASHES_KEY, WELL_KNOWN_PATH, random_mcp_server_name, @@ -320,6 +321,7 @@ def create_docker_compose( extra_hosts: tuple[str, ...] = (), mcp_server_name: str | None = None, external_children: tuple[ExternalChild, ...] = (), + public_url: str | None = None, control_token: str | None = None, ) -> str: """Generate docker-compose.yml content for a gateway deployment onto a VM/laptop/arbitrary machine. @@ -535,6 +537,8 @@ def create_docker_compose( gateway_env.extend(f" - {key}={value}" for key, value in control_environment(control_token).items()) if provider_headers := sandbox_request_header_names(): gateway_env.append(f" - {GATEWAY_PROVIDER_HEADERS_ENV}={','.join(provider_headers)}") + if public_url: + gateway_env.append(f" - {GATEWAY_PUBLIC_URL_ENV}={public_url}") publish = host_port or (lambda port: port) exposed_gateway_ports = [] if expose_gateway_port: @@ -882,6 +886,7 @@ async def _stand_up(_spec): host_ips=sandbox.host_ips, extra_hosts=sandbox.extra_hosts, mcp_server_name=mcp_server_name, + public_url=sandbox.tunnel_urls.get(gateway_port), control_token=control_token, ) compose_content = compose() diff --git a/tst/unit/env/gateway/browser_route_test.py b/tst/unit/env/gateway/browser_route_test.py index eb793a945..740138beb 100644 --- a/tst/unit/env/gateway/browser_route_test.py +++ b/tst/unit/env/gateway/browser_route_test.py @@ -124,15 +124,22 @@ async def _serve(app) -> tuple[uvicorn.Server, str]: @asynccontextmanager async def _gateway(tmp_path, monkeypatch, card: dict | None = None, seen: list[dict] | None = None, - provider_headers: tuple[str, ...] = ()): + provider_headers: tuple[str, ...] = (), public: str | None = None): """The real gateway app on loopback, its /svc child ``web`` a browser computer serving ``card``; yields the gateway's base URL and a reader of its browser_connection events, which waits for ``at_least``: each is written as its connection ends, just after the client sees the close.""" monkeypatch.setattr(gw_mod, "GATEWAY_TRAJECTORY_FILE", str(tmp_path / "trajectory.jsonl")) upstream, upstream_url = await _serve(_browser_computer(_computer_card(_app_card()) if card is None else card, seen)) + gateway_app = None + + async def app(scope, receive, send): # the gateway, built once its address is known + await gateway_app(scope, receive, send) + + gateway, gateway_url = await _serve(app) gw = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], - rest_proxy_urls={"web": upstream_url}, provider_headers=provider_headers) - gateway, gateway_url = await _serve(gw._asgi_app()) + rest_proxy_urls={"web": upstream_url}, provider_headers=provider_headers, + public_url=public and public.format(address=gateway_url.removeprefix("http://"))) + gateway_app = gw._asgi_app() async def events(at_least: int) -> list[dict]: for _ in range(50): found = [event for event in map(json.loads, (tmp_path / "trajectory.jsonl").read_text().splitlines()) @@ -167,6 +174,19 @@ async def test_the_cdp_answer_and_discovery_name_the_gateway_however_the_browser assert tunnelled["webSocketDebuggerUrl"] == f"wss://{url.removeprefix('http://')}/browser/web/cdp/{TOKEN}/devtools/browser" +@pytest.mark.asyncio +async def test_behind_a_tunnel_that_says_no_scheme_the_addresses_take_the_scheme_the_deploy_reaches_the_gateway_at( + tmp_path, monkeypatch): + async with _gateway(tmp_path, monkeypatch, public="https://{address}") as (url, _), httpx.AsyncClient() as client: + answer = (await client.post(f"{url}/svc/web{CDP_PATH}", json={"user": "solver"})).json() + version = (await client.get(f"{url}/browser/web/cdp/{TOKEN}/json/version")).json() + + at = url.removeprefix("http://") + assert answer == {"http_url": f"https://{at}/browser/web/cdp/{TOKEN}/", + "ws_url": f"wss://{at}/browser/web/cdp/{TOKEN}/devtools/browser"} + assert version["webSocketDebuggerUrl"] == f"wss://{at}/browser/web/cdp/{TOKEN}/devtools/browser" + + @pytest.mark.asyncio async def test_a_websocket_is_relayed_both_ways_and_each_connection_is_an_event_without_its_token(tmp_path, monkeypatch): async with _gateway(tmp_path, monkeypatch) as (url, events), httpx.AsyncClient() as client: @@ -300,6 +320,19 @@ def request(headers: dict, scheme: str = "http") -> Request: assert public_base(request({"host": bad})) is None +def test_a_tunnel_that_says_no_scheme_takes_the_scheme_of_the_address_the_deploy_reaches_the_gateway_at(): + def request(headers: dict) -> Request: + return Request({"type": "http", "scheme": "http", "path": "/", "headers": + [(k.lower().encode(), v.encode()) for k, v in headers.items()]}) + + tunnel = "https://gw-18765.w.modal.host" + assert public_base(request({"host": "gw-18765.w.modal.host"}), tunnel) == tunnel + assert public_base(request({"host": "GW-18765.w.modal.host"}), tunnel) == "https://GW-18765.w.modal.host" + assert public_base(request({"host": "other.example"}), tunnel) == "http://other.example" + assert public_base(request({"host": "gw-18765.w.modal.host", "x-forwarded-proto": "http"}), tunnel) == ( + "http://gw-18765.w.modal.host") + + def test_no_log_line_shows_a_cdp_addresss_token(): record = logging.LogRecord("uvicorn.error", logging.INFO, __file__, 1, '%s - "WebSocket %s" [accepted]', ("127.0.0.1:5", f"/browser/web/cdp/{TOKEN}/devtools/browser"), None) diff --git a/tst/unit/providers/env_providers/env_gateway_provider_test.py b/tst/unit/providers/env_providers/env_gateway_provider_test.py index 1a986b74f..cad7194c6 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -29,7 +29,7 @@ from agent_env.env.env import DeployedGatewayEnv, _mcp_url from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.multi_env import MultiEnv -from agent_env.env.envs.service_db import SERVICE_DB_PORT +from agent_env.env.envs.service_db import SERVICE_DB_PORT, ServiceDBConfig from agent_env.env.envs.website import WebsiteEnv from agent_env.providers.env_providers import env_gateway_provider, env_provider from agent_env.providers.env_providers.constants import GATEWAY_APP_DIR, GATEWAY_SERVICE_NAME @@ -1269,6 +1269,19 @@ def test_compose_gives_the_gateway_the_names_of_the_providers_request_headers_al assert {name: entries for name, entries in named.items() if entries} == expected +@pytest.mark.parametrize("public_url", ["https://gw.w.modal.host", None], ids=["tunnel", "none"]) +def test_the_gateway_alone_is_told_the_address_the_deploy_reaches_it_at(public_url): + compose = EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=[MCPServerConfig(image="mcp-slack", environment_name="slack")], gateway_image="agent-gateway", + state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance(), public_url=public_url) + + told = {name: [entry for entry in service.get("environment") or [] if "GATEWAY_PUBLIC_URL" in entry] + for name, service in yaml.safe_load(compose)["services"].items()} + expected = {GATEWAY_SERVICE_NAME: [f"AGENTENV_GATEWAY_PUBLIC_URL={public_url}"]} if public_url else {} + assert {name: entries for name, entries in told.items() if entries} == expected + + @pytest.mark.asyncio async def test_acquire_state_for_deploy_local_returns_none(): """Local Postgres cannot be pre-provisioned (its container's host doesn't exist yet), so the From 07a2b0f4b38587a5c261418567e3e5ab97ffa130 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 13:14:09 -0800 Subject: [PATCH 05/15] fix(multi): the run's browser computer counts for browser steps, an MCP browser's name is checked, and only Modal containers pull under an allowlist Review fixes: stored_browsers counts the browser computer a multi env resolves, the run's too, so browser steps pass preflight when the run names chromium; an MCP server browser's name is checked beside the computers and MCP servers, so a clash refuses rather than stopping the gateway; and PULLS_UNDER_ALLOWLIST is off by default, since the default VM path pulls under the allowlist, on only for Modal containers, whose platform pulls outside it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/agent_env/browsers/README.md | 2 +- src/agent_env/env/envs/multi_env.py | 27 +++++++++------ .../sandbox_providers/modal_sandbox.py | 1 + .../sandbox_providers/modal_vm_sandbox.py | 1 - .../sandbox_providers/sandbox_provider.py | 7 ++-- .../task_step/task_steps/browser/base.py | 6 ++-- tst/unit/env/envs/test_multi_env_computers.py | 33 +++++++++++++++++-- tst/unit/task_step/test_browser_sign_in.py | 2 +- 9 files changed, 58 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d8b080da..36807e304 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ and deserialized through a registry. | `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/`. | | `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), `agent37` (no 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`, `WEBSOCKETS`, `PULLS_UNDER_ALLOWLIST`, off for `modal_vm`, whose VM's Docker pulls a container's image through the sandbox's allowlist; `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). | +| `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), `agent37` (no 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`, `WEBSOCKETS`, `PULLS_UNDER_ALLOWLIST`, on only for `modal`, whose platform pulls a container's image outside the sandbox's network, and off by default, as the default VM path (`create_vm` under the policy, then `pull_images`) pulls through the allowlist; `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). | | `a2a_agent/` | The `A2AAgent` entity (`a2a_agent`), its stores and the validator steps. The protocol package provides the agent-side framework. | | `openenv/` | `build_app(bundle)`: the [OpenEnv](https://github.com/openenv/OpenEnv) server for a bundle's tasks, each session playing one episode through the Episode API, with the envs' MCP tools, an added `end_turn` tool and the task's verifier score as the reward. It prepares bundles as `agent-env run` does (`prepare_bundle(played=True)`, without the agent the client plays) and keeps namespace routing on for the app's life. `openenv` comes with the `openenv` extra, a floor with no cap; this package and `training/` are the only ones that import it. Each task's description in the Task API names its `toolset` (the played agent's envs, role, and whether it gets `bash`). With `token`, every request and WebSocket but `/health` must carry `Authorization: Bearer `, for a server others reach. | | `training/` | One module per outside trainer, each behind its own extra, and the modules they share; [Training helpers](#training-helpers) lists them. | diff --git a/src/agent_env/browsers/README.md b/src/agent_env/browsers/README.md index bde3e38cf..2c47a8bca 100644 --- a/src/agent_env/browsers/README.md +++ b/src/agent_env/browsers/README.md @@ -24,7 +24,7 @@ its website browser with `browser_envs = ["chromium"]`, in place of the run's `p sandbox of its own beside the multi env's computers, sized by the deploy step's `child_envs.chromium`, its tools served under `chromium_`, routing the multi env's synthetic sites through the gateway, and its network allowing the gateway's host and the live sites' hosts alone where the sandbox provider enforces an allowlist and pulls its image under one -(not yet `modal_vm`, which runs it allowing every host, as logged). +(today `modal`; on any other, `modal_vm` included, it runs allowing every host, as logged). A task changes a browser's state with the steps in `agent_env.task_step.task_steps.browser`, each a call to one `browser/v1` operation on the browser in the env `env_id`, the one `browser` names when the env has several: diff --git a/src/agent_env/env/envs/multi_env.py b/src/agent_env/env/envs/multi_env.py index 058446c88..f71b5557d 100644 --- a/src/agent_env/env/envs/multi_env.py +++ b/src/agent_env/env/envs/multi_env.py @@ -132,7 +132,7 @@ def __init__(self, id: str, version: Optional[int], mcp_server_envs: list[MCPSer self.browser_envs = list(browser_envs) if browser_envs is not None else None problems = browser_problems(self.browser_envs or []) if not problems: - problems = self._name_problems(self._browser_computer(self.browser_envs[0] if self.browser_envs else None)) + problems = self._name_problems(self.browser_envs[0] if self.browser_envs else None) if problems: raise ValueError(f"multi env {id!r}: {'; '.join(problems)}") self._sandbox = None @@ -148,15 +148,22 @@ def computer_envs(self) -> list[ComputerEnv]: """Its computers, each on a sandbox of its own, which the gateway fronts by its name: after a deploy, that deploy's.""" return self._computers.envs - def _name_problems(self, browser: Optional[ComputerEnv]) -> list[str]: - """What's wrong with the names of its computers, and ``browser``, its website browser when that is a computer, - beside its other child envs' (``_multi_computers.name_problems``, ``app_name_problems``).""" - computer_envs = [*self.computer_envs, *([browser] if browser is not None else [])] - servers = [env.environment_name for env in (*self.mcp_server_envs, *self.website_envs)] + def _name_problems(self, browser: Optional[Env]) -> list[str]: + """What's wrong with the names of its computers and ``browser``, its website browser, beside its other child + envs' (``_multi_computers.name_problems``, ``app_name_problems``): a browser computer named as a computer is, + and an MCP server browser as an MCP server, which no other MCP server may name either.""" + computer = self._browser_computer(browser) + server = browser if isinstance(browser, MCPServerEnv) else None + computer_envs = [*self.computer_envs, *([computer] if computer is not None else [])] + mcp_servers = [env.environment_name for env in (*self.mcp_server_envs, *([server] if server else []))] + websites = [env.environment_name for env in self.website_envs] + servers = [*mcp_servers, *websites] computers = [env.environment_name for env in computer_envs] - return [*_multi_computers.name_problems(computers, - mcp_servers=[env.environment_name for env in self.mcp_server_envs], - websites=[env.environment_name for env in self.website_envs]), + problems = [] + if server is not None and mcp_servers.count(server.environment_name) > 1: + problems.append(f"website browser {server.environment_name!r} has the name of another of its MCP servers, " + "and the gateway runs each as a container of that name") + return [*problems, *_multi_computers.name_problems(computers, mcp_servers=mcp_servers, websites=websites), *_multi_computers.app_name_problems({env.environment_name: [app.environment_name for app in env.composed_apps() if app is not env] # an app deployed alone @@ -381,7 +388,7 @@ def _child_envs_refusal(self, child_envs: Optional[dict], sandbox_type: str | No names = list(dict.fromkeys(env.environment_name for env in (*self.mcp_server_envs, *self.website_envs, *computers.values()))) # A browser browser_envs names had its names checked at put; the run's is checked here. - problems = self._name_problems(computer) if computer is not None and self.browser_envs is None else [] + problems = self._name_problems(browser) if browser is not None and self.browser_envs is None else [] for name, entry in child_envs.items(): extra = sorted(map(str, set(entry) - set(CHILD_ENV_OPTIONS))) if isinstance(entry, dict) else [] if name not in names: diff --git a/src/agent_env/providers/sandbox_providers/modal_sandbox.py b/src/agent_env/providers/sandbox_providers/modal_sandbox.py index ac52f2d32..d9732dede 100644 --- a/src/agent_env/providers/sandbox_providers/modal_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_sandbox.py @@ -222,6 +222,7 @@ async def write_file_from_url(self, url: str, destination_path: str) -> None: class ModalSandboxProvider(SandboxProvider): EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.modal.host", "*.w.modal.host") + PULLS_UNDER_ALLOWLIST = True # Modal pulls a container sandbox's image itself, outside the sandbox's network # Sandbox class this provider produces. A subclass or instance registered under a different # ``[sandbox.providers.]`` key can set it to a ``ModalSandbox`` subclass whose # ``type`` equals that name — the registry's type-guard requires the produced diff --git a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py index 2ce91d7a2..99312ab53 100644 --- a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py @@ -299,7 +299,6 @@ class ModalVmSandboxProvider(SandboxProvider): EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.modal.host", "*.w.modal.host") WEBSOCKETS = True - PULLS_UNDER_ALLOWLIST = False @classmethod def supports_network_policy(cls, policy: NetworkPolicy) -> bool: diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 6ac153027..a3de71fcf 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -252,9 +252,10 @@ def from_config(cls, **config: Any) -> Self: # provider. Derived for a provider class that doesn't declare it (``__init_subclass__``): on for the default VM # path, off for a container-only backend and for a class that overrides either method. SHARED_MEMORY: ClassVar[bool] = False - # Whether a container it runs under a network that allowlists hosts can still pull its image: off where the pull - # goes through the sandbox's own allowlist, which leaves the registry out, as a Modal VM's Docker does. - PULLS_UNDER_ALLOWLIST: ClassVar[bool] = True + # Whether a container it runs under a network that allowlists hosts can still pull its image: on only where the + # platform pulls it outside the sandbox's network, as Modal's containers do. Off by default: on the default VM path + # (create_vm with the policy, then pull_images) the pull goes through the allowlist, which leaves the registry out. + PULLS_UNDER_ALLOWLIST: ClassVar[bool] = False def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) diff --git a/src/agent_env/task_step/task_steps/browser/base.py b/src/agent_env/task_step/task_steps/browser/base.py index 913b24ee4..e52c135d0 100644 --- a/src/agent_env/task_step/task_steps/browser/base.py +++ b/src/agent_env/task_step/task_steps/browser/base.py @@ -88,10 +88,10 @@ def _record(self, context: TaskStepContext, **fields: Any) -> None: def stored_browsers(env: Env) -> list[str]: """The names of the website browsers a stored env deploys: a computer env's browser apps, the app itself for one - deployed alone, a multi env's computers' browser apps and the browser computer its browser_envs names; none for - any other env.""" + deployed alone, a multi env's computers' browser apps and its browser computer, the one its browser_envs names or + the run's; none for any other env.""" if isinstance(env, MultiEnv): - computers = [*env.computer_envs, *(env.browser_envs or [])] + computers = [*env.computer_envs, *([browser] if (browser := env.browser_computer()) else [])] return [name for computer in computers for name in stored_browsers(computer)] if not isinstance(env, ComputerEnv): return [] diff --git a/tst/unit/env/envs/test_multi_env_computers.py b/tst/unit/env/envs/test_multi_env_computers.py index 3d020bd55..230504d93 100644 --- a/tst/unit/env/envs/test_multi_env_computers.py +++ b/tst/unit/env/envs/test_multi_env_computers.py @@ -37,6 +37,7 @@ from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandboxProvider from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandboxProvider from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy +from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider from agent_env.task.teardown import env_sandbox_ids from agent_env.task_step.task_steps.browser.base import stored_browsers from tst.util.sandbox_providers import mock_provider @@ -787,6 +788,19 @@ def test_a_browser_computer_needs_a_name_no_other_computer_has(): _browsing(FakeComputer("chromium"), browser_envs=[FakeBrowser()]) +def test_an_mcp_server_browser_needs_a_name_no_computer_or_other_mcp_server_has(): + with pytest.raises(ValueError, match="'playwright-mcp' names 2 of its child envs"): + _browsing(FakeComputer("playwright-mcp"), browser_envs=[_playwright()]) + with pytest.raises(ValueError, match="website browser 'playwright-mcp' has the name of another of its MCP servers"): + MultiEnv(id="office", version=1, mcp_server_envs=[_playwright()], browser_envs=[_playwright()]) + + +def test_the_runs_mcp_server_browser_is_refused_a_name_a_computer_has(providers): + with patch("agent_env.env.env.Env.get", return_value=_playwright()): + assert "'playwright-mcp' names 2 of its child envs" in _browsing( + FakeComputer("playwright-mcp")).child_envs_refusal(None) + + # --- its website browser: which one --- def test_the_browser_is_the_one_named_none_for_an_empty_list_and_else_the_runs_when_there_are_websites(local_stores): @@ -1008,9 +1022,14 @@ def test_a_disk_held_to_the_gateway_is_not_let_off_by_a_provider_that_cant_pull_ assert policy == NetworkPolicy(mode=NetworkMode.ALLOWLIST, allow_hosts=("gw.example",)) -def test_modal_vm_says_it_cant_pull_a_container_under_an_allowlist_and_others_say_they_can(): - assert not ModalVmSandboxProvider.PULLS_UNDER_ALLOWLIST - assert ModalSandboxProvider.PULLS_UNDER_ALLOWLIST and LocalSandboxProvider.PULLS_UNDER_ALLOWLIST +def test_only_a_provider_that_pulls_outside_its_sandboxs_network_says_it_pulls_under_an_allowlist(): + class VmPath(SandboxProvider): # the default VM path: create_vm under the policy, then pull_images + async def create_vm(self, **kwargs): + raise NotImplementedError + + assert ModalSandboxProvider.PULLS_UNDER_ALLOWLIST + assert not any(provider.PULLS_UNDER_ALLOWLIST + for provider in (ModalVmSandboxProvider, LocalSandboxProvider, VmPath)) def test_a_computers_own_network_is_widened_as_before_reach_only_or_not(): @@ -1030,6 +1049,14 @@ def test_a_named_browser_computer_counts_among_a_multi_envs_browsers(): assert stored_browsers(_browsing(browser_envs=[_playwright()])) == [] +def test_the_runs_browser_computer_counts_among_a_multi_envs_browsers_when_it_names_none(): + with patch("agent_env.env.env.Env.get", return_value=FakeBrowser()), \ + run_env_ids({WEBSITE_BROWSER_OVERRIDE: "chromium-computer"}): + assert stored_browsers(_browsing()) == ["chromium"] + with patch("agent_env.env.env.Env.get", return_value=_playwright()): + assert stored_browsers(_browsing()) == [] + + def test_cli_put_takes_a_browser_or_none(): chromium = FakeBrowser() slack = MCPServerEnv("slack", 1, docker_image_artifact=_image("slack-image"), environment_name="slack") diff --git a/tst/unit/task_step/test_browser_sign_in.py b/tst/unit/task_step/test_browser_sign_in.py index b10556f74..c8981a851 100644 --- a/tst/unit/task_step/test_browser_sign_in.py +++ b/tst/unit/task_step/test_browser_sign_in.py @@ -201,5 +201,5 @@ def test_a_multi_env_deploys_its_computers_browsers(): computer = ComputerEnv.__new__(ComputerEnv) computer.composed_apps = lambda: [app] office = MultiEnv.__new__(MultiEnv) - office._computers, office.browser_envs = SimpleNamespace(envs=[computer]), None + office._computers, office.browser_envs, office.website_envs = SimpleNamespace(envs=[computer]), None, [] assert stored_browsers(office) == ["chromium"] From 289740675ebe94375e202ce204b382ee47ea265b Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 13:54:18 -0800 Subject: [PATCH 06/15] test(multi): the browser test finds its gateway among the sandboxes its own process made, as main's tests now do Co-Authored-By: Claude Opus 5.5 --- tst/integration/computer/multi_env_browser_test.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tst/integration/computer/multi_env_browser_test.py b/tst/integration/computer/multi_env_browser_test.py index db3a7a717..b5d296803 100644 --- a/tst/integration/computer/multi_env_browser_test.py +++ b/tst/integration/computer/multi_env_browser_test.py @@ -32,6 +32,7 @@ from tst.integration.computer.browser_computer_test import _cdp_agent_loads from tst.integration.computer.multi_env_computers_test import _containers, _gateway, _image from tst.integration.store.local_parallel_deploy_test import _seed_bootstrap_envs +from tst.util import local_sandboxes pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -53,6 +54,12 @@ def shop(browser_computer): backend_docker_image_artifact=_image("agentenv_website", f"browsing-shop-{uid}")) +@pytest.fixture +def made(monkeypatch) -> list[str]: + """The ids of the local sandboxes this test's process makes, so it finds and counts only its own.""" + return local_sandboxes.recorded_sandbox_ids(monkeypatch) + + def _office(shop, browser_envs) -> MultiEnv: return MultiEnv.put(id=f"@local/~/browsing-{uuid.uuid4().hex[:8]}/office", mcp_server_envs=[], website_envs=[shop], browser_envs=browser_envs, name="office") @@ -110,7 +117,7 @@ async def _discovery(http_url: str) -> dict: @pytest.mark.asyncio async def test_chromium_as_a_multi_envs_browser_runs_on_its_own_sandbox_reached_through_the_gateway( - browser_computer, shop, caplog): + browser_computer, shop, made, caplog): caplog.set_level(logging.WARNING, logger="agent_env.providers.env_providers.env_computer_provider") with namespace_routing(): office = _office(shop, [Env.get(browser_computer.id)]) @@ -132,7 +139,7 @@ async def test_chromium_as_a_multi_envs_browser_runs_on_its_own_sandbox_reached_ after_kill = await _answers_again(deployed) after_kill_tabs = (await _drive(deployed))["tabs"] - subprocess.run(["docker", "restart", _gateway(deployed)], capture_output=True, check=True) + subprocess.run(["docker", "restart", _gateway(deployed, made)], capture_output=True, check=True) through_restart = await _answers_again(deployed) await ResetEnvTaskStep(id="reset-2", version=None, env_id=office.id, fail_task_on_error=True).execute(context) From 13eb5e1a898ae52a4b736b80a7891175614c8cb6 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 14:58:50 -0800 Subject: [PATCH 07/15] test(multi): a failed redeploy closes only its own browser computer and leaves the earlier one for close Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/_multi_computers.py | 2 +- tst/unit/env/envs/test_multi_env_computers.py | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/agent_env/env/envs/_multi_computers.py b/src/agent_env/env/envs/_multi_computers.py index 0e893921f..9b11f76ce 100644 --- a/src/agent_env/env/envs/_multi_computers.py +++ b/src/agent_env/env/envs/_multi_computers.py @@ -132,7 +132,7 @@ def __init__(self, envs: list[ComputerEnv], owner: str) -> None: self._entries: dict = {} # the deploy's child_envs entries, by computer self._live: tuple[str, ...] = () # the live hosts of the multi env's live websites self._replaced: list[ComputerEnv] = [] - self._earlier: Optional[tuple[list[ComputerEnv], list[ComputerEnv]]] = None + self._earlier: Optional[tuple[list[ComputerEnv], Optional[ComputerEnv], list[ComputerEnv]]] = None @property def every(self) -> list[ComputerEnv]: diff --git a/tst/unit/env/envs/test_multi_env_computers.py b/tst/unit/env/envs/test_multi_env_computers.py index 230504d93..2c1dcff3e 100644 --- a/tst/unit/env/envs/test_multi_env_computers.py +++ b/tst/unit/env/envs/test_multi_env_computers.py @@ -978,6 +978,32 @@ async def _reattached(cls, record): return browser +@pytest.mark.asyncio +async def test_a_failed_redeploy_closes_only_its_own_browser_and_leaves_the_earlier_one_for_close(gateway, providers, + routes): + earlier = FakeBrowser() + office = _browsing(browser_envs=[earlier]) + await office.deploy() + fresh = [] + + def get(id, version=None): + fresh.append(FakeBrowser()) + return fresh[-1] + + gateway.fails = RuntimeError("no gateway") + with patch("agent_env.env.env.Env.get", side_effect=get), pytest.raises(RuntimeError, match="^no gateway$"): + await office.deploy() + assert office._computers.browser is earlier and earlier.closes == 0 and [b.closes for b in fresh] == [1] + + gateway.fails = None + with patch("agent_env.env.env.Env.get", side_effect=get): + await office.deploy() + assert office._computers.browser is fresh[1] and earlier.closes == 0 + + await office.close() + assert earlier.closes == 1 and [b.closes for b in fresh] == [1, 1] + + @pytest.mark.asyncio async def test_a_redeploy_keeps_the_earlier_browser_for_close(gateway, providers, routes): chromium = FakeBrowser() From cce2785a4d516056dae3afbac6b5a3842ba85612 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 15:06:55 -0800 Subject: [PATCH 08/15] refactor(multi): the website browser's network is its own env's, as any computer child's Edgar's call on review: holding chromium's network to the gateway was in force only on Modal containers (local Docker can't enforce an allowlist, and on every VM-path provider the image pull is cut off by it), so it is taken out for now: reach_only, the fallback and SandboxProvider.PULLS_UNDER_ALLOWLIST go, and a browser computer's network is its env's, widened to the gateway's host and the live hosts only when that is an allowlist, as M5.6 does. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- src/agent_env/browsers/README.md | 5 +- src/agent_env/env/envs/_multi_computers.py | 5 +- src/agent_env/env/envs/computer/env.py | 12 ++-- .../env_providers/env_computer_provider.py | 40 ++--------- .../sandbox_providers/modal_sandbox.py | 1 - .../sandbox_providers/sandbox_provider.py | 4 -- .../computer/multi_env_browser_test.py | 10 +-- tst/unit/env/envs/test_multi_env_computers.py | 66 ++----------------- 9 files changed, 24 insertions(+), 123 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 36807e304..9f7061b64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,12 +121,12 @@ 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 and computers; its website browser is the one its `browser_envs` names, none for `[]`, else the run's when it has websites: an MCP server env such as `playwright-mcp` runs among the gateway's MCP servers, and a browser computer such as `chromium` on a sandbox of its own as one more computer child, sized by its `child_envs` entry, its tools under its name and `_`, routing the multi env's synthetic sites unless that entry's `site_routing` says, its network held to the gateway's host and the live sites' hosts (`reach_only`) where the provider enforces an allowlist and can pull a container's image under one, else allowing every host, as logged; a website env deployed alone refuses a browser computer as the run's browser), `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; its env.toml's `ready_timeout_s`, how long it may take from its server's container starting to ready, is that container's health check `start_period` and what the deploy waits, or its card's `ready` timeout if longer, and its `shm_mb` is the container's `shm_size`, each left to the gateway and Docker without them; 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; a live one, `live = true`, is a real site whose server serves none of its pages, its `live`, `domains` and `allow_hosts` read at put from its server's `@website(...)` (`utils/card_naming.py` `WebsiteSource`) unless given, its `settings`' secrets only as references the gateway resolves as it writes the site's container (`WEBSITE_SETTINGS_ENV`); the gateway gives it no site route, a computer's site table leaves it out, a computer whose network is an allowlist is let reach its names (`MultiEnv.live_hosts`), a browser alone's forward port takes them straight to the real site (`site_proxy.py` `direct`), as the browser base's stand-in proxy takes any host, refusing nothing, each proxy logging each such connection as a JSON line, and a run that deploys one is marked live, `reproducible: false`), `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.]`, or for one distribution `[install."/"]`, which replaces its OS type's there, as its `known_failures` do (`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, its `/dev/shm` the most its apps' Linux installs ask for (`shm_mb`, in MiB, which its composed computer and the deploy record keep; `--shm-size` on a container, `AGENTENV_SHM_MB` read by a Linux disk's init, passed only to a provider whose `SHARED_MEMORY` says it gives that much, else the sandbox's default, which the deploy logs), 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; `@linux(distribution=...)` or `@install("/", spec)` for one distribution, which replaces its OS type's there) 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/`, its scheme the one `X-Forwarded-Proto` names, else, for a request to the host the deploy reaches the gateway at (`AGENTENV_GATEWAY_PUBLIC_URL`, the gateway's tunnel URL on a VM), that URL's, as Modal's tunnel sends none; 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 and computers; its website browser is the one its `browser_envs` names, none for `[]`, else the run's when it has websites: an MCP server env such as `playwright-mcp` runs among the gateway's MCP servers, and a browser computer such as `chromium` on a sandbox of its own as one more computer child, sized by its `child_envs` entry, its tools under its name and `_`, routing the multi env's synthetic sites unless that entry's `site_routing` says, its network its own env's, as any computer child's; a website env deployed alone refuses a browser computer as the run's browser), `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; its env.toml's `ready_timeout_s`, how long it may take from its server's container starting to ready, is that container's health check `start_period` and what the deploy waits, or its card's `ready` timeout if longer, and its `shm_mb` is the container's `shm_size`, each left to the gateway and Docker without them; 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; a live one, `live = true`, is a real site whose server serves none of its pages, its `live`, `domains` and `allow_hosts` read at put from its server's `@website(...)` (`utils/card_naming.py` `WebsiteSource`) unless given, its `settings`' secrets only as references the gateway resolves as it writes the site's container (`WEBSITE_SETTINGS_ENV`); the gateway gives it no site route, a computer's site table leaves it out, a computer whose network is an allowlist is let reach its names (`MultiEnv.live_hosts`), a browser alone's forward port takes them straight to the real site (`site_proxy.py` `direct`), as the browser base's stand-in proxy takes any host, refusing nothing, each proxy logging each such connection as a JSON line, and a run that deploys one is marked live, `reproducible: false`), `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.]`, or for one distribution `[install."/"]`, which replaces its OS type's there, as its `known_failures` do (`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, its `/dev/shm` the most its apps' Linux installs ask for (`shm_mb`, in MiB, which its composed computer and the deploy record keep; `--shm-size` on a container, `AGENTENV_SHM_MB` read by a Linux disk's init, passed only to a provider whose `SHARED_MEMORY` says it gives that much, else the sandbox's default, which the deploy logs), 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; `@linux(distribution=...)` or `@install("/", spec)` for one distribution, which replaces its OS type's there) 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/`, its scheme the one `X-Forwarded-Proto` names, else, for a request to the host the deploy reaches the gateway at (`AGENTENV_GATEWAY_PUBLIC_URL`, the gateway's tunnel URL on a VM), that URL's, as Modal's tunnel sends none; 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/`. | | `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), `agent37` (no 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`, `WEBSOCKETS`, `PULLS_UNDER_ALLOWLIST`, on only for `modal`, whose platform pulls a container's image outside the sandbox's network, and off by default, as the default VM path (`create_vm` under the policy, then `pull_images`) pulls through the allowlist; `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). | +| `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), `agent37` (no 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`, `WEBSOCKETS`; `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). | | `a2a_agent/` | The `A2AAgent` entity (`a2a_agent`), its stores and the validator steps. The protocol package provides the agent-side framework. | | `openenv/` | `build_app(bundle)`: the [OpenEnv](https://github.com/openenv/OpenEnv) server for a bundle's tasks, each session playing one episode through the Episode API, with the envs' MCP tools, an added `end_turn` tool and the task's verifier score as the reward. It prepares bundles as `agent-env run` does (`prepare_bundle(played=True)`, without the agent the client plays) and keeps namespace routing on for the app's life. `openenv` comes with the `openenv` extra, a floor with no cap; this package and `training/` are the only ones that import it. Each task's description in the Task API names its `toolset` (the played agent's envs, role, and whether it gets `bash`). With `token`, every request and WebSocket but `/health` must carry `Authorization: Bearer `, for a server others reach. | | `training/` | One module per outside trainer, each behind its own extra, and the modules they share; [Training helpers](#training-helpers) lists them. | diff --git a/src/agent_env/browsers/README.md b/src/agent_env/browsers/README.md index 2c47a8bca..28801e34d 100644 --- a/src/agent_env/browsers/README.md +++ b/src/agent_env/browsers/README.md @@ -22,9 +22,8 @@ the image has none of, so each deploy and each reset starts with a clean one. Na the alias `aliases.toml` gives it, or by its id, `@local/agentenv-framework/browsers/chromium`. A multi env names it as its website browser with `browser_envs = ["chromium"]`, in place of the run's `playwright-mcp`: it then deploys on a sandbox of its own beside the multi env's computers, sized by the deploy step's `child_envs.chromium`, its tools served -under `chromium_`, routing the multi env's synthetic sites through the gateway, and its network allowing the gateway's -host and the live sites' hosts alone where the sandbox provider enforces an allowlist and pulls its image under one -(today `modal`; on any other, `modal_vm` included, it runs allowing every host, as logged). +under `chromium_`, routing the multi env's synthetic sites through the gateway, its network its own env's, as any +computer child's. A task changes a browser's state with the steps in `agent_env.task_step.task_steps.browser`, each a call to one `browser/v1` operation on the browser in the env `env_id`, the one `browser` names when the env has several: diff --git a/src/agent_env/env/envs/_multi_computers.py b/src/agent_env/env/envs/_multi_computers.py index 9b11f76ce..552b87ced 100644 --- a/src/agent_env/env/envs/_multi_computers.py +++ b/src/agent_env/env/envs/_multi_computers.py @@ -187,8 +187,7 @@ def deployer(self, child_envs: Optional[dict], *, sandbox_type: str | None, ttl_ ) -> Callable[[Optional[str]], Awaitable[list[ExternalChild]]]: """The gateway's ``children``: every computer deployed as its child_envs entry says, its tools served under its name and ``_``, and its network allowing ``reach``, the gateway's address when the gateway has one yet, and the - ``live_hosts`` of the multi env's live websites, the website browser's those hosts alone; then each as the - gateway fronts it. A computer routing the multi env's ``websites`` (their environment names by env id) + ``live_hosts`` of the multi env's live websites; then each as the gateway fronts it. A computer routing the multi env's ``websites`` (their environment names by env id) boots with its boot apps held, for ``finish`` to route them once the gateway is up. A gateway deploy tried again deploys them again, after closing the earlier try's.""" tries = 0 @@ -207,7 +206,7 @@ async def deploy(reach: Optional[str]) -> list[ExternalChild]: results = await asyncio.gather(*(env.deploy( ttl_seconds=ttl_seconds, attribution=attribution, tool_prefix=f"{env.environment_name}_", reach=reach or "", sites_later=env.environment_name in self._routes, - **({"live_hosts": live} if live else {}), **({"reach_only": True} if env is self.browser else {}), + **({"live_hosts": live} if live else {}), **deploy_options((child_envs or {}).get(env.environment_name), sandbox_type)) for env in self.every), return_exceptions=True) if failed := [result for result in results if isinstance(result, BaseException)]: diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index a3bc3c693..cc4bf169e 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -287,19 +287,17 @@ async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, *, attribution: Optional[Attribution] = None, env_params: Optional[dict] = None, tool_prefix: str = "", reach: str = "", - sites_later: bool = False, live_hosts: tuple[str, ...] = (), - reach_only: bool = False) -> DeployedEnv: + sites_later: bool = False, live_hosts: tuple[str, ...] = ()) -> DeployedEnv: """With ``tool_prefix``, its driver serves its tools under it, with ``reach``, a network that restricts its - egress allows that URL's host too, and the ``live_hosts`` of the run's live websites, with ``reach_only`` a - network that allows every host is held to those hosts, and with ``sites_later`` it routes the sites - ``env_params.site_routing`` reaches when ``route_sites`` gives them: what a multi env gives each of its - computers, and its website browser.""" + egress allows that URL's host too, and the ``live_hosts`` of the run's live websites, and with ``sites_later`` + it routes the sites ``env_params.site_routing`` reaches when ``route_sites`` gives them: what a multi env gives + each of its computers.""" return await deploy_through_provider( self, environment_name=self.environment_name, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type, then=self._give_tools, disk_size_gb=disk_size_gb, gateway_mode=gateway_mode, cpu=cpu, memory_mb=memory_mb, env_state_type=env_state_type, env_state_instance_id=env_state_instance_id, attribution=attribution, env_params=env_params, tool_prefix=tool_prefix, reach=reach, sites_later=sites_later, - live_hosts=tuple(live_hosts), reach_only=reach_only) + live_hosts=tuple(live_hosts)) async def route_sites(self, sites: list[dict]) -> None: """Route ``sites`` on this computer, deployed with ``sites_later``, launch its held apps and finish its boot; 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 0cebf78b2..1322c06ba 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -43,7 +43,7 @@ ) from agent_env.env.gateway.constants import WELL_KNOWN_PATH from agent_env.providers.env_providers.env_provider import DeployRefused, _SandboxEnvironmentProvider -from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy +from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy from agent_env.providers.sandbox_providers.sandbox_provider import build_sandbox_provider from agent_env.utils.progress import current_progress @@ -84,14 +84,12 @@ async def deploy( reach: str = "", sites_later: bool = False, live_hosts: tuple[str, ...] = (), - reach_only: bool = False, ) -> DeployedSandboxEnv: """The env's first ready build, in its base's order of forms and arches (or of ``env_params.form``), that the sandbox provider runs natively, booted, once what it boots that no compose started is composed; returns the record once the driver is ready with the env's display. With ``tool_prefix``, its driver serves its tools under it; with ``reach``, a URL this process reaches, a network that restricts the computer's egress allows its host too, and the - ``live_hosts`` of the run's live websites; with ``reach_only``, a network that allows every host is held to - those hosts alone, where the provider can enforce that. With + ``live_hosts`` of the run's live websites. With ``sites_later``, a computer routing sites, whose sites aren't up yet, boots with its boot apps held and returns once its driver answers; ``route_sites`` routes them and finishes its boot.""" from agent_env.env.envs.computer.env import ComputerEnv @@ -126,7 +124,7 @@ async def deploy( attempt = functools.partial(self._boot, env, builds, params, values, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, attribution=attribution, tool_prefix=tool_prefix, reach=reach, sites_later=sites_later, - live_hosts=tuple(live_hosts), reach_only=reach_only) + live_hosts=tuple(live_hosts)) return await self._run(sandbox_provider, env.id, attempt) async def route_sites(self, env: ComputerEnv, record: DeployedSandboxEnv, sites: list[dict]) -> None: @@ -148,8 +146,7 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par values: dict, sandbox_provider: SandboxProvider, *, ttl_seconds: int, disk_size_gb: float, cpu: float | None, memory_mb: int | None, attribution: Optional[Attribution], tool_prefix: str = "", reach: str = "", - sites_later: bool = False, live_hosts: tuple[str, ...] = (), - reach_only: bool = False) -> DeployedSandboxEnv: + sites_later: bool = False, live_hosts: tuple[str, ...] = ()) -> DeployedSandboxEnv: """One attempt on one sandbox provider: the first build it runs natively, booted and readied; its record.""" from agent_env.env.envs.computer.env import ComputerEnv @@ -174,7 +171,8 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par values = _values(computer, values) app_settings = recorded_app_settings(env.composed_apps(), computer_params.app_settings(env, params.app_settings), platform_of(env.computer.os)) - policy = _network(env, sandbox_provider, reach, live_hosts, reach_only, form) + policy = _reaching(NetworkPolicy.from_dict(env.network) if env.network else None, sandbox_provider, reach, + live_hosts) shm_mb = env.shm_mb() booted = await boot( sandbox_provider, image=build["image"], port=env.driver.port, network_policy=policy, token=token, @@ -348,32 +346,6 @@ async def end_recordings(record: DeployedSandboxEnv, reason: str) -> None: logger.warning(f"'{record.env_id}': its recordings weren't ended before its computer went: {e}") -def _network(env: ComputerEnv, sandbox_provider: SandboxProvider, reach: str, live_hosts: tuple[str, ...], - reach_only: bool, form: str = "container") -> Optional[NetworkPolicy]: - """The computer's network: its own, also allowing ``reach``'s host and ``live_hosts`` when it lists the hosts - allowed (``_reaching``), and with ``reach_only`` one that allows every host held to those hosts alone. A provider - that can't enforce that, or can't pull a container's image under it (``PULLS_UNDER_ALLOWLIST``), or a gateway - whose address isn't known yet, leaves it allowing every host, as logged.""" - own = NetworkPolicy.from_dict(env.network) if env.network else None - if not reach_only or (own is not None and own.restricts_egress): - return _reaching(own, sandbox_provider, reach, live_hosts) - if not (reach and urlsplit(sandbox_provider.url_from_sandbox(reach)).hostname): - logger.warning(f"{env.id}: the gateway's address isn't known when it boots on {type(sandbox_provider).__name__}" - ", so its network allows every host, not the gateway's alone") - return own - held = _reaching(NetworkPolicy(mode=NetworkMode.ALLOWLIST), sandbox_provider, reach, live_hosts) - if not sandbox_provider.supports_network_policy(held): - logger.warning(f"{env.id}: {type(sandbox_provider).__name__} can't hold a network to the hosts " - f"{', '.join(held.allow_hosts)}, so its network allows every host; use it for development only") - return own - if form == "container" and not sandbox_provider.PULLS_UNDER_ALLOWLIST: - logger.warning(f"{env.id}: {type(sandbox_provider).__name__} pulls a container's image through its " - f"network, which an allowlist of {', '.join(held.allow_hosts)} would cut off, so its network " - "allows every host") - return own - return held - - def _reaching(policy: Optional[NetworkPolicy], sandbox_provider: SandboxProvider, url: str, live_hosts: tuple[str, ...] = ()) -> Optional[NetworkPolicy]: """``policy``, also allowing ``url``'s host as the sandbox provider's sandboxes reach it, and ``live_hosts``, when diff --git a/src/agent_env/providers/sandbox_providers/modal_sandbox.py b/src/agent_env/providers/sandbox_providers/modal_sandbox.py index d9732dede..ac52f2d32 100644 --- a/src/agent_env/providers/sandbox_providers/modal_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_sandbox.py @@ -222,7 +222,6 @@ async def write_file_from_url(self, url: str, destination_path: str) -> None: class ModalSandboxProvider(SandboxProvider): EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.modal.host", "*.w.modal.host") - PULLS_UNDER_ALLOWLIST = True # Modal pulls a container sandbox's image itself, outside the sandbox's network # Sandbox class this provider produces. A subclass or instance registered under a different # ``[sandbox.providers.]`` key can set it to a ``ModalSandbox`` subclass whose # ``type`` equals that name — the registry's type-guard requires the produced diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index a3de71fcf..1cc76fcca 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -252,10 +252,6 @@ def from_config(cls, **config: Any) -> Self: # provider. Derived for a provider class that doesn't declare it (``__init_subclass__``): on for the default VM # path, off for a container-only backend and for a class that overrides either method. SHARED_MEMORY: ClassVar[bool] = False - # Whether a container it runs under a network that allowlists hosts can still pull its image: on only where the - # platform pulls it outside the sandbox's network, as Modal's containers do. Off by default: on the default VM path - # (create_vm with the policy, then pull_images) the pull goes through the allowlist, which leaves the registry out. - PULLS_UNDER_ALLOWLIST: ClassVar[bool] = False def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) diff --git a/tst/integration/computer/multi_env_browser_test.py b/tst/integration/computer/multi_env_browser_test.py index b5d296803..e1ad10427 100644 --- a/tst/integration/computer/multi_env_browser_test.py +++ b/tst/integration/computer/multi_env_browser_test.py @@ -2,13 +2,12 @@ on a sandbox of its own as one more computer child, beside a synthetic site, with no playwright-mcp. It routes the site, so its start tab loads it by name through the gateway; an agent drives it through the gateway's one /mcp with its tools under ``chromium_``; its CDP address, asked through ``/svc/chromium/``, names the gateway and reaches the site by name; -reset_env brings it back after its container is killed and after the gateway restarts; and local Docker, which can't -hold its network to the gateway, runs it allowing every host and says so. ``browser_envs = []`` deploys no browser. +and reset_env brings it back after its container is killed and after the gateway restarts. ``browser_envs = []`` +deploys no browser. Needs Docker and the local registry on localhost:5000.""" import asyncio import json -import logging import subprocess import uuid @@ -117,8 +116,7 @@ async def _discovery(http_url: str) -> dict: @pytest.mark.asyncio async def test_chromium_as_a_multi_envs_browser_runs_on_its_own_sandbox_reached_through_the_gateway( - browser_computer, shop, made, caplog): - caplog.set_level(logging.WARNING, logger="agent_env.providers.env_providers.env_computer_provider") + browser_computer, shop, made): with namespace_routing(): office = _office(shop, [Env.get(browser_computer.id)]) step = DeployEnvTaskStep(id="office", version=None, env_id=office.id, sandbox_type="local", ttl_seconds=900, @@ -171,8 +169,6 @@ async def test_chromium_as_a_multi_envs_browser_runs_on_its_own_sandbox_reached_ # Through a kill and a gateway restart, a reset brings it back. assert after_kill and SITE_URL in after_kill_tabs, after_kill_tabs assert after_restart, through_restart - # Local Docker can't hold its network to the gateway, so it says it allows every host. - assert "can't hold a network to the hosts" in caplog.text, caplog.text @pytest.mark.asyncio diff --git a/tst/unit/env/envs/test_multi_env_computers.py b/tst/unit/env/envs/test_multi_env_computers.py index 2c1dcff3e..dcfce853d 100644 --- a/tst/unit/env/envs/test_multi_env_computers.py +++ b/tst/unit/env/envs/test_multi_env_computers.py @@ -9,7 +9,6 @@ import asyncio import dataclasses import inspect -import logging import re from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -29,15 +28,10 @@ from agent_env.env.gateway import ExternalChild from agent_env.env.gateway.constants import WELL_KNOWN_PATH from agent_env.env.store import EnvInstanceStore -from agent_env.providers.env_providers import env_computer_provider, env_gateway_provider +from agent_env.providers.env_providers import env_gateway_provider from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider, MCPServerConfig from agent_env.providers.env_providers.env_provider import EnvironmentProvider from agent_env.providers.env_state import LocalPostgresStateProvider -from agent_env.providers.sandbox_providers.local_docker.provider import LocalSandboxProvider -from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandboxProvider -from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandboxProvider -from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy -from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider from agent_env.task.teardown import env_sandbox_ids from agent_env.task_step.task_steps.browser.base import stored_browsers from tst.util.sandbox_providers import mock_provider @@ -877,10 +871,10 @@ async def test_a_browser_computer_deploys_as_a_computer_child_routing_the_sites_ assert chromium.deploys == [{"ttl_seconds": 10800, "attribution": None, "tool_prefix": "chromium_", "reach": GATEWAY, "sites_later": True, - "live_hosts": ("github.test", "api.github.test"), "reach_only": True, + "live_hosts": ("github.test", "api.github.test"), "sandbox_type": "modal", "cpu": 2, "memory_mb": 4096, "env_params": {"site_routing": True}}] - assert "reach_only" not in desktop.deploys[0] and desktop.deploys[0]["sites_later"] is False + assert desktop.deploys[0]["sites_later"] is False assert gateway.started == [[ExternalChild("desktop", "https://desktop.example/mcp", "https://desktop.example"), ExternalChild("chromium", "https://chromium.example/mcp", "https://chromium.example")]] assert set(deployed.child_envs) == {"desktop", "chromium"} @@ -893,7 +887,7 @@ async def test_the_runs_browser_computer_takes_the_same_path(gateway, providers, with patch("agent_env.env.env.Env.get", return_value=chromium), \ run_env_ids({WEBSITE_BROWSER_OVERRIDE: "chromium-computer"}): deployed = await _browsing().deploy() - assert chromium.deploys[0]["reach_only"] is True and set(deployed.child_envs) == {"chromium"} + assert chromium.deploys[0]["tool_prefix"] == "chromium_" and set(deployed.child_envs) == {"chromium"} @pytest.mark.asyncio @@ -1016,58 +1010,6 @@ async def test_a_redeploy_keeps_the_earlier_browser_for_close(gateway, providers assert chromium.log.count(("close", "chromium")) == 2 # the earlier deployment's browser and the later one's -# --- its website browser: its network --- - -def _provider(enforces: bool = True, pulls: bool = True) -> SimpleNamespace: - return SimpleNamespace(url_from_sandbox=lambda url: url, supports_network_policy=lambda policy: enforces, - PULLS_UNDER_ALLOWLIST=pulls) - - -def test_a_reach_only_network_allows_the_gateways_host_and_the_live_hosts_alone(): - env = SimpleNamespace(id="chromium-computer", network=None) - policy = env_computer_provider._network(env, _provider(), GATEWAY, ("github.test",), reach_only=True) - assert policy == NetworkPolicy(mode=NetworkMode.ALLOWLIST, allow_hosts=("gw.example", "github.test")) - - -@pytest.mark.parametrize("enforces, pulls, reach, why", [ - (False, True, GATEWAY, "can't hold a network to the hosts gw.example"), - (True, False, GATEWAY, "pulls a container's image through its network"), - (True, True, "", "the gateway's address isn't known"), -], ids=["provider-cant-enforce", "pull-under-allowlist", "no-gateway-address"]) -def test_a_reach_only_network_the_provider_cant_hold_allows_every_host_and_says_so(enforces, pulls, reach, why, - caplog): - env = SimpleNamespace(id="chromium-computer", network=None) - with caplog.at_level(logging.WARNING, logger=env_computer_provider.logger.name): - assert env_computer_provider._network(env, _provider(enforces, pulls), reach, (), reach_only=True) is None - assert why in caplog.text - - -def test_a_disk_held_to_the_gateway_is_not_let_off_by_a_provider_that_cant_pull_a_container_under_one(): - env = SimpleNamespace(id="chromium-computer", network=None) - policy = env_computer_provider._network(env, _provider(pulls=False), GATEWAY, (), reach_only=True, form="disk") - assert policy == NetworkPolicy(mode=NetworkMode.ALLOWLIST, allow_hosts=("gw.example",)) - - -def test_only_a_provider_that_pulls_outside_its_sandboxs_network_says_it_pulls_under_an_allowlist(): - class VmPath(SandboxProvider): # the default VM path: create_vm under the policy, then pull_images - async def create_vm(self, **kwargs): - raise NotImplementedError - - assert ModalSandboxProvider.PULLS_UNDER_ALLOWLIST - assert not any(provider.PULLS_UNDER_ALLOWLIST - for provider in (ModalVmSandboxProvider, LocalSandboxProvider, VmPath)) - - -def test_a_computers_own_network_is_widened_as_before_reach_only_or_not(): - own = {"mode": "allowlist", "allow_hosts": ["pypi.org"]} - env = SimpleNamespace(id="desktop-computer", network=own) - for reach_only in (False, True): - assert env_computer_provider._network(env, _provider(), GATEWAY, (), reach_only).allow_hosts == ( - "pypi.org", "gw.example") - alone = SimpleNamespace(id="x", network=None) - assert env_computer_provider._network(alone, _provider(), GATEWAY, (), False) is None - - # --- its website browser: elsewhere --- def test_a_named_browser_computer_counts_among_a_multi_envs_browsers(): From 7393cb07dd4b059c43128373e75dbd330b643df0 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 15:31:38 -0800 Subject: [PATCH 09/15] test(multi): the multi env browser tests join browser_computer_test, with a reattach, a load and the run's browser They share its composed chromium, so CI composes it once. The deploy test now also reattaches the browser from the instance store and loads a universe that sets its cookies through the multi env; a new test deploys a multi env naming no browser while the run names chromium. Co-Authored-By: Claude Opus 5.5 --- .../computer/browser_computer_test.py | 224 +++++++++++++++++- .../computer/multi_env_browser_test.py | 187 --------------- 2 files changed, 222 insertions(+), 189 deletions(-) delete mode 100644 tst/integration/computer/multi_env_browser_test.py diff --git a/tst/integration/computer/browser_computer_test.py b/tst/integration/computer/browser_computer_test.py index 9a220c6d4..387dd694d 100644 --- a/tst/integration/computer/browser_computer_test.py +++ b/tst/integration/computer/browser_computer_test.py @@ -21,7 +21,13 @@ loads for well over a minute after it's ready, but for one Google account check at launch. Routing sites, it goes out only through the shared site proxy's forward port: start tabs on a site over HTTPS and on a .localhost one load through it, an event stream comes through as it's sent, any other host and loopback are refused and logged, the session user can't stop the port, killed it comes back, and -after a reset it serves the sites and refuses the rest again. +after a reset it serves the sites and refuses the rest again. As a multi env's website browser, ``browser_envs = +[chromium]``, it deploys on a sandbox of its own as one more computer child beside a synthetic site, with no +playwright-mcp: its start tab loads the site by name through the gateway, an agent drives it through the gateway's /mcp +under ``chromium_``, its CDP address asked through ``/svc/chromium/`` names the gateway and reaches the site by name, a +later process reattaches it from the instance store, a universe loaded into the multi env sets its cookies, and +reset_env brings it back after its container is killed and after the gateway restarts; a multi env naming none takes +the run's browser, chromium too, the same way, and ``browser_envs = []`` deploys none. Needs Docker and the local registry on localhost:5000, and Node 20 or later and npm for the CDP clients.""" @@ -43,18 +49,26 @@ import httpx import pytest from mcp import ClientSession -from mcp.client.streamable_http import streamablehttp_client +from mcp.client.streamable_http import streamable_http_client, streamablehttp_client from PIL import Image from websockets.asyncio.client import connect from agentenv_protocol import client as protocol_v1 +from agentenv_protocol.client import find_extension_method, invoke_extension from agentenv_protocol.computer import COMPUTER_PAGE, COMPUTER_READY from agentenv_protocol.computer.conformance.client import image from agentenv_protocol.website_browser import BROWSER from agent_env.a2a_agent import conversation_store +from agent_env.artifact import EnvironmentUniverseArtifact +from agent_env.artifact.artifacts.environment import EnvironmentArtifact from agent_env.artifact.artifacts.file import FileArtifact +from agent_env.config.runtime import WEBSITE_BROWSER_OVERRIDE, run_env_ids from agent_env.env import Env +from agent_env.env.env import DeployedEnv +from agent_env.env.envs.multi_env import MultiEnv +from agent_env.env.envs.playwright_mcp import PLAYWRIGHT_MCP_ENVIRONMENT_NAME +from agent_env.env.envs.website import WebsiteEnv from agent_env.env.envs.computer.utils import inside from agent_env.env.store import get_env_store from agent_env.providers.sandbox_providers.local_docker.provider import LocalSandboxProvider @@ -65,12 +79,17 @@ from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_steps.browser.connect_cdp import BrowserConnectCdpTaskStep from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep +from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep +from agent_env.task_step.task_steps.load_artifact import LoadArtifactTaskStep from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep from agent_env.task_step.task_steps.reset_env import ResetEnvTaskStep from tst.integration.computer.browser_data import SITE as DATA_SITE, check_browser_data, check_browser_data_under_chaos from tst.integration.computer.browser_steps import check_browser_steps from tst.integration.computer.browser_state import check_browser_state, check_browser_state_under_chaos +from tst.integration.computer.multi_env_computers_test import _containers, _gateway, _image from tst.integration.computer.start_tabs import host_pages, tabs_of +from tst.integration.store.local_parallel_deploy_test import _seed_bootstrap_envs +from tst.util import local_sandboxes from tst.util.a2a_test_agent import put_test_agent from tst.util.tls_front import tls_front @@ -1062,3 +1081,204 @@ def _trusting(ca: LocalCA) -> ssl.SSLContext: async def _get(deployed, path: str, **kwargs) -> httpx.Response: async with httpx.AsyncClient(timeout=30) as http: return await http.get(f"{deployed.environment_url}{path}", **kwargs) + + +# --- a multi env's website browser (browser_envs) --- + +MULTI_SITE = "https://shop.test/api/health" +MULTI_CHROMIUM = {"sandbox_type": "local", + "env_params": {"form": "container", "app_settings": {"chromium": {"start_urls": [MULTI_SITE]}}}} +#: How often a multi env's browser is asked through the gateway whether it answers again. +ANSWER_POLL_S = 2.0 + + +@pytest.fixture(scope="module") +def shop(browser_computer): + """The gateway's and service-db's envs, and a website whose backend is the small agentenv website, named + shop.test, under a local id, since a multi env naming chromium's local id must be local too.""" + with namespace_routing(): + _seed_bootstrap_envs() + uid = uuid.uuid4().hex[:8] + return WebsiteEnv.put(id=f"@local/~/browsing-{uid}/shop", environment_name="shop", domains=["shop.test"], + backend_docker_image_artifact=_image("agentenv_website", f"browsing-shop-{uid}")) + + +@pytest.fixture +def made(monkeypatch) -> list[str]: + """The ids of the local sandboxes this test's process makes, so it finds and counts only its own.""" + return local_sandboxes.recorded_sandbox_ids(monkeypatch) + + +def _office(shop, browser_envs) -> MultiEnv: + return MultiEnv.put(id=f"@local/~/browsing-{uuid.uuid4().hex[:8]}/office", mcp_server_envs=[], + website_envs=[shop], browser_envs=browser_envs, name="office") + + +async def _drive(deployed) -> dict: + """What an agent sees through the gateway's /mcp: the tools it lists, a screenshot by chromium's name, and the + tabs after it opens the site by name.""" + async with streamable_http_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} + shot = await session.call_tool("chromium_computer_screenshot", {}) + opened = await session.call_tool("chromium_computer_navigate", {"url": MULTI_SITE}) + tabs = await session.call_tool("chromium_computer_tabs", {"action": "list"}) + return {"tools": tools, "shot": not shot.isError and any(part.type == "image" for part in shot.content), + "opened": not opened.isError, + "tabs": " ".join(part.text for part in tabs.content if part.type == "text")} + + +async def _answers_again(deployed) -> bool: + """Whether chromium's screenshot answers through the gateway, asked again until it does or ``WAIT_S`` passes.""" + deadline = asyncio.get_running_loop().time() + WAIT_S + while asyncio.get_running_loop().time() < deadline: + try: + async with streamable_http_client(deployed.mcp_url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + if not (await session.call_tool("chromium_computer_screenshot", {})).isError: + return True + except Exception: # noqa: BLE001 - the gateway or the browser restarting + pass + await asyncio.sleep(ANSWER_POLL_S) + return False + + +async def _cdp_at_gateway(deployed, browser: DeployedEnv) -> dict: + """The CDP address the browser gives, asked through the gateway's ``/svc/chromium/`` route with the browser's own + control token, as agentenv asks a computer child.""" + app = next(child for child in browser.environment_card["children_environments"] if child["name"] == "chromium") + path = protocol_v1.find_extension_method(app, BROWSER, "cdp")["endpoint"] + async with httpx.AsyncClient(timeout=180) as http: + answer = await http.post(f"{deployed.gateway_url}/svc/chromium{path}", json={"user": "ada"}, + headers=browser.control_headers) + assert answer.status_code == 200, answer.text + return answer.json() + + +def _browser_universe(env_id: str, cookies: list[dict]) -> EnvironmentUniverseArtifact: + """An environment universe whose chromium environment holds a browser state setting ``cookies``.""" + run = uuid.uuid4().hex[:8] + state = FileArtifact.put_bytes(id=f"{env_id}-browser-{run}", description="multi env browser e2e", + filename="browser-state.json", content=json.dumps({"cookies": cookies}).encode(), + content_type="application/json") + environment = EnvironmentArtifact.put(id=f"{env_id}-browser-env-{run}", environment_name="chromium", + file_artifacts={"browser-state.json": state}) + return EnvironmentUniverseArtifact.put(id=f"{env_id}-universe-{run}", environment_artifacts=[environment]) + + +async def _browser_op(browser: DeployedEnv, op: str, params: dict) -> dict: + """``op``'s answer from the browser app on ``browser``, a multi env's browser computer, through its own record.""" + app = next(child for child in browser.environment_card["children_environments"] if child["name"] == "chromium") + assert find_extension_method(app, BROWSER, op) is not None, op + return await invoke_extension(browser.environment_url, app, BROWSER, params, 60, method=op, + headers=browser.control_headers) + + +async def _discovery(http_url: str) -> dict: + async with httpx.AsyncClient(timeout=60) as http: + answer = await http.get(f"{http_url.rstrip('/')}/json/version") + assert answer.status_code == 200, answer.text + return answer.json() + + +@pytest.mark.asyncio +async def test_chromium_as_a_multi_envs_browser_runs_on_its_own_sandbox_reached_through_the_gateway( + browser_computer, shop, made): + with namespace_routing(): + office = _office(shop, [Env.get(browser_computer.id)]) + step = DeployEnvTaskStep(id="office", version=None, env_id=office.id, sandbox_type="local", ttl_seconds=900, + child_envs={"chromium": MULTI_CHROMIUM}) + assert step.preflight() == [] + context = TaskStepContext() + try: + [deployed] = (await step.execute(context)).deployed_envs + browser = DeployedEnv.from_dict(deployed.child_envs["chromium"]) + stated = {child.environment_name for child in protocol_v1.children(deployed.environment_card)} + driven = await _drive(deployed) + address = await _cdp_at_gateway(deployed, browser) + discovered = await _discovery(address["http_url"]) + seen = await _cdp_agent_loads(address["ws_url"], [MULTI_SITE]) + reattached = await Env.from_instance_id(deployed.instance_id) + world = _browser_universe(office.id, [{"name": "sid", "value": "ada", "url": MULTI_SITE, "max_age": 3600}]) + await LoadArtifactTaskStep(id="seed", version=None, env_id=office.id, artifact_id=world.id).execute(context) + loaded = await _browser_op(browser, "cookies/get", {"urls": [MULTI_SITE]}) + + subprocess.run(["docker", "kill", *_containers(browser.sandbox_id)], capture_output=True) + await ResetEnvTaskStep(id="reset", version=None, env_id=office.id, fail_task_on_error=True).execute(context) + after_kill = await _answers_again(deployed) + after_kill_tabs = (await _drive(deployed))["tabs"] + + subprocess.run(["docker", "restart", _gateway(deployed, made)], capture_output=True, check=True) + through_restart = await _answers_again(deployed) + await ResetEnvTaskStep(id="reset-2", version=None, env_id=office.id, + fail_task_on_error=True).execute(context) + after_restart = await _answers_again(deployed) + up = _containers(browser.sandbox_id) + finally: + report = await teardown_run(context) + + assert not report.failed and up and _containers(browser.sandbox_id) == [] + # chromium is a computer child on a sandbox of its own, and no playwright-mcp runs. + assert set(deployed.child_envs) == {"chromium"} + assert deployed.sandbox_ids["local"] == {"chromium": browser.sandbox_id} + assert "chromium" in stated and PLAYWRIGHT_MCP_ENVIRONMENT_NAME not in stated, stated + computer = deployed.child_envs["chromium"]["metadata"]["computer"] + assert computer["sites"]["names"]["routes"] == {"shop": ["shop.test"]} + [start_tab] = computer["start_tabs"]["chromium"] + assert (start_tab["url"], start_tab["site"], start_tab["outcome"]) == (MULTI_SITE, "shop", "loaded"), start_tab + # An agent drives it through the gateway, every tool under chromium_. + assert {"chromium_computer_screenshot", "chromium_computer_navigate"} <= driven["tools"], driven + assert not any(name.startswith("computer_") for name in driven["tools"]), driven + assert driven["shot"] and driven["opened"] and MULTI_SITE in driven["tabs"], driven + # Its CDP address names the gateway, its discovery answer too, and an agent there reaches the site by name. + token = address["http_url"].split("/cdp/")[1].strip("/") + assert address["http_url"] == f"{deployed.gateway_url}/browser/chromium/cdp/{token}/", address + assert browser.environment_url.rstrip("/") not in json.dumps([address, discovered]), (address, discovered) + assert discovered["webSocketDebuggerUrl"].startswith( + f"ws{deployed.gateway_url.removeprefix('http')}/browser/chromium/cdp/"), discovered + assert seen[MULTI_SITE]["error"] is None and seen[MULTI_SITE].get("ready") == "complete", seen + # A later process reattaches it from the instance store, and a universe loaded into the multi env sets its cookies. + assert reattached._computers.browser._deployed.sandbox_id == browser.sandbox_id + assert [cookie["value"] for cookie in loaded["cookies"] if cookie["name"] == "sid"] == ["ada"], loaded + # Through a kill and a gateway restart, a reset brings it back. + assert after_kill and MULTI_SITE in after_kill_tabs, after_kill_tabs + assert after_restart, through_restart + + +@pytest.mark.asyncio +async def test_a_multi_env_naming_no_browser_takes_the_runs_and_chromium_runs_as_a_computer_child(browser_computer, + shop): + with namespace_routing(): + office = _office(shop, None) + context = TaskStepContext() + try: + with run_env_ids({WEBSITE_BROWSER_OVERRIDE: browser_computer.id}): + [deployed] = (await DeployEnvTaskStep(id="office", version=None, env_id=office.id, sandbox_type="local", + ttl_seconds=900, child_envs={"chromium": MULTI_CHROMIUM} + ).execute(context)).deployed_envs + stated = {child.environment_name for child in protocol_v1.children(deployed.environment_card)} + finally: + report = await teardown_run(context) + + assert not report.failed and set(deployed.child_envs) == {"chromium"} + assert PLAYWRIGHT_MCP_ENVIRONMENT_NAME not in stated, stated + [start_tab] = deployed.child_envs["chromium"]["metadata"]["computer"]["start_tabs"]["chromium"] + assert (start_tab["url"], start_tab["site"], start_tab["outcome"]) == (MULTI_SITE, "shop", "loaded"), start_tab + + +@pytest.mark.asyncio +async def test_a_multi_env_naming_no_browser_deploys_none(browser_computer, shop): + with namespace_routing(): + office = _office(shop, []) + context = TaskStepContext() + try: + [deployed] = (await DeployEnvTaskStep(id="office", version=None, env_id=office.id, sandbox_type="local", + ttl_seconds=900).execute(context)).deployed_envs + stated = {child.environment_name for child in protocol_v1.children(deployed.environment_card)} + finally: + report = await teardown_run(context) + + assert not report.failed and not deployed.child_envs + assert "shop" in stated and PLAYWRIGHT_MCP_ENVIRONMENT_NAME not in stated and "chromium" not in stated, stated diff --git a/tst/integration/computer/multi_env_browser_test.py b/tst/integration/computer/multi_env_browser_test.py deleted file mode 100644 index e1ad10427..000000000 --- a/tst/integration/computer/multi_env_browser_test.py +++ /dev/null @@ -1,187 +0,0 @@ -"""agentenv's chromium as a multi env's website browser, on the local backend: ``browser_envs = [chromium]`` deploys it -on a sandbox of its own as one more computer child, beside a synthetic site, with no playwright-mcp. It routes the site, -so its start tab loads it by name through the gateway; an agent drives it through the gateway's one /mcp with its tools -under ``chromium_``; its CDP address, asked through ``/svc/chromium/``, names the gateway and reaches the site by name; -and reset_env brings it back after its container is killed and after the gateway restarts. ``browser_envs = []`` -deploys no browser. -Needs Docker and the local registry on localhost:5000.""" - -import asyncio -import json -import subprocess -import uuid - -import httpx -import pytest -from agentenv_protocol import client as protocol_v1 -from agentenv_protocol.website_browser import BROWSER -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client - -from agent_env.env import Env -from agent_env.env.env import DeployedEnv -from agent_env.env.envs.multi_env import MultiEnv -from agent_env.env.envs.playwright_mcp import PLAYWRIGHT_MCP_ENVIRONMENT_NAME -from agent_env.env.envs.website import WebsiteEnv -from agent_env.store.routing import namespace_routing -from agent_env.task.teardown import teardown_run -from agent_env.task_step.context import TaskStepContext -from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep -from agent_env.task_step.task_steps.reset_env import ResetEnvTaskStep -from tst.integration.computer.browser_computer_test import _cdp_agent_loads -from tst.integration.computer.multi_env_computers_test import _containers, _gateway, _image -from tst.integration.store.local_parallel_deploy_test import _seed_bootstrap_envs -from tst.util import local_sandboxes - -pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] - -SITE_URL = "https://shop.test/api/health" -CHROMIUM = {"sandbox_type": "local", - "env_params": {"form": "container", "app_settings": {"chromium": {"start_urls": [SITE_URL]}}}} -#: How long the browser may take to answer again after a restart or a reset; how often it's asked meanwhile. -WAIT_S, POLL_S = 120.0, 2.0 - - -@pytest.fixture(scope="module") -def shop(browser_computer): - """The gateway's and service-db's envs, and a website whose backend is the small agentenv website, named - shop.test, under a local id, since a multi env naming chromium's local id must be local too.""" - with namespace_routing(): - _seed_bootstrap_envs() - uid = uuid.uuid4().hex[:8] - return WebsiteEnv.put(id=f"@local/~/browsing-{uid}/shop", environment_name="shop", domains=["shop.test"], - backend_docker_image_artifact=_image("agentenv_website", f"browsing-shop-{uid}")) - - -@pytest.fixture -def made(monkeypatch) -> list[str]: - """The ids of the local sandboxes this test's process makes, so it finds and counts only its own.""" - return local_sandboxes.recorded_sandbox_ids(monkeypatch) - - -def _office(shop, browser_envs) -> MultiEnv: - return MultiEnv.put(id=f"@local/~/browsing-{uuid.uuid4().hex[:8]}/office", mcp_server_envs=[], - website_envs=[shop], browser_envs=browser_envs, name="office") - - -async def _drive(deployed) -> dict: - """What an agent sees through the gateway's /mcp: the tools it lists, a screenshot by chromium's name, and the - tabs after it opens the site by name.""" - async with streamable_http_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} - shot = await session.call_tool("chromium_computer_screenshot", {}) - opened = await session.call_tool("chromium_computer_navigate", {"url": SITE_URL}) - tabs = await session.call_tool("chromium_computer_tabs", {"action": "list"}) - return {"tools": tools, "shot": not shot.isError and any(part.type == "image" for part in shot.content), - "opened": not opened.isError, - "tabs": " ".join(part.text for part in tabs.content if part.type == "text")} - - -async def _answers_again(deployed) -> bool: - """Whether chromium's screenshot answers through the gateway, asked again until it does or ``WAIT_S`` passes.""" - deadline = asyncio.get_running_loop().time() + WAIT_S - while asyncio.get_running_loop().time() < deadline: - try: - async with streamable_http_client(deployed.mcp_url) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - if not (await session.call_tool("chromium_computer_screenshot", {})).isError: - return True - except Exception: # noqa: BLE001 - the gateway or the browser restarting - pass - await asyncio.sleep(POLL_S) - return False - - -async def _cdp(deployed, browser: DeployedEnv) -> dict: - """The CDP address the browser gives, asked through the gateway's ``/svc/chromium/`` route with the browser's own - control token, as agentenv asks a computer child.""" - app = next(child for child in browser.environment_card["children_environments"] if child["name"] == "chromium") - path = protocol_v1.find_extension_method(app, BROWSER, "cdp")["endpoint"] - async with httpx.AsyncClient(timeout=180) as http: - answer = await http.post(f"{deployed.gateway_url}/svc/chromium{path}", json={"user": "ada"}, - headers=browser.control_headers) - assert answer.status_code == 200, answer.text - return answer.json() - - -async def _discovery(http_url: str) -> dict: - async with httpx.AsyncClient(timeout=60) as http: - answer = await http.get(f"{http_url.rstrip('/')}/json/version") - assert answer.status_code == 200, answer.text - return answer.json() - - -@pytest.mark.asyncio -async def test_chromium_as_a_multi_envs_browser_runs_on_its_own_sandbox_reached_through_the_gateway( - browser_computer, shop, made): - with namespace_routing(): - office = _office(shop, [Env.get(browser_computer.id)]) - step = DeployEnvTaskStep(id="office", version=None, env_id=office.id, sandbox_type="local", ttl_seconds=900, - child_envs={"chromium": CHROMIUM}) - assert step.preflight() == [] - context = TaskStepContext() - try: - [deployed] = (await step.execute(context)).deployed_envs - browser = DeployedEnv.from_dict(deployed.child_envs["chromium"]) - stated = {child.environment_name for child in protocol_v1.children(deployed.environment_card)} - driven = await _drive(deployed) - address = await _cdp(deployed, browser) - discovered = await _discovery(address["http_url"]) - seen = await _cdp_agent_loads(address["ws_url"], [SITE_URL]) - - subprocess.run(["docker", "kill", *_containers(browser.sandbox_id)], capture_output=True) - await ResetEnvTaskStep(id="reset", version=None, env_id=office.id, fail_task_on_error=True).execute(context) - after_kill = await _answers_again(deployed) - after_kill_tabs = (await _drive(deployed))["tabs"] - - subprocess.run(["docker", "restart", _gateway(deployed, made)], capture_output=True, check=True) - through_restart = await _answers_again(deployed) - await ResetEnvTaskStep(id="reset-2", version=None, env_id=office.id, - fail_task_on_error=True).execute(context) - after_restart = await _answers_again(deployed) - up = _containers(browser.sandbox_id) - finally: - report = await teardown_run(context) - - assert not report.failed and up and _containers(browser.sandbox_id) == [] - # chromium is a computer child on a sandbox of its own, and no playwright-mcp runs. - assert set(deployed.child_envs) == {"chromium"} - assert deployed.sandbox_ids["local"] == {"chromium": browser.sandbox_id} - assert "chromium" in stated and PLAYWRIGHT_MCP_ENVIRONMENT_NAME not in stated, stated - computer = deployed.child_envs["chromium"]["metadata"]["computer"] - assert computer["sites"]["names"]["routes"] == {"shop": ["shop.test"]} - [start_tab] = computer["start_tabs"]["chromium"] - assert (start_tab["url"], start_tab["site"], start_tab["outcome"]) == (SITE_URL, "shop", "loaded"), start_tab - # An agent drives it through the gateway, every tool under chromium_. - assert {"chromium_computer_screenshot", "chromium_computer_navigate"} <= driven["tools"], driven - assert not any(name.startswith("computer_") for name in driven["tools"]), driven - assert driven["shot"] and driven["opened"] and SITE_URL in driven["tabs"], driven - # Its CDP address names the gateway, its discovery answer too, and an agent there reaches the site by name. - token = address["http_url"].split("/cdp/")[1].strip("/") - assert address["http_url"] == f"{deployed.gateway_url}/browser/chromium/cdp/{token}/", address - assert browser.environment_url.rstrip("/") not in json.dumps([address, discovered]), (address, discovered) - assert discovered["webSocketDebuggerUrl"].startswith( - f"ws{deployed.gateway_url.removeprefix('http')}/browser/chromium/cdp/"), discovered - assert seen[SITE_URL]["error"] is None and seen[SITE_URL].get("ready") == "complete", seen - # Through a kill and a gateway restart, a reset brings it back. - assert after_kill and SITE_URL in after_kill_tabs, after_kill_tabs - assert after_restart, through_restart - - -@pytest.mark.asyncio -async def test_a_multi_env_naming_no_browser_deploys_none(browser_computer, shop): - with namespace_routing(): - office = _office(shop, []) - context = TaskStepContext() - try: - [deployed] = (await DeployEnvTaskStep(id="office", version=None, env_id=office.id, sandbox_type="local", - ttl_seconds=900).execute(context)).deployed_envs - stated = {child.environment_name for child in protocol_v1.children(deployed.environment_card)} - finally: - report = await teardown_run(context) - - assert not report.failed and not deployed.child_envs - assert "shop" in stated and PLAYWRIGHT_MCP_ENVIRONMENT_NAME not in stated and "chromium" not in stated, stated From 8ade11458271b46d45bb04587295f2f25e6bcbc8 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 16:09:27 -0800 Subject: [PATCH 10/15] feat(browser): browser_connect_cdp hands an agent an address to a multi env's browser at its gateway A browser in one of a multi env's computers was refused. The step now asks its cdp through the multi env's gateway, on its /svc route to that computer with the computer's control token, so the address it hands the agent is the gateway's relay, rebased for the agent's sandbox as deploy_agent rebases the gateway's MCP URL; the env's tools are hidden at the gateway the agent connects to. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/browsers/README.md | 7 +-- .../task_steps/browser/connect_cdp.py | 45 +++++++++++++------ .../computer/browser_computer_test.py | 37 ++++++++++++++- .../task_step/test_browser_connect_cdp.py | 45 ++++++++++++++++--- tst/unit/task_step/test_browser_sign_in.py | 9 ---- 5 files changed, 111 insertions(+), 32 deletions(-) diff --git a/src/agent_env/browsers/README.md b/src/agent_env/browsers/README.md index 28801e34d..b50ac47c6 100644 --- a/src/agent_env/browsers/README.md +++ b/src/agent_env/browsers/README.md @@ -40,7 +40,8 @@ each cookie is set for every name the site is reached by (an exact name by its U and the browser's `data/add` loads them, which opens the start tabs again while no agent has acted. A task runs it after the load that seeds the site and again after `reset_env`, which starts the browser afresh. Preflight refuses a step on an env with no browser, none of the name given, or several and none named. On a multi env, the steps reach -the browser apps of its computers, each through the computer's own record and control token; `browser_connect_cdp` -doesn't reach those yet. -`browser_connect_cdp` hands an agent a CDP address to the same browser. A whole state, Playwright's `storageState` +the browser apps of its computers, each through the computer's own record and control token. +`browser_connect_cdp` hands an agent a CDP address to the same browser; on a multi env, one at its gateway, asked +through the gateway's `/svc//` route, so the agent reaches the browser through the gateway's CDP relay as it +reaches the rest of the env, with the multi env's tools hidden from its role. A whole state, Playwright's `storageState` included, loads through `load_artifact` or `data/add` instead. diff --git a/src/agent_env/task_step/task_steps/browser/connect_cdp.py b/src/agent_env/task_step/task_steps/browser/connect_cdp.py index f7a4d0c91..a7666813e 100644 --- a/src/agent_env/task_step/task_steps/browser/connect_cdp.py +++ b/src/agent_env/task_step/task_steps/browser/connect_cdp.py @@ -1,6 +1,7 @@ """Task step that hands a deployed agent a CDP address to an env's website browser, through the agent's cdp-config/v1, -and hides that browser's MCP tools from the agent. A reset ends the address, so a task connects the agent again after -reset_env, or with a retry_config that reruns this step.""" +and hides that browser's MCP tools from the agent. A browser in one of a multi env's computers is reached through the +multi env's gateway, as its agents reach the rest of it. A reset ends the address, so a task connects the agent again +after reset_env, or with a retry_config that reruns this step.""" from __future__ import annotations @@ -8,7 +9,7 @@ from typing import ClassVar, Optional import httpx -from agentenv_protocol.client import find_extension_method +from agentenv_protocol.client import find_extension_method, invoke_extension from agentenv_protocol.tool_access import DISABLE, DISABLE_TOOL_EXTENSION_URI, WILDCARD from agentenv_protocol.tool_context import DEFAULT_ROLE from agentenv_protocol.website_browser import BROWSER @@ -20,7 +21,7 @@ from agent_env.providers.sandbox_providers.sandbox_provider import reachable_url from agent_env.task_step.context import DeployedAgent, TaskStepContext from agent_env.task_step.task_step import TaskStep, TaskStepDependency -from agent_env.task_step.task_steps.browser.base import BrowserStep +from agent_env.task_step.task_steps.browser.base import BrowserStep, deployed_env logger = logging.getLogger(__name__) @@ -37,7 +38,8 @@ class BrowserConnectCdpTaskStep(BrowserStep): """Hand the agent ``agent_name`` a CDP address to the website browser ``browser`` in the env ``env_id``, the env's only one when unnamed: added to its cdp-config/v1 as ``name``, with every tool the env serves hidden from - its role unless ``keep_tools``. The address is the browser's own for that agent, as its sandbox reaches it.""" + its role unless ``keep_tools``. The address is the browser's own for that agent, as its sandbox reaches it; for a + browser in one of a multi env's computers, the multi env's gateway's, which relays it to the browser.""" type: ClassVar[str] = "browser_connect_cdp" entity_refs = (EntityRef.env("env_id"),) @@ -77,18 +79,18 @@ def from_dict(cls, data: dict) -> BrowserConnectCdpTaskStep: async def execute(self, context: TaskStepContext) -> TaskStepContext: deployed, browser, card = self._find(context) - if deployed.env_id != self.env_id: - raise RuntimeError(f"the website browser {browser!r} is in a computer of the multi env {self.env_id!r}, whose " - "agents reach it through the multi env's gateway; browser_connect_cdp doesn't hand out " - "that address yet") + env = deployed_env(context, self.env_id) # what the agent reaches: a multi env's gateway, or the browser's env agent = _deployed_agent(context, self.agent_name) cdp_config = A2AAgent.find_extension(agent.a2a_card or {}, A2AAgent.EXT_CDP_CONFIG) if cdp_config is None or not agent.a2a_url: raise RuntimeError(f"agent {agent.agent_name!r} can't take a CDP address: its card has no " f"{A2AAgent.EXT_CDP_CONFIG}") - base, theirs = _env_urls(deployed, agent) - asked = {"user": agent.agent_name, **({"origin": theirs} if _takes_origin(card) else {})} - address = await self._call(context, "cdp", asked, CDP_TIMEOUT_S) + base, theirs = _env_urls(env, agent) + if deployed.env_id == self.env_id: + asked = {"user": agent.agent_name, **({"origin": theirs} if _takes_origin(card) else {})} + address = await self._call(context, "cdp", asked, CDP_TIMEOUT_S) + else: + address = await _cdp_at_gateway(env, deployed, card, {"user": agent.agent_name}) http_url = _rebased(address["http_url"], base, theirs) ws_url = _rebased(address["ws_url"], _ws(base), _ws(theirs)) async with httpx.AsyncClient(timeout=ADD_TIMEOUT_S) as client: @@ -99,9 +101,9 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: role = agent.role or DEFAULT_ROLE hidden = None if self.keep_tools else WILDCARD if hidden: - await deployed.invoke(DISABLE_TOOL_EXTENSION_URI, DISABLE, {"role": role, "tools": hidden}) + await env.invoke(DISABLE_TOOL_EXTENSION_URI, DISABLE, {"role": role, "tools": hidden}) logger.info(f"agent {agent.agent_name!r} has {self.name!r}, a CDP address to {browser or 'the env'} in env " - f"{deployed.env_id!r}" + (f"; the env's tools are hidden from role {role!r}" if hidden else "")) + f"{env.env_id!r}" + (f"; the env's tools are hidden from role {role!r}" if hidden else "")) # The agent's cdp-config now holds this address under its name, so it replaces any connection recorded so. records = context.metadata.setdefault(CONNECTIONS_KEY, []) records[:] = [record for record in records @@ -111,6 +113,21 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: return context +async def _cdp_at_gateway(gateway: DeployedEnv, computer: DeployedEnv, card: dict, params: dict) -> dict: + """The browser's ``cdp`` answer, the browser ``card`` names in ``computer``, one of the multi env ``gateway``'s + computers: asked through the gateway's ``/svc`` route to that computer, with the computer's control token, so the + gateway names its own address in it, its CDP relay's, as it does for any client reaching it there.""" + key = next((name for name, record in (getattr(gateway, "child_envs", None) or {}).items() + if record.get("sandbox_id") == computer.sandbox_id), None) + if key is None: + raise RuntimeError(f"env {gateway.env_id!r} keeps no record of the computer its website browser is in") + if find_extension_method(card, BROWSER, "cdp") is None: + raise RuntimeError(f"the website browser in env {gateway.env_id!r} serves no browser/v1 cdp: rebuild it on a " + "newer agentenv-framework-protocol") + return await invoke_extension(f"{gateway.environment_url.rstrip('/')}/svc/{key}", card, BROWSER, params, + CDP_TIMEOUT_S, method="cdp", headers=computer.control_headers) + + def _deployed_agent(context: TaskStepContext, agent_name: str) -> DeployedAgent: agent = next((a for a in context.deployed_agents if a.agent_name == agent_name), None) if agent is None: diff --git a/tst/integration/computer/browser_computer_test.py b/tst/integration/computer/browser_computer_test.py index 387dd694d..b4fc8b760 100644 --- a/tst/integration/computer/browser_computer_test.py +++ b/tst/integration/computer/browser_computer_test.py @@ -27,7 +27,9 @@ under ``chromium_``, its CDP address asked through ``/svc/chromium/`` names the gateway and reaches the site by name, a later process reattaches it from the instance store, a universe loaded into the multi env sets its cookies, and reset_env brings it back after its container is killed and after the gateway restarts; a multi env naming none takes -the run's browser, chromium too, the same way, and ``browser_envs = []`` deploys none. +the run's browser, chromium too, the same way, and ``browser_envs = []`` deploys none; and browser_connect_cdp +hands an agent an address to it at the multi env's gateway, whose relay takes the agent's page to the site by name, +with the multi env's tools hidden from the agent's role. Needs Docker and the local registry on localhost:5000, and Node 20 or later and npm for the CDP clients.""" @@ -1268,6 +1270,39 @@ async def test_a_multi_env_naming_no_browser_takes_the_runs_and_chromium_runs_as assert (start_tab["url"], start_tab["site"], start_tab["outcome"]) == (MULTI_SITE, "shop", "loaded"), start_tab +@pytest.mark.asyncio +async def test_browser_connect_cdp_hands_an_agent_an_address_at_the_multi_envs_gateway_with_its_tools_hidden( + browser_computer, shop): + with namespace_routing(): + agent = put_test_agent(f"cdp-agent-{uuid.uuid4().hex[:8]}") + office = _office(shop, [Env.get(browser_computer.id)]) + context = TaskStepContext() + try: + [deployed] = (await DeployEnvTaskStep(id="office", version=None, env_id=office.id, sandbox_type="local", + ttl_seconds=900, child_envs={"chromium": MULTI_CHROMIUM} + ).execute(context)).deployed_envs + await DeployAgentTaskStep(id="deploy-solver", version=None, env_ids=[], a2a_agent_id=agent.id, + a2a_agent_version=agent.version, agent_name="solver", + sandbox_type="local").execute(context) + await BrowserConnectCdpTaskStep(id="connect", version=None, env_id=office.id, + agent_name="solver").execute(context) + _, title = await _opened_over_cdp(context, "first", url=MULTI_SITE) + tools = await _tool_names(deployed) + async with httpx.AsyncClient(timeout=30) as http: + lines = (await http.get(f"{deployed.gateway_url}/trajectory")).text.splitlines() + relayed = [event for event in map(json.loads, lines) if event.get("event_type") == "browser_connection"] + finally: + report = await teardown_run(context) + + assert not title.startswith("failed"), title + assert tools == [], tools + assert [event["browser"] for event in relayed] and all(event["browser"] == "chromium" for event in relayed), relayed + assert context.metadata["browser_connections"] == [{ + "step_id": "connect", "env_id": office.id, "agent_name": "solver", "browser": "chromium", "name": "chromium", + "keep_tools": False, "role": "default", "tools_hidden": "*"}] + assert not report.failed + + @pytest.mark.asyncio async def test_a_multi_env_naming_no_browser_deploys_none(browser_computer, shop): with namespace_routing(): diff --git a/tst/unit/task_step/test_browser_connect_cdp.py b/tst/unit/task_step/test_browser_connect_cdp.py index d80c3f331..1425f624a 100644 --- a/tst/unit/task_step/test_browser_connect_cdp.py +++ b/tst/unit/task_step/test_browser_connect_cdp.py @@ -1,21 +1,24 @@ """browser_connect_cdp asks the env's website browser for the agent's CDP address under the env's URL as the agent's -sandbox reaches it, or, of a browser that takes no origin, rebases the address it answers onto that; adds it to the -agent's cdp-config and hides every tool the env serves from the agent's role unless kept; it refuses an agent that takes -no CDP address and an env whose browser it can't tell; and connecting under a name already connected replaces its -record. Only the HTTP boundary is mocked.""" +sandbox reaches it, or, of a browser that takes no origin, rebases the address it answers onto that; a browser in one +of a multi env's computers is asked through the multi env's gateway, which names its own address, rebased the same +way; it adds the address to the agent's cdp-config and hides every tool the env serves from the agent's role unless +kept; it refuses an agent that takes no CDP address and an env whose browser it can't tell; and connecting under a +name already connected replaces its record. Only the HTTP boundary is mocked.""" from __future__ import annotations +import dataclasses import json import httpx import pytest -from agent_env.env.env import DeployedSandboxEnv +from agent_env.env.env import DeployedGatewayEnv, DeployedSandboxEnv from agent_env.task_step.context import DeployedAgent, TaskStepContext from agent_env.task_step.task_steps.browser.connect_cdp import BrowserConnectCdpTaskStep _ENV = "http://localhost:41000" +_GATEWAY = "http://localhost:43000" _AGENT = "http://localhost:42000" _BROWSER = {"uri": "urn:agentenv:browser/v1", "params": { "endpoint": "/svc/chromium/agentenv/ext/browser", "methods": {"cdp": {"endpoint": "/svc/chromium/agentenv/ext/browser/cdp"}}}} @@ -73,6 +76,27 @@ async def test_a_browser_that_takes_an_origin_mints_the_agents_address_where_its "metadata": {"browser": "chromium"}} +@pytest.mark.asyncio +async def test_a_browser_in_a_multi_envs_computer_is_reached_through_its_gateway_and_its_tools_hidden_there( + monkeypatch): + sent = _mock(monkeypatch, tokens=["t1"], base=f"{_GATEWAY}/browser/chromium") + context = TaskStepContext(deployed_envs=[_multi()], deployed_agents=[_agent()], metadata={}) + + await BrowserConnectCdpTaskStep(id="connect", version=None, env_id="office", agent_name="solver").execute(context) + + asked, added, hidden = sent + assert (str(asked.url), json.loads(asked.content)) == ( + f"{_GATEWAY}/svc/chromium/svc/chromium/agentenv/ext/browser/cdp", {"user": "solver"}) + assert asked.headers["authorization"] == "Bearer control" # the computer's own token, which the gateway passes on + assert json.loads(added.content)["http_url"] == "http://host.docker.internal:43000/browser/chromium/cdp/t1/" + assert json.loads(added.content)["ws_url"] == ( + "ws://host.docker.internal:43000/browser/chromium/cdp/t1/devtools/browser") + assert (str(hidden.url), json.loads(hidden.content)) == (f"{_GATEWAY}/tools/disable", + {"role": "default", "tools": "*"}) + assert hidden.headers["authorization"] == "Bearer gateway-control" + assert context.metadata["browser_connections"][0]["env_id"] == "office" + + @pytest.mark.asyncio async def test_a_role_kept_tools_a_name_and_a_gateways_address_are_the_tasks(monkeypatch): sent = _mock(monkeypatch, tokens=["t1"], base="https://gateway.test/browser/chromium") @@ -148,6 +172,17 @@ def _env(children: list | None = None, *, url: str = _ENV, sandbox_type: str = " "children_environments": [_app("chromium")] if children is None else children}) +def _multi() -> DeployedGatewayEnv: + """A multi env's deployment whose computer ``chromium`` is the browser computer ``_env`` gives, its gateway + serving tool access on its own card.""" + return DeployedGatewayEnv( + env_id="office", env_version=1, gateway_url=_GATEWAY, sandbox_id="gw", sandbox_type="local", + control_token="gateway-control", environment_card_url=f"{_GATEWAY}/.well-known/agent-env.json", + environment_card={"name": "office", "capabilities": {"extensions": [_TOOL_ACCESS]}, + "children_environments": [{"name": "chromium"}]}, + child_envs={"chromium": dataclasses.asdict(_env())}) + + def _agent(role: str | None = None, extensions: list | None = None) -> DeployedAgent: return DeployedAgent(agent_name="solver", api_url=_AGENT, a2a_url=_AGENT, sandbox_type="local", role=role, a2a_card={"capabilities": {"extensions": [_CDP_CONFIG] if extensions is None else extensions}}) diff --git a/tst/unit/task_step/test_browser_sign_in.py b/tst/unit/task_step/test_browser_sign_in.py index c8981a851..bcb12cbca 100644 --- a/tst/unit/task_step/test_browser_sign_in.py +++ b/tst/unit/task_step/test_browser_sign_in.py @@ -22,7 +22,6 @@ from agent_env.task_step.registry import get_task_step_registry from agent_env.task_step.task_steps.browser import sign_in from agent_env.task_step.task_steps.browser.base import stored_browsers -from agent_env.task_step.task_steps.browser.connect_cdp import BrowserConnectCdpTaskStep from agent_env.task_step.task_steps.browser.cookies import BrowserSetCookiesTaskStep from agent_env.task_step.task_steps.browser.sign_in import BrowserSignInTaskStep, scoped @@ -155,14 +154,6 @@ async def test_any_browser_step_reaches_a_browser_in_a_multi_envs_computer_with_ assert request.headers["authorization"] == "Bearer desktop-token" -@pytest.mark.asyncio -async def test_a_cdp_address_to_a_browser_in_a_multi_envs_computer_is_refused_for_now(served): - with pytest.raises(RuntimeError, match="is in a computer of the multi env 'office'"): - await BrowserConnectCdpTaskStep(id="cdp", version=None, env_id="office", agent_name="agent").execute( - TaskStepContext(deployed_envs=[_office()], metadata={})) - assert served == [] - - @pytest.mark.asyncio @pytest.mark.parametrize(("office", "site", "problem"), [ (_office(session=False), "shop-site", "site 'shop-site' serves no website/v1 session"), From 06d0421b78256caf8246dfd367850d30e0902240 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 16:15:15 -0800 Subject: [PATCH 11/15] fix(browser): browser_connect_cdp refuses a multi env browser's address the gateway didn't relay The gateway's CDP relay tells apart one website browser per computer; for a computer with several, its cdp answer came back unrewritten, naming the computer's own address, which the step would have handed the agent. The step now refuses an address that isn't at the gateway's relay, before the agent gets it or any tool is hidden. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/browsers/README.md | 3 ++- .../task_step/task_steps/browser/connect_cdp.py | 12 ++++++++++-- tst/unit/task_step/test_browser_connect_cdp.py | 12 ++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/agent_env/browsers/README.md b/src/agent_env/browsers/README.md index b50ac47c6..ad9dabbe7 100644 --- a/src/agent_env/browsers/README.md +++ b/src/agent_env/browsers/README.md @@ -43,5 +43,6 @@ step on an env with no browser, none of the name given, or several and none name the browser apps of its computers, each through the computer's own record and control token. `browser_connect_cdp` hands an agent a CDP address to the same browser; on a multi env, one at its gateway, asked through the gateway's `/svc//` route, so the agent reaches the browser through the gateway's CDP relay as it -reaches the rest of the env, with the multi env's tools hidden from its role. A whole state, Playwright's `storageState` +reaches the rest of the env, with the multi env's tools hidden from its role; a computer there with several website +browsers is refused, as the relay doesn't tell them apart yet. A whole state, Playwright's `storageState` included, loads through `load_artifact` or `data/add` instead. diff --git a/src/agent_env/task_step/task_steps/browser/connect_cdp.py b/src/agent_env/task_step/task_steps/browser/connect_cdp.py index a7666813e..e2b41f59b 100644 --- a/src/agent_env/task_step/task_steps/browser/connect_cdp.py +++ b/src/agent_env/task_step/task_steps/browser/connect_cdp.py @@ -124,8 +124,16 @@ async def _cdp_at_gateway(gateway: DeployedEnv, computer: DeployedEnv, card: dic if find_extension_method(card, BROWSER, "cdp") is None: raise RuntimeError(f"the website browser in env {gateway.env_id!r} serves no browser/v1 cdp: rebuild it on a " "newer agentenv-framework-protocol") - return await invoke_extension(f"{gateway.environment_url.rstrip('/')}/svc/{key}", card, BROWSER, params, - CDP_TIMEOUT_S, method="cdp", headers=computer.control_headers) + base = gateway.environment_url.rstrip("/") + address = await invoke_extension(f"{base}/svc/{key}", card, BROWSER, params, CDP_TIMEOUT_S, method="cdp", + headers=computer.control_headers) + relayed = (f"{base}/browser/{key}/", f"{_ws(base)}/browser/{key}/") + if not (str(address.get("http_url", "")).startswith(relayed[0]) and + str(address.get("ws_url", "")).startswith(relayed[1])): + raise RuntimeError(f"env {gateway.env_id!r}'s gateway didn't put the CDP address of the website browser in its " + f"computer {key!r} at its relay, as it does for a computer with one website browser; one " + "with several it doesn't tell apart yet") + return address def _deployed_agent(context: TaskStepContext, agent_name: str) -> DeployedAgent: diff --git a/tst/unit/task_step/test_browser_connect_cdp.py b/tst/unit/task_step/test_browser_connect_cdp.py index 1425f624a..a7000bebd 100644 --- a/tst/unit/task_step/test_browser_connect_cdp.py +++ b/tst/unit/task_step/test_browser_connect_cdp.py @@ -97,6 +97,18 @@ async def test_a_browser_in_a_multi_envs_computer_is_reached_through_its_gateway assert context.metadata["browser_connections"][0]["env_id"] == "office" +@pytest.mark.asyncio +async def test_a_cdp_address_the_multi_envs_gateway_didnt_relay_is_refused_before_the_agent_gets_it(monkeypatch): + sent = _mock(monkeypatch, tokens=["t1"]) # the computer's own address, as a gateway answers for several browsers + context = TaskStepContext(deployed_envs=[_multi()], deployed_agents=[_agent()], metadata={}) + + with pytest.raises(RuntimeError, match="gateway didn't put the CDP address .* at its relay"): + await BrowserConnectCdpTaskStep(id="connect", version=None, env_id="office", + agent_name="solver").execute(context) + assert [request.url.path for request in sent] == ["/svc/chromium/svc/chromium/agentenv/ext/browser/cdp"] + assert "browser_connections" not in context.metadata + + @pytest.mark.asyncio async def test_a_role_kept_tools_a_name_and_a_gateways_address_are_the_tasks(monkeypatch): sent = _mock(monkeypatch, tokens=["t1"], base="https://gateway.test/browser/chromium") From 6b2495cf9059551cd4a330edeb7f739f22721503 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 16:35:09 -0800 Subject: [PATCH 12/15] refactor(gateway): the gateway is told its gateway_url, the record's name for its address public_url was the same value as the deploy record's gateway_url (the gateway's tunnel URL), so it takes that name: the compose parameter, AGENTENV_GATEWAY_URL, Gateway(gateway_url=...) and the CDP relay's argument. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/agent_env/env/gateway/browser_proxy.py | 16 ++++++++-------- src/agent_env/env/gateway/constants.py | 6 +++--- src/agent_env/env/gateway/entrypoint.py | 4 ++-- src/agent_env/env/gateway/gateway.py | 6 +++--- .../env_providers/env_gateway_provider.py | 10 +++++----- tst/unit/env/gateway/browser_route_test.py | 2 +- .../env_providers/env_gateway_provider_test.py | 10 +++++----- 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9f7061b64..3f670546b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,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 and computers; its website browser is the one its `browser_envs` names, none for `[]`, else the run's when it has websites: an MCP server env such as `playwright-mcp` runs among the gateway's MCP servers, and a browser computer such as `chromium` on a sandbox of its own as one more computer child, sized by its `child_envs` entry, its tools under its name and `_`, routing the multi env's synthetic sites unless that entry's `site_routing` says, its network its own env's, as any computer child's; a website env deployed alone refuses a browser computer as the run's browser), `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; its env.toml's `ready_timeout_s`, how long it may take from its server's container starting to ready, is that container's health check `start_period` and what the deploy waits, or its card's `ready` timeout if longer, and its `shm_mb` is the container's `shm_size`, each left to the gateway and Docker without them; 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; a live one, `live = true`, is a real site whose server serves none of its pages, its `live`, `domains` and `allow_hosts` read at put from its server's `@website(...)` (`utils/card_naming.py` `WebsiteSource`) unless given, its `settings`' secrets only as references the gateway resolves as it writes the site's container (`WEBSITE_SETTINGS_ENV`); the gateway gives it no site route, a computer's site table leaves it out, a computer whose network is an allowlist is let reach its names (`MultiEnv.live_hosts`), a browser alone's forward port takes them straight to the real site (`site_proxy.py` `direct`), as the browser base's stand-in proxy takes any host, refusing nothing, each proxy logging each such connection as a JSON line, and a run that deploys one is marked live, `reproducible: false`), `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.]`, or for one distribution `[install."/"]`, which replaces its OS type's there, as its `known_failures` do (`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, its `/dev/shm` the most its apps' Linux installs ask for (`shm_mb`, in MiB, which its composed computer and the deploy record keep; `--shm-size` on a container, `AGENTENV_SHM_MB` read by a Linux disk's init, passed only to a provider whose `SHARED_MEMORY` says it gives that much, else the sandbox's default, which the deploy logs), 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; `@linux(distribution=...)` or `@install("/", spec)` for one distribution, which replaces its OS type's there) 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/`, its scheme the one `X-Forwarded-Proto` names, else, for a request to the host the deploy reaches the gateway at (`AGENTENV_GATEWAY_PUBLIC_URL`, the gateway's tunnel URL on a VM), that URL's, as Modal's tunnel sends none; 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 and computers; its website browser is the one its `browser_envs` names, none for `[]`, else the run's when it has websites: an MCP server env such as `playwright-mcp` runs among the gateway's MCP servers, and a browser computer such as `chromium` on a sandbox of its own as one more computer child, sized by its `child_envs` entry, its tools under its name and `_`, routing the multi env's synthetic sites unless that entry's `site_routing` says, its network its own env's, as any computer child's; a website env deployed alone refuses a browser computer as the run's browser), `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; its env.toml's `ready_timeout_s`, how long it may take from its server's container starting to ready, is that container's health check `start_period` and what the deploy waits, or its card's `ready` timeout if longer, and its `shm_mb` is the container's `shm_size`, each left to the gateway and Docker without them; 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; a live one, `live = true`, is a real site whose server serves none of its pages, its `live`, `domains` and `allow_hosts` read at put from its server's `@website(...)` (`utils/card_naming.py` `WebsiteSource`) unless given, its `settings`' secrets only as references the gateway resolves as it writes the site's container (`WEBSITE_SETTINGS_ENV`); the gateway gives it no site route, a computer's site table leaves it out, a computer whose network is an allowlist is let reach its names (`MultiEnv.live_hosts`), a browser alone's forward port takes them straight to the real site (`site_proxy.py` `direct`), as the browser base's stand-in proxy takes any host, refusing nothing, each proxy logging each such connection as a JSON line, and a run that deploys one is marked live, `reproducible: false`), `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.]`, or for one distribution `[install."/"]`, which replaces its OS type's there, as its `known_failures` do (`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, its `/dev/shm` the most its apps' Linux installs ask for (`shm_mb`, in MiB, which its composed computer and the deploy record keep; `--shm-size` on a container, `AGENTENV_SHM_MB` read by a Linux disk's init, passed only to a provider whose `SHARED_MEMORY` says it gives that much, else the sandbox's default, which the deploy logs), 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; `@linux(distribution=...)` or `@install("/", spec)` for one distribution, which replaces its OS type's there) 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/`, its scheme the one `X-Forwarded-Proto` names, else, for a request to the host the deploy reaches the gateway at (`AGENTENV_GATEWAY_URL`, the gateway's tunnel URL on a VM), that URL's, as Modal's tunnel sends none; 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/env/gateway/browser_proxy.py b/src/agent_env/env/gateway/browser_proxy.py index 432777b65..348a007d8 100644 --- a/src/agent_env/env/gateway/browser_proxy.py +++ b/src/agent_env/env/gateway/browser_proxy.py @@ -99,16 +99,16 @@ def rewritten(body: Any, fields: frozenset[str], base: str, key: str) -> Any: return body -def public_base(connection: Request | WebSocket, public_url: str | None = None) -> str | None: +def public_base(connection: Request | WebSocket, gateway_url: str | None = None) -> str | None: """The address the client reached the gateway at, scheme, host and port: the Host it sent, with the scheme a - TLS-terminating tunnel in front says it was reached by (``X-Forwarded-Proto``), else ``public_url``'s, the + TLS-terminating tunnel in front says it was reached by (``X-Forwarded-Proto``), else ``gateway_url``'s, the address the deploy reaches the gateway at, when the Host is its host, else the connection's own. None for a Host that isn't a host and port. A rewrite with it goes back only to the client that sent it.""" host = connection.headers.get("host", "") if not HOST.fullmatch(host): return None proto = connection.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower() - known = urlsplit(public_url) if public_url else None + known = urlsplit(gateway_url) if gateway_url else None if proto not in ("http", "https") and known and known.scheme in ("http", "https") \ and known.netloc.lower() == host.lower(): proto = known.scheme @@ -131,15 +131,15 @@ class BrowserRoutes: """The ``/browser//cdp//`` route over a gateway's ``/svc`` children, and the rewrite of a browser's ``cdp`` answer on their ``/svc`` route. ``log_event`` writes a trajectory event, ``attribution`` names a role's caller for one, and ``role_of`` reads the role a connection's ``AgentEnv-Role`` header names. The headers named in - ``withheld`` (lower case), the sandbox provider's credentials, reach no browser; ``public_url`` is the address the + ``withheld`` (lower case), the sandbox provider's credentials, reach no browser; ``gateway_url`` is the address the deploy reaches the gateway at (``public_base``).""" def __init__(self, rest_proxy_urls: dict[str, str], timeout: httpx.Timeout, log_event: Callable[[dict], Awaitable[Any]], attribution: Callable[[str], dict], role_of: Callable[[Request | WebSocket], str], *, withheld: frozenset[bytes] = frozenset(), - public_url: str | None = None): + gateway_url: str | None = None): self._urls = rest_proxy_urls - self._public_url = public_url + self._gateway_url = gateway_url self._withheld = withheld self._timeout = timeout self._log_event = log_event @@ -175,7 +175,7 @@ async def discovery(self, request: Request) -> Response: browser = await self.browser(key) if isinstance(browser, Response): return browser - base = public_base(request, self._public_url) + base = public_base(request, self._gateway_url) if base is None: return _error(400, "the Host header isn't a host and port") headers = client_headers(request, self._withheld) @@ -196,7 +196,7 @@ async def cdp_answer(self, request: Request, base_url: str, role: str) -> Respon browser = await self.browser(key) if not isinstance(browser, Browser) or path != browser.cdp_path: return None - base = public_base(request, self._public_url) + base = public_base(request, self._gateway_url) if base is None: return _error(400, "the Host header isn't a host and port") try: diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index eeaa53817..f26d18ad8 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -150,9 +150,9 @@ def sources(names: list[str]) -> str: # Sent by a computer's site proxy, which serves a website's route at the root of the site's own name. AGENT_ENV_SITE_ROOT_HEADER = "AgentEnv-Site-Root" GATEWAY_PROVIDER_HEADERS_ENV = "AGENTENV_PROVIDER_REQUEST_HEADERS" -# The address the deploy reaches the gateway at, whose scheme a client reaching it at that host used when a -# TLS-terminating tunnel in front sends no X-Forwarded-Proto, as Modal's doesn't. -GATEWAY_PUBLIC_URL_ENV = "AGENTENV_GATEWAY_PUBLIC_URL" +# The gateway's own address, its deploy record's gateway_url, whose scheme a client reaching it at that host used when +# a TLS-terminating tunnel in front sends no X-Forwarded-Proto, as Modal's doesn't. +GATEWAY_URL_ENV = "AGENTENV_GATEWAY_URL" # MCP request `_meta` keys the gateway stamps on every child tools/call: the caller's role, and on the MCP path a # key naming the inbound session, so a child can keep per-caller state behind the gateway's one shared session. AGENT_ENV_ROLE_META_KEY = "agentenv.io/role" diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 32cc08f1c..5c26f7277 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -8,7 +8,7 @@ import httpx from . import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode, InternalMCPServer -from .constants import (CONTROL_TOKEN_ENV, DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, GATEWAY_PUBLIC_URL_ENV, +from .constants import (CONTROL_TOKEN_ENV, DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, GATEWAY_URL_ENV, MCP_TRANSPORT, WELL_KNOWN_PATH) from .gateway import Gateway, card_url @@ -218,6 +218,6 @@ def parse_name_url_map(env_str: str) -> dict[str, str]: service_db_url=service_db_url, control_token=os.environ.get(CONTROL_TOKEN_ENV), provider_headers=os.environ.get(GATEWAY_PROVIDER_HEADERS_ENV, "").split(","), - public_url=os.environ.get(GATEWAY_PUBLIC_URL_ENV) or None, + gateway_url=os.environ.get(GATEWAY_URL_ENV) or None, ) gateway.run() diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 0daf80925..8832b7510 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -142,10 +142,10 @@ def __init__( service_db_url: str | None = None, control_token: str | None = None, provider_headers: Iterable[str] = (), - public_url: str | None = None, + gateway_url: str | None = None, ): """`provider_headers` names the headers the sandbox provider in front of the gateway is sent, its credentials, - which the gateway takes off every request it forwards to a service behind it; `public_url`, the address the + which the gateway takes off every request it forwards to a service behind it; `gateway_url`, the address the deploy reaches it at, gives the scheme of a CDP address it hands a client that reached it there.""" self.host = host self.port = port @@ -158,7 +158,7 @@ def __init__( self._withheld = frozenset(name.strip().lower().encode() for name in provider_headers if name.strip()) self._browsers = BrowserRoutes(self.rest_proxy_urls, self.REST_PROXY_TIMEOUT, self._log_event, self._attribution, self._request_role, withheld=self._withheld, - public_url=public_url) + gateway_url=gateway_url) for name in _TOKEN_LOGGERS: # each names a CDP address's path, its token in it, at INFO log = logging.getLogger(name) if not any(isinstance(f, TokenRedaction) for f in log.filters): diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index 392db0259..b7087d400 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -35,7 +35,7 @@ from agent_env.env.gateway.constants import ( DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, - GATEWAY_PUBLIC_URL_ENV, + GATEWAY_URL_ENV, STATE_TOOL_NAME_CLASHES_KEY, WELL_KNOWN_PATH, random_mcp_server_name, @@ -321,7 +321,7 @@ def create_docker_compose( extra_hosts: tuple[str, ...] = (), mcp_server_name: str | None = None, external_children: tuple[ExternalChild, ...] = (), - public_url: str | None = None, + gateway_url: str | None = None, control_token: str | None = None, ) -> str: """Generate docker-compose.yml content for a gateway deployment onto a VM/laptop/arbitrary machine. @@ -537,8 +537,8 @@ def create_docker_compose( gateway_env.extend(f" - {key}={value}" for key, value in control_environment(control_token).items()) if provider_headers := sandbox_request_header_names(): gateway_env.append(f" - {GATEWAY_PROVIDER_HEADERS_ENV}={','.join(provider_headers)}") - if public_url: - gateway_env.append(f" - {GATEWAY_PUBLIC_URL_ENV}={public_url}") + if gateway_url: + gateway_env.append(f" - {GATEWAY_URL_ENV}={gateway_url}") publish = host_port or (lambda port: port) exposed_gateway_ports = [] if expose_gateway_port: @@ -886,7 +886,7 @@ async def _stand_up(_spec): host_ips=sandbox.host_ips, extra_hosts=sandbox.extra_hosts, mcp_server_name=mcp_server_name, - public_url=sandbox.tunnel_urls.get(gateway_port), + gateway_url=sandbox.tunnel_urls.get(gateway_port), control_token=control_token, ) compose_content = compose() diff --git a/tst/unit/env/gateway/browser_route_test.py b/tst/unit/env/gateway/browser_route_test.py index 740138beb..db04640fa 100644 --- a/tst/unit/env/gateway/browser_route_test.py +++ b/tst/unit/env/gateway/browser_route_test.py @@ -138,7 +138,7 @@ async def app(scope, receive, send): # the gateway, built once its address is k gateway, gateway_url = await _serve(app) gw = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], rest_proxy_urls={"web": upstream_url}, provider_headers=provider_headers, - public_url=public and public.format(address=gateway_url.removeprefix("http://"))) + gateway_url=public and public.format(address=gateway_url.removeprefix("http://"))) gateway_app = gw._asgi_app() async def events(at_least: int) -> list[dict]: for _ in range(50): diff --git a/tst/unit/providers/env_providers/env_gateway_provider_test.py b/tst/unit/providers/env_providers/env_gateway_provider_test.py index cad7194c6..d28bac939 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -1269,16 +1269,16 @@ def test_compose_gives_the_gateway_the_names_of_the_providers_request_headers_al assert {name: entries for name, entries in named.items() if entries} == expected -@pytest.mark.parametrize("public_url", ["https://gw.w.modal.host", None], ids=["tunnel", "none"]) -def test_the_gateway_alone_is_told_the_address_the_deploy_reaches_it_at(public_url): +@pytest.mark.parametrize("gateway_url", ["https://gw.w.modal.host", None], ids=["tunnel", "none"]) +def test_the_gateway_alone_is_told_the_address_the_deploy_reaches_it_at(gateway_url): compose = EnvironmentGatewayProvider().create_docker_compose( mcp_servers=[MCPServerConfig(image="mcp-slack", environment_name="slack")], gateway_image="agent-gateway", state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), - state_instance=LocalPostgresStateProvider.default_instance(), public_url=public_url) + state_instance=LocalPostgresStateProvider.default_instance(), gateway_url=gateway_url) - told = {name: [entry for entry in service.get("environment") or [] if "GATEWAY_PUBLIC_URL" in entry] + told = {name: [entry for entry in service.get("environment") or [] if "AGENTENV_GATEWAY_URL" in entry] for name, service in yaml.safe_load(compose)["services"].items()} - expected = {GATEWAY_SERVICE_NAME: [f"AGENTENV_GATEWAY_PUBLIC_URL={public_url}"]} if public_url else {} + expected = {GATEWAY_SERVICE_NAME: [f"AGENTENV_GATEWAY_URL={gateway_url}"]} if gateway_url else {} assert {name: entries for name, entries in told.items() if entries} == expected From 5b0a38a1139fd51376644f48fe00564688892b97 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 19:02:04 -0800 Subject: [PATCH 13/15] fix(browser): a multi env's CDP address takes its scheme from the origin the agent's named, on containers too browser_connect_cdp names the agent's origin on the cdp call it asks through the multi env's gateway too, as #608 has it name it of a browser asked directly, and the gateway keeps the https or wss of an address the browser minted under it. So behind a tunnel that ends TLS, as Modal's, the address and its discovery are https and wss on a VM and on Modal containers alike, where a container gateway's tunnel URL isn't known until it runs. The deploy no longer tells the gateway its gateway_url (AGENTENV_GATEWAY_URL), which only a VM deploy could. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/agent_env/browsers/README.md | 9 ++-- src/agent_env/env/gateway/browser_proxy.py | 31 +++++------- src/agent_env/env/gateway/constants.py | 3 -- src/agent_env/env/gateway/entrypoint.py | 5 +- src/agent_env/env/gateway/gateway.py | 7 +-- .../env_providers/env_gateway_provider.py | 5 -- .../task_steps/browser/connect_cdp.py | 11 ++--- .../computer/browser_computer_test.py | 48 +++++++++++++++---- tst/unit/env/gateway/browser_route_test.py | 46 +++++++----------- .../env_gateway_provider_test.py | 18 +------ .../task_step/test_browser_connect_cdp.py | 42 +++++++++++----- 12 files changed, 118 insertions(+), 109 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3f670546b..8d99e82cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,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 and computers; its website browser is the one its `browser_envs` names, none for `[]`, else the run's when it has websites: an MCP server env such as `playwright-mcp` runs among the gateway's MCP servers, and a browser computer such as `chromium` on a sandbox of its own as one more computer child, sized by its `child_envs` entry, its tools under its name and `_`, routing the multi env's synthetic sites unless that entry's `site_routing` says, its network its own env's, as any computer child's; a website env deployed alone refuses a browser computer as the run's browser), `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; its env.toml's `ready_timeout_s`, how long it may take from its server's container starting to ready, is that container's health check `start_period` and what the deploy waits, or its card's `ready` timeout if longer, and its `shm_mb` is the container's `shm_size`, each left to the gateway and Docker without them; 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; a live one, `live = true`, is a real site whose server serves none of its pages, its `live`, `domains` and `allow_hosts` read at put from its server's `@website(...)` (`utils/card_naming.py` `WebsiteSource`) unless given, its `settings`' secrets only as references the gateway resolves as it writes the site's container (`WEBSITE_SETTINGS_ENV`); the gateway gives it no site route, a computer's site table leaves it out, a computer whose network is an allowlist is let reach its names (`MultiEnv.live_hosts`), a browser alone's forward port takes them straight to the real site (`site_proxy.py` `direct`), as the browser base's stand-in proxy takes any host, refusing nothing, each proxy logging each such connection as a JSON line, and a run that deploys one is marked live, `reproducible: false`), `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.]`, or for one distribution `[install."/"]`, which replaces its OS type's there, as its `known_failures` do (`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, its `/dev/shm` the most its apps' Linux installs ask for (`shm_mb`, in MiB, which its composed computer and the deploy record keep; `--shm-size` on a container, `AGENTENV_SHM_MB` read by a Linux disk's init, passed only to a provider whose `SHARED_MEMORY` says it gives that much, else the sandbox's default, which the deploy logs), 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; `@linux(distribution=...)` or `@install("/", spec)` for one distribution, which replaces its OS type's there) 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/`, its scheme the one `X-Forwarded-Proto` names, else, for a request to the host the deploy reaches the gateway at (`AGENTENV_GATEWAY_URL`, the gateway's tunnel URL on a VM), that URL's, as Modal's tunnel sends none; 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 and computers; its website browser is the one its `browser_envs` names, none for `[]`, else the run's when it has websites: an MCP server env such as `playwright-mcp` runs among the gateway's MCP servers, and a browser computer such as `chromium` on a sandbox of its own as one more computer child, sized by its `child_envs` entry, its tools under its name and `_`, routing the multi env's synthetic sites unless that entry's `site_routing` says, its network its own env's, as any computer child's; a website env deployed alone refuses a browser computer as the run's browser), `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; its env.toml's `ready_timeout_s`, how long it may take from its server's container starting to ready, is that container's health check `start_period` and what the deploy waits, or its card's `ready` timeout if longer, and its `shm_mb` is the container's `shm_size`, each left to the gateway and Docker without them; 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; a live one, `live = true`, is a real site whose server serves none of its pages, its `live`, `domains` and `allow_hosts` read at put from its server's `@website(...)` (`utils/card_naming.py` `WebsiteSource`) unless given, its `settings`' secrets only as references the gateway resolves as it writes the site's container (`WEBSITE_SETTINGS_ENV`); the gateway gives it no site route, a computer's site table leaves it out, a computer whose network is an allowlist is let reach its names (`MultiEnv.live_hosts`), a browser alone's forward port takes them straight to the real site (`site_proxy.py` `direct`), as the browser base's stand-in proxy takes any host, refusing nothing, each proxy logging each such connection as a JSON line, and a run that deploys one is marked live, `reproducible: false`), `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.]`, or for one distribution `[install."/"]`, which replaces its OS type's there, as its `known_failures` do (`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, its `/dev/shm` the most its apps' Linux installs ask for (`shm_mb`, in MiB, which its composed computer and the deploy record keep; `--shm-size` on a container, `AGENTENV_SHM_MB` read by a Linux disk's init, passed only to a provider whose `SHARED_MEMORY` says it gives that much, else the sandbox's default, which the deploy logs), 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; `@linux(distribution=...)` or `@install("/", spec)` for one distribution, which replaces its OS type's there) 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/`, keeping the https or wss of an address the browser minted under an origin its caller named, as `browser_connect_cdp` names the agent's, since a tunnel that ends TLS, as Modal's, may send no `X-Forwarded-Proto`; 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/browsers/README.md b/src/agent_env/browsers/README.md index ad9dabbe7..56438cbf1 100644 --- a/src/agent_env/browsers/README.md +++ b/src/agent_env/browsers/README.md @@ -41,8 +41,9 @@ and the browser's `data/add` loads them, which opens the start tabs again while after the load that seeds the site and again after `reset_env`, which starts the browser afresh. Preflight refuses a step on an env with no browser, none of the name given, or several and none named. On a multi env, the steps reach the browser apps of its computers, each through the computer's own record and control token. -`browser_connect_cdp` hands an agent a CDP address to the same browser; on a multi env, one at its gateway, asked -through the gateway's `/svc//` route, so the agent reaches the browser through the gateway's CDP relay as it -reaches the rest of the env, with the multi env's tools hidden from its role; a computer there with several website -browsers is refused, as the relay doesn't tell them apart yet. A whole state, Playwright's `storageState` +`browser_connect_cdp` hands an agent a CDP address to the same browser, under the origin where the agent reaches the +env, so through a tunnel that ends TLS it's https and wss; on a multi env, one at its gateway, asked through the +gateway's `/svc//` route, so the agent reaches the browser through the gateway's CDP relay as it reaches the +rest of the env, with the multi env's tools hidden from its role; a computer there with several website browsers is +refused, as the relay doesn't tell them apart yet. A whole state, Playwright's `storageState` included, loads through `load_artifact` or `data/add` instead. diff --git a/src/agent_env/env/gateway/browser_proxy.py b/src/agent_env/env/gateway/browser_proxy.py index 348a007d8..f241db52c 100644 --- a/src/agent_env/env/gateway/browser_proxy.py +++ b/src/agent_env/env/gateway/browser_proxy.py @@ -5,7 +5,8 @@ The browser builds those addresses from the address its own server was reached at, so behind the gateway they would name the browser's host and a client following one would leave the gateway. The gateway rewrites them by path, replacing everything before ``/cdp//`` with its own address and ``/browser/``, so a rewrite holds whatever -host an address was minted at.""" +host an address was minted at. It keeps the https or wss of an address the browser minted under an origin its caller +named, since a tunnel in front of the gateway that ends TLS may not say how it was reached.""" from __future__ import annotations import asyncio @@ -79,12 +80,14 @@ def browsers(card: dict) -> list[Browser]: def at_gateway(url: str, base: str, key: str) -> str: """``url``, a CDP address a browser handed out, at the gateway: everything before its ``/cdp//`` becomes - ``base`` and ``/browser/``, a WebSocket URL staying one. A value that isn't such an address is kept.""" + ``base`` and ``/browser/``, a WebSocket URL staying one, secure when either is: a browser the gateway reaches + over plain http names https only under an origin its caller named. A value that isn't such an address is kept.""" parts = urlsplit(url) match = ADDRESS.search(parts.path) if parts.scheme not in ("http", "https", "ws", "wss") or match is None: return url - secure, host = base.startswith("https://"), base.split("://", 1)[1] + secure = base.startswith("https://") or parts.scheme in ("https", "wss") + host = base.split("://", 1)[1] scheme = ("wss" if secure else "ws") if parts.scheme in ("ws", "wss") else ("https" if secure else "http") return f"{scheme}://{host}/browser/{key}{match.group(0)}" + (f"?{parts.query}" if parts.query else "") @@ -99,19 +102,14 @@ def rewritten(body: Any, fields: frozenset[str], base: str, key: str) -> Any: return body -def public_base(connection: Request | WebSocket, gateway_url: str | None = None) -> str | None: +def public_base(connection: Request | WebSocket) -> str | None: """The address the client reached the gateway at, scheme, host and port: the Host it sent, with the scheme a - TLS-terminating tunnel in front says it was reached by (``X-Forwarded-Proto``), else ``gateway_url``'s, the - address the deploy reaches the gateway at, when the Host is its host, else the connection's own. None for a Host - that isn't a host and port. A rewrite with it goes back only to the client that sent it.""" + TLS-terminating tunnel in front says it was reached by (``X-Forwarded-Proto``), else the connection's own. + None for a Host that isn't a host and port. A rewrite with it goes back only to the client that sent it.""" host = connection.headers.get("host", "") if not HOST.fullmatch(host): return None proto = connection.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower() - known = urlsplit(gateway_url) if gateway_url else None - if proto not in ("http", "https") and known and known.scheme in ("http", "https") \ - and known.netloc.lower() == host.lower(): - proto = known.scheme if proto not in ("http", "https"): proto = "https" if connection.url.scheme in ("https", "wss") else "http" return f"{proto}://{host}" @@ -131,15 +129,12 @@ class BrowserRoutes: """The ``/browser//cdp//`` route over a gateway's ``/svc`` children, and the rewrite of a browser's ``cdp`` answer on their ``/svc`` route. ``log_event`` writes a trajectory event, ``attribution`` names a role's caller for one, and ``role_of`` reads the role a connection's ``AgentEnv-Role`` header names. The headers named in - ``withheld`` (lower case), the sandbox provider's credentials, reach no browser; ``gateway_url`` is the address the - deploy reaches the gateway at (``public_base``).""" + ``withheld`` (lower case), the sandbox provider's credentials, reach no browser.""" def __init__(self, rest_proxy_urls: dict[str, str], timeout: httpx.Timeout, log_event: Callable[[dict], Awaitable[Any]], attribution: Callable[[str], dict], - role_of: Callable[[Request | WebSocket], str], *, withheld: frozenset[bytes] = frozenset(), - gateway_url: str | None = None): + role_of: Callable[[Request | WebSocket], str], *, withheld: frozenset[bytes] = frozenset()): self._urls = rest_proxy_urls - self._gateway_url = gateway_url self._withheld = withheld self._timeout = timeout self._log_event = log_event @@ -175,7 +170,7 @@ async def discovery(self, request: Request) -> Response: browser = await self.browser(key) if isinstance(browser, Response): return browser - base = public_base(request, self._gateway_url) + base = public_base(request) if base is None: return _error(400, "the Host header isn't a host and port") headers = client_headers(request, self._withheld) @@ -196,7 +191,7 @@ async def cdp_answer(self, request: Request, base_url: str, role: str) -> Respon browser = await self.browser(key) if not isinstance(browser, Browser) or path != browser.cdp_path: return None - base = public_base(request, self._gateway_url) + base = public_base(request) if base is None: return _error(400, "the Host header isn't a host and port") try: diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index f26d18ad8..37af9456c 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -150,9 +150,6 @@ def sources(names: list[str]) -> str: # Sent by a computer's site proxy, which serves a website's route at the root of the site's own name. AGENT_ENV_SITE_ROOT_HEADER = "AgentEnv-Site-Root" GATEWAY_PROVIDER_HEADERS_ENV = "AGENTENV_PROVIDER_REQUEST_HEADERS" -# The gateway's own address, its deploy record's gateway_url, whose scheme a client reaching it at that host used when -# a TLS-terminating tunnel in front sends no X-Forwarded-Proto, as Modal's doesn't. -GATEWAY_URL_ENV = "AGENTENV_GATEWAY_URL" # MCP request `_meta` keys the gateway stamps on every child tools/call: the caller's role, and on the MCP path a # key naming the inbound session, so a child can keep per-caller state behind the gateway's one shared session. AGENT_ENV_ROLE_META_KEY = "agentenv.io/role" diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 5c26f7277..1437372ff 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -8,8 +8,8 @@ import httpx from . import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode, InternalMCPServer -from .constants import (CONTROL_TOKEN_ENV, DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, GATEWAY_URL_ENV, - MCP_TRANSPORT, WELL_KNOWN_PATH) +from .constants import (CONTROL_TOKEN_ENV, DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, MCP_TRANSPORT, + WELL_KNOWN_PATH) from .gateway import Gateway, card_url logger = logging.getLogger(__name__) @@ -218,6 +218,5 @@ def parse_name_url_map(env_str: str) -> dict[str, str]: service_db_url=service_db_url, control_token=os.environ.get(CONTROL_TOKEN_ENV), provider_headers=os.environ.get(GATEWAY_PROVIDER_HEADERS_ENV, "").split(","), - gateway_url=os.environ.get(GATEWAY_URL_ENV) or None, ) gateway.run() diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 8832b7510..3b257b056 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -142,11 +142,9 @@ def __init__( service_db_url: str | None = None, control_token: str | None = None, provider_headers: Iterable[str] = (), - gateway_url: str | None = None, ): """`provider_headers` names the headers the sandbox provider in front of the gateway is sent, its credentials, - which the gateway takes off every request it forwards to a service behind it; `gateway_url`, the address the - deploy reaches it at, gives the scheme of a CDP address it hands a client that reached it there.""" + which the gateway takes off every request it forwards to a service behind it.""" self.host = host self.port = port self.server_name = server_name @@ -157,8 +155,7 @@ def __init__( self._site_reserved: dict[str, frozenset[str]] = {} self._withheld = frozenset(name.strip().lower().encode() for name in provider_headers if name.strip()) self._browsers = BrowserRoutes(self.rest_proxy_urls, self.REST_PROXY_TIMEOUT, self._log_event, - self._attribution, self._request_role, withheld=self._withheld, - gateway_url=gateway_url) + self._attribution, self._request_role, withheld=self._withheld) for name in _TOKEN_LOGGERS: # each names a CDP address's path, its token in it, at INFO log = logging.getLogger(name) if not any(isinstance(f, TokenRedaction) for f in log.filters): diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index b7087d400..a98709ad5 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -35,7 +35,6 @@ from agent_env.env.gateway.constants import ( DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, - GATEWAY_URL_ENV, STATE_TOOL_NAME_CLASHES_KEY, WELL_KNOWN_PATH, random_mcp_server_name, @@ -321,7 +320,6 @@ def create_docker_compose( extra_hosts: tuple[str, ...] = (), mcp_server_name: str | None = None, external_children: tuple[ExternalChild, ...] = (), - gateway_url: str | None = None, control_token: str | None = None, ) -> str: """Generate docker-compose.yml content for a gateway deployment onto a VM/laptop/arbitrary machine. @@ -537,8 +535,6 @@ def create_docker_compose( gateway_env.extend(f" - {key}={value}" for key, value in control_environment(control_token).items()) if provider_headers := sandbox_request_header_names(): gateway_env.append(f" - {GATEWAY_PROVIDER_HEADERS_ENV}={','.join(provider_headers)}") - if gateway_url: - gateway_env.append(f" - {GATEWAY_URL_ENV}={gateway_url}") publish = host_port or (lambda port: port) exposed_gateway_ports = [] if expose_gateway_port: @@ -886,7 +882,6 @@ async def _stand_up(_spec): host_ips=sandbox.host_ips, extra_hosts=sandbox.extra_hosts, mcp_server_name=mcp_server_name, - gateway_url=sandbox.tunnel_urls.get(gateway_port), control_token=control_token, ) compose_content = compose() diff --git a/src/agent_env/task_step/task_steps/browser/connect_cdp.py b/src/agent_env/task_step/task_steps/browser/connect_cdp.py index e2b41f59b..52e09c973 100644 --- a/src/agent_env/task_step/task_steps/browser/connect_cdp.py +++ b/src/agent_env/task_step/task_steps/browser/connect_cdp.py @@ -86,11 +86,9 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: raise RuntimeError(f"agent {agent.agent_name!r} can't take a CDP address: its card has no " f"{A2AAgent.EXT_CDP_CONFIG}") base, theirs = _env_urls(env, agent) - if deployed.env_id == self.env_id: - asked = {"user": agent.agent_name, **({"origin": theirs} if _takes_origin(card) else {})} - address = await self._call(context, "cdp", asked, CDP_TIMEOUT_S) - else: - address = await _cdp_at_gateway(env, deployed, card, {"user": agent.agent_name}) + asked = {"user": agent.agent_name, **({"origin": theirs} if _takes_origin(card) else {})} + address = (await self._call(context, "cdp", asked, CDP_TIMEOUT_S) if deployed.env_id == self.env_id + else await _cdp_at_gateway(env, deployed, card, asked)) http_url = _rebased(address["http_url"], base, theirs) ws_url = _rebased(address["ws_url"], _ws(base), _ws(theirs)) async with httpx.AsyncClient(timeout=ADD_TIMEOUT_S) as client: @@ -116,7 +114,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: async def _cdp_at_gateway(gateway: DeployedEnv, computer: DeployedEnv, card: dict, params: dict) -> dict: """The browser's ``cdp`` answer, the browser ``card`` names in ``computer``, one of the multi env ``gateway``'s computers: asked through the gateway's ``/svc`` route to that computer, with the computer's control token, so the - gateway names its own address in it, its CDP relay's, as it does for any client reaching it there.""" + gateway names its own address in it, its CDP relay's, as it does for any client reaching it there, keeping the + https or wss of the origin in ``params`` the browser minted it under.""" key = next((name for name, record in (getattr(gateway, "child_envs", None) or {}).items() if record.get("sandbox_id") == computer.sandbox_id), None) if key is None: diff --git a/tst/integration/computer/browser_computer_test.py b/tst/integration/computer/browser_computer_test.py index b4fc8b760..5e3656cca 100644 --- a/tst/integration/computer/browser_computer_test.py +++ b/tst/integration/computer/browser_computer_test.py @@ -610,10 +610,10 @@ async def _through_port(deployed, url: str, proxy: str = "http://127.0.0.1:3129" "text: document.body ? document.body.innerText.slice(0, 200) : ''})") -async def _cdp_agent_loads(ws_url: str, urls: list[str]) -> dict: +async def _cdp_agent_loads(ws_url: str, urls: list[str], tls: ssl.SSLContext | None = None) -> dict: """What a CDP agent at ``ws_url`` sees navigating a tab of its own to each of ``urls``, one after another: - ``Page.navigate``'s error, if any, and the page once it has loaded.""" - async with connect(ws_url, max_size=None, open_timeout=60) as ws: + ``Page.navigate``'s error, if any, and the page once it has loaded; trusting ``tls`` for a wss address.""" + async with connect(ws_url, max_size=None, open_timeout=60, **({"ssl": tls} if tls else {})) as ws: ids = itertools.count(1) async def call(method: str, params: dict, session: str | None = None) -> dict: @@ -1147,13 +1147,16 @@ async def _answers_again(deployed) -> bool: return False -async def _cdp_at_gateway(deployed, browser: DeployedEnv) -> dict: - """The CDP address the browser gives, asked through the gateway's ``/svc/chromium/`` route with the browser's own - control token, as agentenv asks a computer child.""" +async def _cdp_at_gateway(deployed, browser: DeployedEnv, *, at: str | None = None, user: str = "ada", + origin: str | None = None, ca: LocalCA | None = None) -> dict: + """The CDP address the browser gives ``user``, asked through the gateway's ``/svc/chromium/`` route, at ``at``, a + front before it, else its own address, with the browser's own control token, as agentenv asks a computer child, + naming ``origin`` or not.""" app = next(child for child in browser.environment_card["children_environments"] if child["name"] == "chromium") path = protocol_v1.find_extension_method(app, BROWSER, "cdp")["endpoint"] - async with httpx.AsyncClient(timeout=180) as http: - answer = await http.post(f"{deployed.gateway_url}/svc/chromium{path}", json={"user": "ada"}, + async with httpx.AsyncClient(timeout=180, verify=_trusting(ca) if ca else True) as http: + answer = await http.post(f"{at or deployed.gateway_url}/svc/chromium{path}", + json={"user": user, **({"origin": origin} if origin else {})}, headers=browser.control_headers) assert answer.status_code == 200, answer.text return answer.json() @@ -1303,6 +1306,35 @@ async def test_browser_connect_cdp_hands_an_agent_an_address_at_the_multi_envs_g assert not report.failed +@pytest.mark.asyncio +async def test_behind_a_front_that_ends_tls_the_multi_envs_gateway_hands_out_https_and_wss_and_an_agent_connects( + browser_computer, shop): + ca = local_ca() + with namespace_routing(): + office = _office(shop, [Env.get(browser_computer.id)]) + context = TaskStepContext() + try: + [deployed] = (await DeployEnvTaskStep(id="office", version=None, env_id=office.id, sandbox_type="local", + ttl_seconds=900, child_envs={"chromium": MULTI_CHROMIUM} + ).execute(context)).deployed_envs + browser = DeployedEnv.from_dict(deployed.child_envs["chromium"]) + gateway = urllib.parse.urlsplit(deployed.gateway_url) + async with tls_front(ca, gateway.hostname, gateway.port) as front: + named = await _cdp_at_gateway(deployed, browser, at=front, origin=front, ca=ca) + unnamed = await _cdp_at_gateway(deployed, browser, at=front, user="bob", ca=ca) + discovered = await _discovered(named["http_url"], ca) + seen = await _cdp_agent_loads(named["ws_url"], [MULTI_SITE], tls=_trusting(ca)) + finally: + report = await teardown_run(context) + + host = front.removeprefix("https://") + assert named["http_url"].startswith(f"{front}/browser/chromium/cdp/"), named + assert named["ws_url"].startswith(f"wss://{host}/browser/chromium/cdp/") and discovered == named["ws_url"], named + assert unnamed["ws_url"].startswith(f"ws://{host}/browser/chromium/cdp/"), unnamed + assert seen[MULTI_SITE]["error"] is None and seen[MULTI_SITE].get("ready") == "complete", seen + assert not report.failed + + @pytest.mark.asyncio async def test_a_multi_env_naming_no_browser_deploys_none(browser_computer, shop): with namespace_routing(): diff --git a/tst/unit/env/gateway/browser_route_test.py b/tst/unit/env/gateway/browser_route_test.py index db04640fa..e0d0ad723 100644 --- a/tst/unit/env/gateway/browser_route_test.py +++ b/tst/unit/env/gateway/browser_route_test.py @@ -63,8 +63,14 @@ def saw(connection) -> None: async def served_card(_: Request) -> JSONResponse: return JSONResponse(card) + named: dict[str, str] = {} + async def cdp(request: Request) -> JSONResponse: saw(request) + if origin := (await request.json()).get("origin"): # minted under it, as a browser taking one does + root = f"{origin.rstrip('/')}{request.headers.get('x-forwarded-prefix', '')}{ROUTE}{TOKEN}" + named["ws_url"] = "wss" + root.removeprefix("https") + "/devtools/browser" + return JSONResponse({"http_url": f"{root}/", "ws_url": named["ws_url"]}) return JSONResponse({"http_url": MINTED, "ws_url": "ws://browser-host:7000/svc/chromium/cdp/t0k3n/devtools/browser"}) async def discovery(request: Request) -> JSONResponse: @@ -75,7 +81,7 @@ async def discovery(request: Request) -> JSONResponse: return JSONResponse([{"id": "1", "type": "page", "title": "Home", "url": "about:blank", "webSocketDebuggerUrl": f"ws://browser-host:7000/svc/chromium/cdp/{TOKEN}/devtools/page/1", "devtoolsFrontendUrl": ""}]) - return JSONResponse({"Browser": "Chrome/156", "webSocketDebuggerUrl": + return JSONResponse({"Browser": "Chrome/156", "webSocketDebuggerUrl": named.get("ws_url") or f"ws://browser-host:7000/svc/chromium/cdp/{TOKEN}/devtools/browser"}) async def browser_socket(websocket: WebSocket) -> None: @@ -124,22 +130,15 @@ async def _serve(app) -> tuple[uvicorn.Server, str]: @asynccontextmanager async def _gateway(tmp_path, monkeypatch, card: dict | None = None, seen: list[dict] | None = None, - provider_headers: tuple[str, ...] = (), public: str | None = None): + provider_headers: tuple[str, ...] = ()): """The real gateway app on loopback, its /svc child ``web`` a browser computer serving ``card``; yields the gateway's base URL and a reader of its browser_connection events, which waits for ``at_least``: each is written as its connection ends, just after the client sees the close.""" monkeypatch.setattr(gw_mod, "GATEWAY_TRAJECTORY_FILE", str(tmp_path / "trajectory.jsonl")) upstream, upstream_url = await _serve(_browser_computer(_computer_card(_app_card()) if card is None else card, seen)) - gateway_app = None - - async def app(scope, receive, send): # the gateway, built once its address is known - await gateway_app(scope, receive, send) - - gateway, gateway_url = await _serve(app) gw = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], - rest_proxy_urls={"web": upstream_url}, provider_headers=provider_headers, - gateway_url=public and public.format(address=gateway_url.removeprefix("http://"))) - gateway_app = gw._asgi_app() + rest_proxy_urls={"web": upstream_url}, provider_headers=provider_headers) + gateway, gateway_url = await _serve(gw._asgi_app()) async def events(at_least: int) -> list[dict]: for _ in range(50): found = [event for event in map(json.loads, (tmp_path / "trajectory.jsonl").read_text().splitlines()) @@ -175,12 +174,12 @@ async def test_the_cdp_answer_and_discovery_name_the_gateway_however_the_browser @pytest.mark.asyncio -async def test_behind_a_tunnel_that_says_no_scheme_the_addresses_take_the_scheme_the_deploy_reaches_the_gateway_at( +async def test_behind_a_tunnel_that_says_no_scheme_an_address_minted_under_an_https_origin_stays_https_and_wss( tmp_path, monkeypatch): - async with _gateway(tmp_path, monkeypatch, public="https://{address}") as (url, _), httpx.AsyncClient() as client: - answer = (await client.post(f"{url}/svc/web{CDP_PATH}", json={"user": "solver"})).json() + async with _gateway(tmp_path, monkeypatch) as (url, _), httpx.AsyncClient() as client: + answer = (await client.post(f"{url}/svc/web{CDP_PATH}", + json={"user": "solver", "origin": "https://gw-18765.w.modal.host"})).json() version = (await client.get(f"{url}/browser/web/cdp/{TOKEN}/json/version")).json() - at = url.removeprefix("http://") assert answer == {"http_url": f"https://{at}/browser/web/cdp/{TOKEN}/", "ws_url": f"wss://{at}/browser/web/cdp/{TOKEN}/devtools/browser"} @@ -294,6 +293,10 @@ def test_an_address_is_rebased_by_its_path_and_anything_else_is_kept(): assert at_gateway("ws://x/svc/chromium/cdp/abc/devtools/browser?y=1", base, "b") == ( "wss://gw.example:8443/browser/b/cdp/abc/devtools/browser?y=1") assert at_gateway("ws://x/cdp/abc", "http://gw", "b") == "ws://gw/browser/b/cdp/abc" + assert at_gateway("https://named.example/svc/chromium/cdp/abc/", "http://gw", "b") == ( + "https://gw/browser/b/cdp/abc/") + assert at_gateway("wss://named.example/cdp/abc/devtools/browser", "http://gw", "b") == ( + "wss://gw/browser/b/cdp/abc/devtools/browser") for kept in ("", "/devtools/inspector.html?ws=x/cdp/abc", "http://x/devtools/browser", "file:///cdp/abc/"): assert at_gateway(kept, base, "b") == kept assert rewritten({"webSocketDebuggerUrl": "ws://x/cdp/a/", "other": "ws://x/cdp/a/"}, frozenset({"webSocketDebuggerUrl"}), @@ -320,19 +323,6 @@ def request(headers: dict, scheme: str = "http") -> Request: assert public_base(request({"host": bad})) is None -def test_a_tunnel_that_says_no_scheme_takes_the_scheme_of_the_address_the_deploy_reaches_the_gateway_at(): - def request(headers: dict) -> Request: - return Request({"type": "http", "scheme": "http", "path": "/", "headers": - [(k.lower().encode(), v.encode()) for k, v in headers.items()]}) - - tunnel = "https://gw-18765.w.modal.host" - assert public_base(request({"host": "gw-18765.w.modal.host"}), tunnel) == tunnel - assert public_base(request({"host": "GW-18765.w.modal.host"}), tunnel) == "https://GW-18765.w.modal.host" - assert public_base(request({"host": "other.example"}), tunnel) == "http://other.example" - assert public_base(request({"host": "gw-18765.w.modal.host", "x-forwarded-proto": "http"}), tunnel) == ( - "http://gw-18765.w.modal.host") - - def test_no_log_line_shows_a_cdp_addresss_token(): record = logging.LogRecord("uvicorn.error", logging.INFO, __file__, 1, '%s - "WebSocket %s" [accepted]', ("127.0.0.1:5", f"/browser/web/cdp/{TOKEN}/devtools/browser"), None) diff --git a/tst/unit/providers/env_providers/env_gateway_provider_test.py b/tst/unit/providers/env_providers/env_gateway_provider_test.py index d28bac939..0de98b99d 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -29,7 +29,7 @@ from agent_env.env.env import DeployedGatewayEnv, _mcp_url from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.multi_env import MultiEnv -from agent_env.env.envs.service_db import SERVICE_DB_PORT, ServiceDBConfig +from agent_env.env.envs.service_db import SERVICE_DB_PORT from agent_env.env.envs.website import WebsiteEnv from agent_env.providers.env_providers import env_gateway_provider, env_provider from agent_env.providers.env_providers.constants import GATEWAY_APP_DIR, GATEWAY_SERVICE_NAME @@ -162,7 +162,6 @@ async def test_container_mode_rejects_websites(): ) - def _vm_provider(**attrs) -> MagicMock: """A sandbox provider that creates VMs, where a gateway runs, as every VM provider does.""" provider = MagicMock(ON_THIS_MACHINE=False, SUPPORTED_SANDBOX_MODES=VMS_AND_CONTAINERS, SANDBOX_ACCEPTS=Accepts.LOADABLE, **attrs) @@ -501,7 +500,6 @@ async def fake_create_container(**kwargs): assert gateway_call["env"]["SERVICE_DB_URL"] == db_url_base - @pytest.mark.asyncio async def test_container_mode_mcp_url_follows_the_card(): """The recorded MCP URL is the one the gateway's card declares, joined onto the gateway URL.""" @@ -536,7 +534,6 @@ async def fake_create_container(**kwargs): assert result.environment_card == card - @pytest.mark.parametrize("gateway_url,declared,expected", [ ("https://gw.example", None, "https://gw.example/mcp"), ("https://gw.example", "/custom-mcp", "https://gw.example/custom-mcp"), @@ -1269,19 +1266,6 @@ def test_compose_gives_the_gateway_the_names_of_the_providers_request_headers_al assert {name: entries for name, entries in named.items() if entries} == expected -@pytest.mark.parametrize("gateway_url", ["https://gw.w.modal.host", None], ids=["tunnel", "none"]) -def test_the_gateway_alone_is_told_the_address_the_deploy_reaches_it_at(gateway_url): - compose = EnvironmentGatewayProvider().create_docker_compose( - mcp_servers=[MCPServerConfig(image="mcp-slack", environment_name="slack")], gateway_image="agent-gateway", - state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), - state_instance=LocalPostgresStateProvider.default_instance(), gateway_url=gateway_url) - - told = {name: [entry for entry in service.get("environment") or [] if "AGENTENV_GATEWAY_URL" in entry] - for name, service in yaml.safe_load(compose)["services"].items()} - expected = {GATEWAY_SERVICE_NAME: [f"AGENTENV_GATEWAY_URL={gateway_url}"]} if gateway_url else {} - assert {name: entries for name, entries in told.items() if entries} == expected - - @pytest.mark.asyncio async def test_acquire_state_for_deploy_local_returns_none(): """Local Postgres cannot be pre-provisioned (its container's host doesn't exist yet), so the diff --git a/tst/unit/task_step/test_browser_connect_cdp.py b/tst/unit/task_step/test_browser_connect_cdp.py index a7000bebd..de0e05d1d 100644 --- a/tst/unit/task_step/test_browser_connect_cdp.py +++ b/tst/unit/task_step/test_browser_connect_cdp.py @@ -97,6 +97,22 @@ async def test_a_browser_in_a_multi_envs_computer_is_reached_through_its_gateway assert context.metadata["browser_connections"][0]["env_id"] == "office" +@pytest.mark.asyncio +async def test_behind_a_tunnel_that_ends_tls_the_gateway_is_asked_under_the_agents_origin_and_hands_out_https_and_wss( + monkeypatch): + sent = _mock(monkeypatch, tokens=["t1"], relay="gw.w.modal.host") # the gateway sees plain http, as behind Modal + context = TaskStepContext(deployed_envs=[_multi("https://gw.w.modal.host", "modal", _BROWSER_TAKING_ORIGIN)], + deployed_agents=[_agent(sandbox_type="modal")], metadata={}) + + await BrowserConnectCdpTaskStep(id="connect", version=None, env_id="office", agent_name="solver").execute(context) + + asked, added, _ = sent + assert json.loads(asked.content) == {"user": "solver", "origin": "https://gw.w.modal.host"} + assert (json.loads(added.content)["http_url"], json.loads(added.content)["ws_url"]) == ( + "https://gw.w.modal.host/browser/chromium/cdp/t1/", + "wss://gw.w.modal.host/browser/chromium/cdp/t1/devtools/browser") + + @pytest.mark.asyncio async def test_a_cdp_address_the_multi_envs_gateway_didnt_relay_is_refused_before_the_agent_gets_it(monkeypatch): sent = _mock(monkeypatch, tokens=["t1"]) # the computer's own address, as a gateway answers for several browsers @@ -184,25 +200,27 @@ def _env(children: list | None = None, *, url: str = _ENV, sandbox_type: str = " "children_environments": [_app("chromium")] if children is None else children}) -def _multi() -> DeployedGatewayEnv: - """A multi env's deployment whose computer ``chromium`` is the browser computer ``_env`` gives, its gateway - serving tool access on its own card.""" +def _multi(gateway: str = _GATEWAY, sandbox_type: str = "local", browser: dict = _BROWSER) -> DeployedGatewayEnv: + """A multi env's deployment at ``gateway`` whose computer ``chromium`` is the browser computer ``_env`` gives, + serving ``browser``, its gateway serving tool access on its own card.""" return DeployedGatewayEnv( - env_id="office", env_version=1, gateway_url=_GATEWAY, sandbox_id="gw", sandbox_type="local", - control_token="gateway-control", environment_card_url=f"{_GATEWAY}/.well-known/agent-env.json", + env_id="office", env_version=1, gateway_url=gateway, sandbox_id="gw", sandbox_type=sandbox_type, + control_token="gateway-control", environment_card_url=f"{gateway}/.well-known/agent-env.json", environment_card={"name": "office", "capabilities": {"extensions": [_TOOL_ACCESS]}, "children_environments": [{"name": "chromium"}]}, - child_envs={"chromium": dataclasses.asdict(_env())}) + child_envs={"chromium": dataclasses.asdict(_env([_app("chromium", browser)], sandbox_type=sandbox_type))}) -def _agent(role: str | None = None, extensions: list | None = None) -> DeployedAgent: - return DeployedAgent(agent_name="solver", api_url=_AGENT, a2a_url=_AGENT, sandbox_type="local", role=role, +def _agent(role: str | None = None, extensions: list | None = None, sandbox_type: str = "local") -> DeployedAgent: + return DeployedAgent(agent_name="solver", api_url=_AGENT, a2a_url=_AGENT, sandbox_type=sandbox_type, role=role, a2a_card={"capabilities": {"extensions": [_CDP_CONFIG] if extensions is None else extensions}}) -def _mock(monkeypatch, *, tokens: list[str], base: str = f"{_ENV}/svc/chromium") -> list[httpx.Request]: - """Answer the browser's cdp with each of ``tokens`` in turn, under the origin it names, else at ``base``, and - anything else as done; return the requests.""" +def _mock(monkeypatch, *, tokens: list[str], base: str = f"{_ENV}/svc/chromium", + relay: str | None = None) -> list[httpx.Request]: + """Answer the browser's cdp with each of ``tokens`` in turn, under the origin it names, else at ``base``, or, as a + gateway at the host ``relay`` that sees plain http, at its relay in the origin's scheme; anything else as done; + return the requests.""" sent, real, left = [], httpx.AsyncClient, iter(tokens) def handle(request: httpx.Request) -> httpx.Response: @@ -210,6 +228,8 @@ def handle(request: httpx.Request) -> httpx.Response: if request.url.path.endswith("/browser/cdp"): token, origin = next(left), json.loads(request.content).get("origin") at = f"{origin}/svc/chromium" if origin else base + if relay: + at = f"{'https' if (origin or '').startswith('https') else 'http'}://{relay}/browser/chromium" ws = "wss" + at.removeprefix("https") if at.startswith("https") else "ws" + at.removeprefix("http") return httpx.Response(200, json={"http_url": f"{at}/cdp/{token}/", "ws_url": f"{ws}/cdp/{token}/devtools/browser"}) From 95a0dcc4e05af38cf773872ab662b6bb9da2fd95 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 19:35:42 -0800 Subject: [PATCH 14/15] fix(browser): a browser that takes no origin, behind a tunnel gateway, is refused with word to compose it again browser_connect_cdp refused such a browser's address as one the gateway didn't relay, blaming a computer with several browsers. It is at the relay, in http where the gateway is reached by https: the browser, composed before agentenv-framework 0.9.1505, takes no origin. The step now says so. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/browsers/README.md | 3 ++- .../task_step/task_steps/browser/connect_cdp.py | 16 ++++++++++------ tst/unit/task_step/test_browser_connect_cdp.py | 12 ++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/agent_env/browsers/README.md b/src/agent_env/browsers/README.md index 56438cbf1..5ee314f81 100644 --- a/src/agent_env/browsers/README.md +++ b/src/agent_env/browsers/README.md @@ -45,5 +45,6 @@ the browser apps of its computers, each through the computer's own record and co env, so through a tunnel that ends TLS it's https and wss; on a multi env, one at its gateway, asked through the gateway's `/svc//` route, so the agent reaches the browser through the gateway's CDP relay as it reaches the rest of the env, with the multi env's tools hidden from its role; a computer there with several website browsers is -refused, as the relay doesn't tell them apart yet. A whole state, Playwright's `storageState` +refused, as the relay doesn't tell them apart yet, and so, behind a tunnel that ends TLS, is a browser that takes no +origin, one composed before agentenv-framework 0.9.1505. A whole state, Playwright's `storageState` included, loads through `load_artifact` or `data/add` instead. diff --git a/src/agent_env/task_step/task_steps/browser/connect_cdp.py b/src/agent_env/task_step/task_steps/browser/connect_cdp.py index 52e09c973..836b761cf 100644 --- a/src/agent_env/task_step/task_steps/browser/connect_cdp.py +++ b/src/agent_env/task_step/task_steps/browser/connect_cdp.py @@ -127,12 +127,16 @@ async def _cdp_at_gateway(gateway: DeployedEnv, computer: DeployedEnv, card: dic address = await invoke_extension(f"{base}/svc/{key}", card, BROWSER, params, CDP_TIMEOUT_S, method="cdp", headers=computer.control_headers) relayed = (f"{base}/browser/{key}/", f"{_ws(base)}/browser/{key}/") - if not (str(address.get("http_url", "")).startswith(relayed[0]) and - str(address.get("ws_url", "")).startswith(relayed[1])): - raise RuntimeError(f"env {gateway.env_id!r}'s gateway didn't put the CDP address of the website browser in its " - f"computer {key!r} at its relay, as it does for a computer with one website browser; one " - "with several it doesn't tell apart yet") - return address + http_url, ws_url = str(address.get("http_url", "")), str(address.get("ws_url", "")) + if http_url.startswith(relayed[0]) and ws_url.startswith(relayed[1]): + return address + if "origin" not in params and http_url.split("://", 1)[-1].startswith(relayed[0].split("://", 1)[1]): + raise RuntimeError(f"the website browser in env {gateway.env_id!r}'s computer {key!r} takes no origin, so " + f"behind a tunnel that ends TLS its CDP address doesn't say {base.split('://')[0]} as the " + "gateway is reached: compose it again on agentenv-framework 0.9.1505 or later") + raise RuntimeError(f"env {gateway.env_id!r}'s gateway didn't put the CDP address of the website browser in its " + f"computer {key!r} at its relay, as it does for a computer with one website browser; one " + "with several it doesn't tell apart yet") def _deployed_agent(context: TaskStepContext, agent_name: str) -> DeployedAgent: diff --git a/tst/unit/task_step/test_browser_connect_cdp.py b/tst/unit/task_step/test_browser_connect_cdp.py index de0e05d1d..d4c7cba96 100644 --- a/tst/unit/task_step/test_browser_connect_cdp.py +++ b/tst/unit/task_step/test_browser_connect_cdp.py @@ -113,6 +113,18 @@ async def test_behind_a_tunnel_that_ends_tls_the_gateway_is_asked_under_the_agen "wss://gw.w.modal.host/browser/chromium/cdp/t1/devtools/browser") +@pytest.mark.asyncio +async def test_behind_a_tunnel_a_browser_that_takes_no_origin_is_refused_with_word_to_compose_it_again(monkeypatch): + _mock(monkeypatch, tokens=["t1"], relay="gw.w.modal.host") # composed before #608: its cdp takes no origin + context = TaskStepContext(deployed_envs=[_multi("https://gw.w.modal.host", "modal")], + deployed_agents=[_agent(sandbox_type="modal")], metadata={}) + + with pytest.raises(RuntimeError, match="takes no origin.*compose it again on agentenv-framework 0.9.1505"): + await BrowserConnectCdpTaskStep(id="connect", version=None, env_id="office", + agent_name="solver").execute(context) + assert "browser_connections" not in context.metadata + + @pytest.mark.asyncio async def test_a_cdp_address_the_multi_envs_gateway_didnt_relay_is_refused_before_the_agent_gets_it(monkeypatch): sent = _mock(monkeypatch, tokens=["t1"]) # the computer's own address, as a gateway answers for several browsers From 767ed3e77ec7643afd141f35c5205bd2adb864a8 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 20:33:29 -0800 Subject: [PATCH 15/15] refactor(multi): one place picks a multi env's website browser and where it runs MultiEnv.website_browser() gives the browser a deploy adds and where it runs, a WebsiteBrowser: a browser computer on a sandbox of its own, an MCP server env among the gateway's MCP servers, or the run's, which the gateway looks up as it deploys when the store doesn't hold it yet. It replaces browser_env, browser_computer and _browser_computer, and the gateway's topology reads it instead of choosing again (_named_browser_server goes). A run's MCP server browser the store holds is now handed to the gateway rather than looked up a second time. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/multi_env.py | 79 +++++++++++-------- .../env_providers/env_gateway_provider.py | 11 +-- .../task_step/task_steps/browser/base.py | 2 +- tst/unit/env/envs/test_multi_env_computers.py | 24 +++--- 4 files changed, 62 insertions(+), 54 deletions(-) diff --git a/src/agent_env/env/envs/multi_env.py b/src/agent_env/env/envs/multi_env.py index f71b5557d..e8c7f0ca1 100644 --- a/src/agent_env/env/envs/multi_env.py +++ b/src/agent_env/env/envs/multi_env.py @@ -5,6 +5,7 @@ import os import re import shlex +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Callable, ClassVar, Optional import yaml @@ -65,6 +66,34 @@ def _snapshot_after_load_default() -> bool: +@dataclass(frozen=True) +class WebsiteBrowser: + """The website browser a multi env's deploy adds, and where it runs: ``computer``, a browser computer, on a sandbox + of its own beside its computers; ``server``, an MCP server env, among its gateway's MCP servers; or neither, with + ``runs`` when it is the run's (``website_browser_env_id``) and the store doesn't hold it as either yet, an infra + env a run may set up later, which the gateway looks up as it deploys.""" + + computer: Optional[ComputerEnv] = None + server: Optional[MCPServerEnv] = None + runs: bool = False + + @property + def env(self) -> Optional[Env]: + return self.computer or self.server + + +def placed(browser: Optional[Env]) -> WebsiteBrowser: + """``browser`` where a multi env runs it: a computer on a sandbox of its own, an MCP server env among its gateway's + MCP servers, and an env of another kind left for the gateway to look up as it deploys.""" + if browser is None: + return WebsiteBrowser() + if isinstance(browser, get_env_registry()[COMPUTER_ENV_TYPE]): + return WebsiteBrowser(computer=browser) + if isinstance(browser, MCPServerEnv): + return WebsiteBrowser(server=browser) + return WebsiteBrowser(runs=True) + + def browser_problems(envs: list[Env]) -> list[str]: """Why ``envs``, a multi env's ``browser_envs``, can't be its website browsers: more than one, as a multi env runs one, or an env that isn't a website browser env, an MCP server env serving one as ``playwright-mcp`` does or a @@ -132,7 +161,7 @@ def __init__(self, id: str, version: Optional[int], mcp_server_envs: list[MCPSer self.browser_envs = list(browser_envs) if browser_envs is not None else None problems = browser_problems(self.browser_envs or []) if not problems: - problems = self._name_problems(self.browser_envs[0] if self.browser_envs else None) + problems = self._name_problems(placed(self.browser_envs[0] if self.browser_envs else None)) if problems: raise ValueError(f"multi env {id!r}: {'; '.join(problems)}") self._sandbox = None @@ -148,12 +177,11 @@ def computer_envs(self) -> list[ComputerEnv]: """Its computers, each on a sandbox of its own, which the gateway fronts by its name: after a deploy, that deploy's.""" return self._computers.envs - def _name_problems(self, browser: Optional[Env]) -> list[str]: + def _name_problems(self, browser: WebsiteBrowser) -> list[str]: """What's wrong with the names of its computers and ``browser``, its website browser, beside its other child envs' (``_multi_computers.name_problems``, ``app_name_problems``): a browser computer named as a computer is, and an MCP server browser as an MCP server, which no other MCP server may name either.""" - computer = self._browser_computer(browser) - server = browser if isinstance(browser, MCPServerEnv) else None + computer, server = browser.computer, browser.server computer_envs = [*self.computer_envs, *([computer] if computer is not None else [])] mcp_servers = [env.environment_name for env in (*self.mcp_server_envs, *([server] if server else []))] websites = [env.environment_name for env in self.website_envs] @@ -169,28 +197,18 @@ def _name_problems(self, browser: Optional[Env]) -> list[str]: if app is not env] # an app deployed alone for env in computer_envs}, [*servers, *computers])] - def browser_env(self) -> Optional[Env]: - """The website browser a deploy adds: the one ``browser_envs`` names, none when it names none, and without it - the run's (``website_browser_env_id``) when it has websites, as every env with websites gets. The run's is None - while the store doesn't hold it yet: an infra env a run may set up later, which the gateway looks up, as it - always has.""" + def website_browser(self) -> WebsiteBrowser: + """The website browser a deploy adds, and where it runs: the one ``browser_envs`` names, none when it names + none, and without it the run's (``website_browser_env_id``) when it has websites, as every env with websites + gets.""" if self.browser_envs is not None: - return self.browser_envs[0] if self.browser_envs else None + return placed(self.browser_envs[0] if self.browser_envs else None) if not self.website_envs: - return None + return WebsiteBrowser() try: - return Env.get(website_browser_env_id(get_config())) + return placed(Env.get(website_browser_env_id(get_config()))) except NotFoundError: - return None - - def browser_computer(self) -> Optional[ComputerEnv]: - """Its website browser (``browser_env``) when that is a computer, which runs on a sandbox of its own beside its - computers rather than among the gateway's MCP servers.""" - return self._browser_computer(self.browser_env()) - - @staticmethod - def _browser_computer(browser: Optional[Env]) -> Optional[ComputerEnv]: - return browser if isinstance(browser, get_env_registry()[COMPUTER_ENV_TYPE]) else None + return WebsiteBrowser(runs=True) def to_dict(self) -> dict: base = super().to_dict() @@ -279,12 +297,11 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None, child_envs: Optional[dict] = None) -> DeployedEnv: - browser = self.browser_env() - computer = self._browser_computer(browser) + browser = self.website_browser() if refusal := self._child_envs_refusal(child_envs, sandbox_type, browser): raise ValueError(refusal) - self._computers.begin(computer) - deployer = self._computers.deployer(self._entries(child_envs, computer), sandbox_type=sandbox_type, + self._computers.begin(browser.computer) + deployer = self._computers.deployer(self._entries(child_envs, browser.computer), sandbox_type=sandbox_type, ttl_seconds=ttl_seconds, attribution=attribution, websites=self._websites(), live_hosts=self.live_hosts()) computers = dict(children=deployer, then=self._computers.finish) if self._computers.every else {} @@ -371,24 +388,24 @@ def deploy_refusal(self, *, sandbox_type: str | None = None, child_envs: Optiona def child_envs_refusal(self, child_envs: Optional[dict], *, sandbox_type: str | None = None) -> str | None: """Why a deploy would refuse ``child_envs``, each child env's options by its environment name, or None. A - computer, and its website browser when that is one (``browser_env``), is checked as its entry, or no entry, + computer, and its website browser when that is one (``website_browser``), is checked as its entry, or no entry, would deploy it on a sandbox of its own, ``sandbox_type`` (the multi env's) unless the entry names one, the browser routing its synthetic websites unless its entry says whether it does; any other child env runs on the multi env's sandbox and takes no entry.""" - return self._child_envs_refusal(child_envs, sandbox_type, self.browser_env()) + return self._child_envs_refusal(child_envs, sandbox_type, self.website_browser()) def _child_envs_refusal(self, child_envs: Optional[dict], sandbox_type: str | None, - browser: Optional[Env]) -> str | None: + browser: WebsiteBrowser) -> str | None: if child_envs and not isinstance(child_envs, dict): return f"child_envs is a table of child env names, not {type(child_envs).__name__}" - computer = self._browser_computer(browser) + computer = browser.computer entries = self._entries(child_envs, computer) or {} child_envs = child_envs or {} computers = {env.environment_name: env for env in [*self.computer_envs, *([computer] if computer else [])]} names = list(dict.fromkeys(env.environment_name for env in (*self.mcp_server_envs, *self.website_envs, *computers.values()))) # A browser browser_envs names had its names checked at put; the run's is checked here. - problems = self._name_problems(browser) if browser is not None and self.browser_envs is None else [] + problems = self._name_problems(browser) if browser.env is not None and self.browser_envs is None else [] for name, entry in child_envs.items(): extra = sorted(map(str, set(entry) - set(CHILD_ENV_OPTIONS))) if isinstance(entry, dict) else [] if name not in names: diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index a98709ad5..6537a4a99 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -1616,13 +1616,6 @@ def _with_browser(mcp_servers: list[MCPServerConfig], images: list[DockerImageAr return [*mcp_servers, server], [*images, browser.docker_image_artifact] -def _named_browser_server(browser_envs: list[Env]) -> "MCPServerEnv | None": - """The website browser a multi env's ``browser_envs`` names, when that is an MCP server env its gateway runs.""" - from agent_env.env.envs.mcp_server import MCPServerEnv - - return next((env for env in browser_envs if isinstance(env, MCPServerEnv)), None) - - def _gateway_topology(env: Env) -> _GatewayTopology: """What a gateway fronts for env: a MultiEnv's MCP servers and websites (whatever their own env_provider_type) under its name and the website browser it names or the run's, a WebsiteEnv's website and the run's browser, or else the env's @@ -1633,8 +1626,8 @@ def _gateway_topology(env: Env) -> _GatewayTopology: is_multi = isinstance(env, MultiEnv) if is_multi: servers, websites, name = env.mcp_server_envs, env.website_envs, env.name - browser = _named_browser_server(env.browser_envs) if env.browser_envs is not None else ( - None if env.browser_computer() is not None else _THE_RUNS) + placed = env.website_browser() + browser = _THE_RUNS if placed.runs else placed.server elif isinstance(env, WebsiteEnv): servers, websites, name, browser = [], [env], None, _THE_RUNS else: diff --git a/src/agent_env/task_step/task_steps/browser/base.py b/src/agent_env/task_step/task_steps/browser/base.py index e52c135d0..e528fdbc6 100644 --- a/src/agent_env/task_step/task_steps/browser/base.py +++ b/src/agent_env/task_step/task_steps/browser/base.py @@ -91,7 +91,7 @@ def stored_browsers(env: Env) -> list[str]: deployed alone, a multi env's computers' browser apps and its browser computer, the one its browser_envs names or the run's; none for any other env.""" if isinstance(env, MultiEnv): - computers = [*env.computer_envs, *([browser] if (browser := env.browser_computer()) else [])] + computers = [*env.computer_envs, *([browser] if (browser := env.website_browser().computer) else [])] return [name for computer in computers for name in stored_browsers(computer)] if not isinstance(env, ComputerEnv): return [] diff --git a/tst/unit/env/envs/test_multi_env_computers.py b/tst/unit/env/envs/test_multi_env_computers.py index dcfce853d..b165bc025 100644 --- a/tst/unit/env/envs/test_multi_env_computers.py +++ b/tst/unit/env/envs/test_multi_env_computers.py @@ -24,7 +24,7 @@ from agent_env.env.envs.computer import COMPUTER_ENV_TYPE from agent_env.env.envs.computer.env import ComputerEnv from agent_env.env.envs.mcp_server import MCPServerEnv -from agent_env.env.envs.multi_env import MultiEnv +from agent_env.env.envs.multi_env import MultiEnv, WebsiteBrowser from agent_env.env.gateway import ExternalChild from agent_env.env.gateway.constants import WELL_KNOWN_PATH from agent_env.env.store import EnvInstanceStore @@ -774,7 +774,7 @@ def test_browser_envs_takes_one_website_browser_env(browsers, problem): @pytest.mark.parametrize("browser", [FakeBrowser, _playwright], ids=["browser-computer", "mcp-server"]) def test_browser_envs_takes_a_browser_computer_or_an_mcp_server_serving_one(browser): - assert _browsing(browser_envs=[browser()]).browser_env().id in ("chromium-computer", "playwright-mcp") + assert _browsing(browser_envs=[browser()]).website_browser().env.id in ("chromium-computer", "playwright-mcp") def test_a_browser_computer_needs_a_name_no_other_computer_has(): @@ -798,24 +798,25 @@ def test_the_runs_mcp_server_browser_is_refused_a_name_a_computer_has(providers) # --- its website browser: which one --- def test_the_browser_is_the_one_named_none_for_an_empty_list_and_else_the_runs_when_there_are_websites(local_stores): - chromium = FakeBrowser() - assert _browsing(browser_envs=[chromium]).browser_env() is chromium - assert _browsing(browser_envs=[]).browser_env() is None - assert _browsing(websites=()).browser_env() is None + chromium, playwright = FakeBrowser(), _playwright() + assert _browsing(browser_envs=[chromium]).website_browser() == WebsiteBrowser(computer=chromium) + assert _browsing(browser_envs=[playwright]).website_browser() == WebsiteBrowser(server=playwright) + assert _browsing(browser_envs=[]).website_browser() == WebsiteBrowser() + assert _browsing(websites=()).website_browser() == WebsiteBrowser() with patch("agent_env.env.env.Env.get", return_value=chromium) as get, \ run_env_ids({WEBSITE_BROWSER_OVERRIDE: "chromium-computer"}): - assert _browsing().browser_env() is chromium and _browsing().browser_computer() is chromium + assert _browsing().website_browser() == WebsiteBrowser(computer=chromium) get.assert_called_with("chromium-computer") def test_a_runs_browser_the_store_doesnt_hold_yet_is_left_for_the_gateway_to_look_up(local_stores): office = _browsing() - assert office.browser_env() is None and office.browser_computer() is None + assert office.website_browser() == WebsiteBrowser(runs=True) assert env_gateway_provider._gateway_topology(office).website_browser is env_gateway_provider._THE_RUNS @pytest.mark.parametrize("browser_envs, runs, expected", [ - (None, _playwright, "the run's"), + (None, _playwright, "playwright-mcp"), (None, FakeBrowser, None), ([], _playwright, None), ("chromium", _playwright, None), @@ -829,10 +830,7 @@ def test_the_gateway_runs_an_mcp_server_browser_and_leaves_a_browser_computer_to with patch("agent_env.env.env.Env.get", return_value=runs()), \ patch.object(env_gateway_provider, "website_config", return_value=MagicMock()): browser = env_gateway_provider._gateway_topology(office).website_browser - if expected == "the run's": - assert browser is env_gateway_provider._THE_RUNS - else: - assert getattr(browser, "id", None) == expected + assert getattr(browser, "id", None) == expected def test_a_lone_website_with_a_browser_computer_as_the_runs_browser_is_refused_naming_a_multi_env():