diff --git a/AGENTS.md b/AGENTS.md index 5af2559e5..8d99e82cf 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 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 b9b9ed31c..5ee314f81 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, 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: @@ -36,7 +40,11 @@ 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, 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, 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/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..552b87ced 100644 --- a/src/agent_env/env/envs/_multi_computers.py +++ b/src/agent_env/env/envs/_multi_computers.py @@ -117,50 +117,70 @@ 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 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 - 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]]]: """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.envs 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] = () @@ -188,13 +208,13 @@ async def deploy(reach: Optional[str]) -> list[ExternalChild]: reach=reach or "", sites_later=env.environment_name in self._routes, **({"live_hosts": live} if live 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 +225,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 +235,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 +245,27 @@ 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] + 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 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/multi_env.py b/src/agent_env/env/envs/multi_env.py index 1b8a0bbb5..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 @@ -35,6 +36,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 +53,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 +64,54 @@ 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") + + +@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 + 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 +134,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 +157,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(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 self._gateway_url = None @@ -127,6 +177,39 @@ 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: 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, 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] + servers = [*mcp_servers, *websites] + computers = [env.environment_name for env in computer_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 + for env in computer_envs}, [*servers, *computers])] + + 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 placed(self.browser_envs[0] if self.browser_envs else None) + if not self.website_envs: + return WebsiteBrowser() + try: + return placed(Env.get(website_browser_env_id(get_config()))) + except NotFoundError: + return WebsiteBrowser(runs=True) + def to_dict(self) -> dict: base = super().to_dict() base["mcp_server_envs"] = [ @@ -139,6 +222,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 +241,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 +275,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 +297,14 @@ 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.website_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(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 {} try: deployed_env = await deploy_through_provider( self, environment_name=None, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type, @@ -227,6 +322,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 +387,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 (``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.website_browser()) + + def _child_envs_refusal(self, child_envs: Optional[dict], sandbox_type: 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 = 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} - 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(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: @@ -297,20 +417,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 +632,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/env/gateway/browser_proxy.py b/src/agent_env/env/gateway/browser_proxy.py index 2d08ddd03..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 "") 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..6537a4a99 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,48 @@ 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 _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 + placed = env.website_browser() + browser = _THE_RUNS if placed.runs else placed.server 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 +1640,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..e528fdbc6 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 its browser computer, the one its browser_envs names or + the run's; 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, *([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 [] return [app.environment_name for app in env.composed_apps() if app.declaration.browser] 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..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 @@ -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,16 @@ 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) + base, theirs = _env_urls(env, agent) asked = {"user": agent.agent_name, **({"origin": theirs} if _takes_origin(card) else {})} - address = await self._call(context, "cdp", asked, CDP_TIMEOUT_S) + 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: @@ -99,9 +99,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 +111,34 @@ 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, 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: + 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") + 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}/") + 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: 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 9a220c6d4..5e3656cca 100644 --- a/tst/integration/computer/browser_computer_test.py +++ b/tst/integration/computer/browser_computer_test.py @@ -21,7 +21,15 @@ 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; 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.""" @@ -43,18 +51,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 +81,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 @@ -589,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: @@ -1062,3 +1083,269 @@ 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, *, 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, 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() + + +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_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_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(): + 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..b165bc025 100644 --- a/tst/unit/env/envs/test_multi_env_computers.py +++ b/tst/unit/env/envs/test_multi_env_computers.py @@ -1,6 +1,8 @@ """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 @@ -14,19 +16,24 @@ 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.multi_env import MultiEnv +from agent_env.env.envs.mcp_server import MCPServerEnv +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 -from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider +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.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 +689,352 @@ 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()]).website_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()]) + + +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): + 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().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.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, "playwright-mcp"), + (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 + 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"), + "sandbox_type": "modal", "cpu": 2, "memory_mb": 4096, + "env_params": {"site_routing": True}}] + 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"} + 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]["tool_prefix"] == "chromium_" 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 + + +@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 + 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() + 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: 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_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") + 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/env/gateway/browser_route_test.py b/tst/unit/env/gateway/browser_route_test.py index eb793a945..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: @@ -167,6 +173,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_an_address_minted_under_an_https_origin_stays_https_and_wss( + tmp_path, monkeypatch): + 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"} + 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: @@ -274,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"}), 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..0de98b99d 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -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"), @@ -2042,7 +2039,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_connect_cdp.py b/tst/unit/task_step/test_browser_connect_cdp.py index d80c3f331..d4c7cba96 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,67 @@ 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_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_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 + 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") @@ -148,14 +212,27 @@ def _env(children: list | None = None, *, url: str = _ENV, sandbox_type: str = " "children_environments": [_app("chromium")] if children is None else children}) -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 _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=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([_app("chromium", browser)], sandbox_type=sandbox_type))}) + + +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: @@ -163,6 +240,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"}) diff --git a/tst/unit/task_step/test_browser_sign_in.py b/tst/unit/task_step/test_browser_sign_in.py index da24cb79f..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"), @@ -201,5 +192,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, office.website_envs = SimpleNamespace(envs=[computer]), None, [] assert stored_browsers(office) == ["chromium"]