diff --git a/AGENTS.md b/AGENTS.md index 20fbbdb4a..bc204e8dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,7 @@ and deserialized through a registry. | Package | Contents | |---|---| -| `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset`, `close` and `load_file_artifact_universe`; teardown closes each env a run deployed before terminating its sandboxes by id. Built-ins in `env/envs/`: `MCPServerEnv` (`mcp_server`), `MultiEnv` (`multi`, MCP servers plus websites), `ServiceDBEnv` (`service_db`, PostgreSQL), `WebsiteEnv` (`website`, one site: a server image, `backend_image` in its env.toml, serving the pages and API, ready when its card's `website/v1` `ready` answers a `WebsiteReadiness` that says so, as `agentenv_protocol.website` (`website_readiness`, `env_readiness`) defines a website env's health for the deploy and `DeployedEnv.readiness()` alike, or, for a card without `ready`, when its `health` path answers 200, which is deprecated; the gateway container's `env/gateway/constants.py` mirrors the URI and op, a test keeping them equal; a separate frontend image is deprecated, warned of at put and optional in a bundle, and a stored `backend_docker_image_artifact` reads as the server image), `GatewayEnv` (`gateway_server`), `ComputerDriverEnv` (`computer_driver`, `env/envs/computer/driver_env.py`: a computer driver's files and what its class declares for each platform it supports, read at put in a process of its own, `agentenv_protocol.computer._declare`, where the driver's own dependencies needn't be installed, with the `port` it serves on and its install by OS, `[install.]`, 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, 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), `ServiceDBEnv` (`service_db`, PostgreSQL), `WebsiteEnv` (`website`, one site: a server image, `backend_image` in its env.toml, serving the pages and API, ready when its card's `website/v1` `ready` answers a `WebsiteReadiness` that says so, as `agentenv_protocol.website` (`website_readiness`, `env_readiness`) defines a website env's health for the deploy and `DeployedEnv.readiness()` alike, or, for a card without `ready`, when its `health` path answers 200, which is deprecated; the gateway container's `env/gateway/constants.py` mirrors the URI and op, a test keeping them equal; a separate frontend image is deprecated, warned of at put and optional in a bundle, and a stored `backend_docker_image_artifact` reads as the server image), `GatewayEnv` (`gateway_server`), `ComputerDriverEnv` (`computer_driver`, `env/envs/computer/driver_env.py`: a computer driver's files and what its class declares for each platform it supports, read at put in a process of its own, `agentenv_protocol.computer._declare`, where the driver's own dependencies needn't be installed, with the `port` it serves on and its install by OS, `[install.]`, or for one distribution `[install."/"]`, which replaces its OS type's there, as its `known_failures` do (`env/envs/computer/utils/install.py`): files, apt `packages` with extra `sources`, a `python` requirements file for a venv, `downloads` by URL and sha256, `run` scripts, the services the base's init runs, and `ports`; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch on a builder of the put's sandbox provider, by default the env's (`utils/builder.py`; `--sandbox` on `env build`, `bundle register` and `run`): this machine's Docker for `local`, else a VM of the provider, which fetches the put's stored files by signed URL, takes the rest inline and pushes to the image store itself, so it builds only with stores a remote VM reaches; each arch goes to the first link that runs it natively, else one that emulates it (a VM registers QEMU; `--no-emulate` leaves it `skipped` instead, for a later put), and its pair records the provider that built it (`builder`); a run or register that reuses a computer env composes what it has left (`pending`), a run only the forms its sandbox's deploys boot (`booted_forms`: on each link, the first form it boots natively, by the deploy's own rule, `boot.first_native`; a compose of a disk adds its container), and a deploy composes first what it boots that no compose started, and a container it runs natively that was built but not probed, as one whose probe a setting that didn't resolve stopped, booting it only once ready (`ComputerEnv.compose_unstarted`); tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile, build context and lock so any env with the same inputs reuses it; every layer's packages, requirements and downloads are resolved at put in a container of the base for each arch (`utils/packages.py`, `utils/resolve_in_base.py`), stored in the object store by sha256 under `agentenv-packages/`, and listed in a lock stored by a digest of its inputs, which any env with the same inputs reuses, and builds install only the lock's files, every install step `RUN --network=none`; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 (`utils/probing.py`) then boots each native build on the put's sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, its `/dev/shm` the most its apps' Linux installs ask for (`shm_mb`, in MiB, which its composed computer and the deploy record keep; `--shm-size` on a container, `AGENTENV_SHM_MB` read by a Linux disk's init, passed only to a provider whose `SHARED_MEMORY` says it gives that much, else the sandbox's default, which the deploy logs), then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`, which declares each OS with its own decorator (`@linux(...)`, or `@install(os_type, spec)` for any OS type; `@linux(distribution=...)` or `@install("/", spec)` for one distribution, which replaces its OS type's there) holding its install, its launch and, optionally, a skill and settings and build settings that add to the class's there, and install steps and ready checks for every OS or those their `os=` names; everything that reads its settings, launch or steps asks for the computer's OS type; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference, over its env.toml's `[settings]` and a computer env's `settings` beside it in `apps`, each by top-level field (`params.app_settings`; checked at put, a required one left to the deploy; the deploy record keeps the merged values as `app_settings`, secrets withheld); its env.toml's `build_settings`, values of its `BuildSettings` checked at put, are written to `.agentenv/build_settings.json` in its folder, so they're part of the image's key, its install steps read them, and a deploy can't change them). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `env/gateway/gateway.py` is the env-side gateway server, container-only code. Its site route, `/website//`, never forwards a site's data plane or card (`/agentenv/…`, `/.well-known/agent-env.json`), which agentenv reaches through `/svc//`; a site with no frontend image is served by its server image there. Its CDP route, `/browser//cdp//` (`env/gateway/browser_proxy.py`), reaches the website browser behind the `/svc` child ``, found from that child's card (it, or an app among its children, offering `browser/v1` `cdp` with `/cdp/` in its `computer-app/v1` `agent_routes`): it relays the browser's discovery and WebSocket both ways, each leg pinged every 20 s, and every CDP address the browser hands out, in `json/version`, `json/list` and its `cdp` answer through `/svc//`, comes back at the gateway, everything before `/cdp//` becoming the address the caller reached the gateway at and `/browser/`; each connection is a `browser_connection` trajectory event, without its token. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | | `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Every artifact but an environment universe, whose `metadata` key holds its legacy metadata file ids, carries a `metadata` dict outside its versioned fields, changed by compare-and-swap (`merge_metadata`). Built-ins: `CliArtifact` (`cli`), `ComputerArtifact` (`computer`, a base computer: its architectures, OS, session user, settings and container form, whose image `put` reads from its registry into `metadata.validated_images`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`: its environment's files by path, stored as the one wrapped `file_artifact_ref` every environment artifact had when it has one file at its filename, else as `file_artifact_refs`; `get_file_artifacts()` reads either, and a load hands each file to the env's `data/add` with its path, which a pre-v1 load, taking one file, refuses), `EnvironmentUniverseArtifact` (`environment_universe`), `SkillArtifact` (`skill`); `[artifacts] type_aliases` maps another stored spelling of a type onto one of them. A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull, or a published image's tag with its `image_digest` and its build context, which sandboxes pull by the digest and build from the context when they can't (the infra images, and a bundle agent built from a shipped agent's sources, `Plan.published`), or, written by `put_context`, only its build context, which a VM sandbox builds before running it (`VmSandbox.load_docker_images`) and Modal builds before creating its container (`ModalSandboxProvider.prepare_image`, outside a fallback chain's deadline); anything else that runs images by name (a container on the local provider, a lone server on any provider but Modal) refuses such an image. A build context (`utils/docker/context.py`) is what `docker build` would send, `.dockerignore` applied, written so equal files give equal bytes and an equal `source_digest`. | | `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, among them `start_computer_recording` and `end_computer_recording`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. A computer's framework-only routes (everything but MCP and the card) take the per-deploy control token its provider boots it with (`DeployedEnv.control_token`, `AGENTENV_CONTROL_TOKEN`); framework calls send `deployed.control_headers`, `Task.run` runs every step under `protocol_v1.authorized` with each deployed env's (a `verify` included), and a `run_code` script inside the computer gets `input["env"]["headers"]`. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each env still up closes (`Env.close`; a computer env ends any recording still open), what stayed open is registered from its namespace, then every sandbox is terminated by id. `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB, DynamoDB, Firestore with MongoDB compatibility), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. `store/routing.py` is the local namespaces, `@local` and `@hf` (`is_local_id`), each with a documents file of its own: while the CLI or a `build_app` server runs, a local id and what a local task's run records go to local stores beside the configured ones, and that run's writes to the configured stores are refused. A new backend must pass the conformance kits in `tst/store/`. | diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index ce561f047..5641492de 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -780,8 +780,10 @@ class MailApp(ComputerAppEnvironment): installed into the venv every app gets beside them; `sources`, extra apt repositories (`AptSource`, with a key file in the folder); `downloads`, files fetched by URL and checked by sha256 (`Download`); `run`, scripts of the folder run as root or the session user (`RunScript`); - `services`, programs the computer's init runs from the prefix as the session user; and `ports`, - those it listens on. Packages, requirements and downloads are resolved and fetched once, when the + `services`, programs the computer's init runs from the prefix as the session user; `ports`, + those it listens on; and `shm_mb`, the shared memory it needs in `/dev/shm`, in MiB, which a + computer of it gets, the most its apps ask for, where its sandbox can size it (Chrome, say, which + keeps its renderers' memory there). Packages, requirements and downloads are resolved and fetched once, when the computer env is put, and every build installs only those stored files, with the network closed. Each value is checked as the class is defined: a URL, a sha256, a path that stays in the folder. Installs are exact: apt installs each package without its recommends, and the venv gets exactly diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py index 004703ccd..f13b2f865 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py @@ -134,7 +134,9 @@ class Linux(BaseModel): """How a Linux computer installs the app: ``files``, a folder of the app's copied under its prefix; ``packages``, the OS's; ``python``, a requirements file in that folder installed into a venv beside them; ``sources``, extra apt repositories; ``downloads``, files fetched by URL; ``run``, scripts run once those are in; ``services``, programs - its init runs; ``ports``, those it listens on. Everything is fetched once at put, and builds install only that.""" + its init runs; ``ports``, those it listens on; ``shm_mb``, the shared memory it needs in ``/dev/shm``, in MiB, which + a computer of it gets where its sandbox can size it. Everything is fetched once at put, and builds install only + that.""" model_config = ConfigDict(extra="forbid") @@ -146,6 +148,7 @@ class Linux(BaseModel): run: Optional[list[RunScript]] = None services: list[Service] = Field(default_factory=list) ports: Optional[list[int]] = None + shm_mb: Optional[int] = None class Windows(BaseModel): @@ -349,13 +352,13 @@ def deco(cls: type) -> type: def linux(*, distribution: Optional[str] = None, files: str = ".", packages: Optional[Packages] = None, python: Optional[str] = None, sources: Optional[list[AptSource]] = None, downloads: Optional[list[Download]] = None, run: Optional[list[RunScript]] = None, - services: Optional[list[Service]] = None, ports: Optional[list[int]] = None, launch: Optional[Launch] = None, - paths: Optional[list[str]] = None, skill: Optional[str] = None, settings: Optional[type[BaseModel]] = None, - build_settings: Optional[type[BaseModel]] = None) -> Callable: + services: Optional[list[Service]] = None, ports: Optional[list[int]] = None, shm_mb: Optional[int] = None, + launch: Optional[Launch] = None, paths: Optional[list[str]] = None, skill: Optional[str] = None, + settings: Optional[type[BaseModel]] = None, build_settings: Optional[type[BaseModel]] = None) -> Callable: """``@install`` for Linux, or with ``distribution`` for that distribution alone: how such a computer installs the app, ``Linux``'s fields, and what is its own there.""" spec = Linux(files=files, packages=packages, python=python, sources=sources, downloads=downloads, run=run, - services=services or [], ports=ports) + services=services or [], ports=ports, shm_mb=shm_mb) return install(platform_key("linux", distribution), spec, launch=launch, paths=paths, skill=skill, settings=settings, build_settings=build_settings) diff --git a/src/agent_env/browsers/envs/chromium/chromium.py b/src/agent_env/browsers/envs/chromium/chromium.py index 33331d9c5..9b97819e8 100644 --- a/src/agent_env/browsers/envs/chromium/chromium.py +++ b/src/agent_env/browsers/envs/chromium/chromium.py @@ -23,10 +23,12 @@ "unzip"] WINDOW_CLASS = "Chromium" PROFILE = ".config/chromium" +#: The shared memory, in MiB, Chrome's renderers get in /dev/shm, so a heavy page doesn't fill Docker's 64 MiB. +SHM_MB = 1024 @computer_app(supports=[{"os": {"type": "linux", "display": ["x11", "none"]}, "arch": ["amd64", "arm64"]}]) -@linux(packages=Packages(apt=PACKAGES), downloads=DOWNLOADS, paths=[PROFILE], +@linux(packages=Packages(apt=PACKAGES), downloads=DOWNLOADS, paths=[PROFILE], shm_mb=SHM_MB, launch=Launch(at="boot", command=["chromium/chrome/chrome", "--no-sandbox", f"--class={WINDOW_CLASS}"], window={"class": WINDOW_CLASS}, ready=Ready(window=True))) class Chromium(WebsiteBrowserEnvironment): diff --git a/src/agent_env/computers/disk/linux/boot/init b/src/agent_env/computers/disk/linux/boot/init index 07d38b977..44480b86e 100755 --- a/src/agent_env/computers/disk/linux/boot/init +++ b/src/agent_env/computers/disk/linux/boot/init @@ -60,7 +60,10 @@ mount --move /sys /newroot/sys mount --move /dev /newroot/dev mkdir -p /newroot/dev/pts /newroot/dev/shm mount -t devpts -o gid=5,mode=620,ptmxmode=666 devpts /newroot/dev/pts -mount -t tmpfs -o mode=1777,size=512m shm /newroot/dev/shm +# /dev/shm holds what the computer's apps need (AGENTENV_SHM_MB, in MiB) when the deploy says, else 512 MiB. +case "$AGENTENV_SHM_MB" in ''|0|*[!0-9]*) shm_mb=512 ;; *) shm_mb=$AGENTENV_SHM_MB ;; esac +unset AGENTENV_SHM_MB +mount -t tmpfs -o mode=1777,size="${shm_mb}m" shm /newroot/dev/shm log "starting $* as $user ($uid:$gid) in $workdir" # Last, as busybox runs its applets through /proc/self/exe. mount --move /proc /newroot/proc diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index c530ba54d..a697af662 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -34,7 +34,7 @@ from agent_env.env.envs.computer import COMPUTER_ENV_TYPE from agent_env.env.envs.computer.utils import boot, composer, gate, inside, sites from agent_env.env.envs.computer.utils.composed import stale -from agent_env.env.envs.computer.utils.install import platform_of +from agent_env.env.envs.computer.utils.install import for_os, platform_of from agent_env.env.envs.computer.utils.composer import Composing from agent_env.env.envs.computer.utils import params as computer_params from agent_env.env.envs.computer.driver_env import ComputerDriverEnv @@ -190,6 +190,13 @@ def composed_apps(self) -> list[ComputerAppEnv]: """The computer app envs composed into the env's computer, each installed under its prefix.""" return self.apps + def shm_mb(self) -> int | None: + """The shared memory its computer's ``/dev/shm`` needs, in MiB: the most any of its apps' installs on its OS + declares, or None when none declares any.""" + sizes = [size for app in self.composed_apps() + if (size := getattr(for_os(app.install, self.computer.os), "shm_mb", None))] + return max(sizes, default=None) + def app_defaults(self) -> dict[str, dict[str, Any]]: """Each composed app's default settings, by name: its own env's ``[settings]`` under this env's for it, field by field.""" diff --git a/src/agent_env/env/envs/computer/utils/boot.py b/src/agent_env/env/envs/computer/utils/boot.py index 6b8f50c02..a982a6983 100644 --- a/src/agent_env/env/envs/computer/utils/boot.py +++ b/src/agent_env/env/envs/computer/utils/boot.py @@ -7,6 +7,7 @@ import asyncio import contextlib import json +import logging import secrets from collections.abc import Iterable from dataclasses import dataclass @@ -31,6 +32,8 @@ from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, Sandbox from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider +logger = logging.getLogger(__name__) + #: How long a computer may take from its container starting to its driver saying it's ready. READY_TIMEOUT_S = 180.0 #: How much longer than the slowest starting app's own readiness timeout a computer may take, for the rest of it first. @@ -52,6 +55,8 @@ class ComputerFailed(RuntimeError): #: for Windows, what Windows 11 asks of a PC. DISK_CPU, DISK_MEMORY_MB = 2.0, 4096 WINDOWS_CPU, WINDOWS_MEMORY_MB = 4.0, 8192 +#: The variable a Linux disk's init sizes its ``/dev/shm`` by, in MiB (``computers/disk/linux/boot/init``). +SHM_MB_ENV = "AGENTENV_SHM_MB" @dataclass @@ -150,16 +155,35 @@ def control_environment(token: Optional[str]) -> dict[str, str]: return {CONTROL_TOKEN_ENV: token} if token else {} +def shm_environment(form: str, os_type: str, shm_mb: Optional[int]) -> dict[str, str]: + """What sizes a Linux disk's ``/dev/shm`` to ``shm_mb`` in its boot environment, which its init reads; nothing for a + container, which its sandbox sizes, or without a size, which leaves the init's own.""" + return {SHM_MB_ENV: str(shm_mb)} if shm_mb and form == "disk" and os_type == "linux" else {} + + +def shm_kwargs(sandbox_provider: SandboxProvider, shm_mb: Optional[int]) -> dict[str, int]: + """What ``create_container`` and ``reset_container`` take for the container's ``/dev/shm`` to hold ``shm_mb`` MiB: + the size for a provider whose SHARED_MEMORY says it gives that, else nothing, logged, the container keeping its + provider's own.""" + if not shm_mb: + return {} + if sandbox_provider.SHARED_MEMORY: + return {"shm_mb": shm_mb} + logger.info("%s can't size /dev/shm: the container keeps its default, not the %d MiB asked for", + type(sandbox_provider).__name__, shm_mb) + return {} + + async def boot(sandbox_provider: SandboxProvider, *, image: str, port: int, environment: dict[str, str], token: str, network_policy: Optional[NetworkPolicy], timeout: int, attribution: Optional[Attribution], disk_size_gb: float = 10, cpu: Optional[float] = None, memory_mb: Optional[int] = None, ready_s: float = READY_TIMEOUT_S, form: str = "container", held: bool = False, - os_type: str = "linux") -> Booted: + os_type: str = "linux", shm_mb: Optional[int] = None) -> Booted: """``image``, of ``form``, booted with ``environment`` and its control ``token`` on ``sandbox_provider``, which the - caller chose to run its arch natively, its driver's ``port`` reached on the sandbox, once the driver, asked with that - token, says the computer is ready, or with ``held``, its boot apps' launch held, once it answers (``ready``); a boot - that fails is torn down.""" - environment = {**environment, **control_environment(token)} + caller chose to run its arch natively, its driver's ``port`` reached on the sandbox, its ``/dev/shm`` ``shm_mb`` MiB + where the sandbox can size it, once the driver, asked with that token, says the computer is ready, or with + ``held``, its boot apps' launch held, once it answers (``ready``); a boot that fails is torn down.""" + environment = {**environment, **control_environment(token), **shm_environment(form, os_type, shm_mb)} if form == "vm_bundle": sandbox = await sandbox_provider.create_vm( image=image, env=environment, exposed_ports=[port], network_policy=network_policy, @@ -173,7 +197,8 @@ async def boot(sandbox_provider: SandboxProvider, *, image: str, port: int, envi else: sandbox = await sandbox_provider.create_container( image_name=image, port=port, env=environment, network_policy=network_policy, disk_size_gb=disk_size_gb, - timeout=timeout, attribution=dict(attribution or {}), **_size_kwargs(cpu, memory_mb)) + timeout=timeout, attribution=dict(attribution or {}), **_size_kwargs(cpu, memory_mb), + **shm_kwargs(sandbox_provider, shm_mb)) try: url = sandbox.tunnel_urls[port] return Booted(sandbox, url, await ready(url, ready_s, token, answering=held)) diff --git a/src/agent_env/env/envs/computer/utils/composed.py b/src/agent_env/env/envs/computer/utils/composed.py index 06bc039e2..3ee5281ad 100644 --- a/src/agent_env/env/envs/computer/utils/composed.py +++ b/src/agent_env/env/envs/computer/utils/composed.py @@ -54,12 +54,14 @@ def built(self, form: str = "container") -> dict[str, str]: if result.get("status") in BUILT and not stale(result)} def computer(self, ref: dict | None) -> None: - """Record the env's computer, with the images it was written over, the disks' and the VM bundles' apart.""" + """Record the env's computer, with the images it was written over, the disks' and the VM bundles' apart, and the + shared memory its apps need.""" if ref is not None: with self.lock: - disks, bundles = self.built("disk"), self.built("vm_bundle") + disks, bundles, shm_mb = self.built("disk"), self.built("vm_bundle"), self.env.shm_mb() self.env.merge_metadata({"computer": {**ref, "images": self.built(), **({"disks": disks} if disks else {}), - **({"vm_bundles": bundles} if bundles else {})}}) + **({"vm_bundles": bundles} if bundles else {}), + **({"shm_mb": shm_mb} if shm_mb else {})}}) def _merge(self, results: dict[tuple[str, str], dict]) -> None: with self.lock: diff --git a/src/agent_env/env/envs/computer/utils/install.py b/src/agent_env/env/envs/computer/utils/install.py index b6cbb02a2..faa0b1480 100644 --- a/src/agent_env/env/envs/computer/utils/install.py +++ b/src/agent_env/env/envs/computer/utils/install.py @@ -3,8 +3,9 @@ class with ``@linux(...)``: ``files``, a folder of its own copied under the laye packages, as apt takes them; ``python``, a requirements file in that folder installed into a venv beside them; ``sources``, extra apt repositories to resolve from; ``downloads``, files fetched by URL and checked by sha256; ``run``, scripts of the folder run once those are installed; ``services``, the programs the base's init runs from the -prefix; and ``ports``, those it listens on. Everything but the files is resolved and fetched once at put, and every -build installs only what was stored then (``packages`` module). The models of packages, sources, downloads and run +prefix; and ``ports``, those it listens on. An app's Linux install also takes ``shm_mb``, the shared memory it needs in +``/dev/shm``, in MiB. Everything but the files is resolved and fetched once at put, and every build installs only what +was stored then (``packages`` module). The models of packages, sources, downloads and run scripts are the protocol's, which an app's ``@linux(...)`` uses too. ``[install.macos]`` takes the same, but apt's packages and sources; its services are LaunchAgents of the login session, and its ``grants`` the TCC permissions an app bundle or an executable it installs needs. ``[install.windows]`` takes the same as macOS but grants: its run scripts are @@ -24,6 +25,8 @@ class with ``@linux(...)``: ``files``, a folder of its own copied under the laye #: The highest port a layer can listen on. MAX_PORT = 65535 +#: The most shared memory, in MiB, an app can ask its computer's ``/dev/shm`` for. +MAX_SHM_MB = 65536 # A requirements line that includes another requirements or constraints file, or installs one editable. _INCLUDES = re.compile(r"-[rce]|--(requirement|constraint|editable)(=|$)") _FIND_LINKS = re.compile(r"-f|--find-links(=|$)") @@ -118,9 +121,18 @@ def _named_once(cls, value: list[Service]) -> list[Service]: class AppInstall(Install): - """An app's install, which may run no service: a desktop app runs when it's launched.""" + """An app's install, which may run no service: a desktop app runs when it's launched. ``shm_mb`` is the shared + memory it needs in ``/dev/shm``, a Linux computer's alone.""" services: list[Service] = Field(default_factory=list) + shm_mb: int | None = None + + @field_validator("shm_mb") + @classmethod + def _shm_mb(cls, value: int | None) -> int | None: + if value is not None and not 1 <= value <= MAX_SHM_MB: + raise ValueError(f"shm_mb is 1 to {MAX_SHM_MB} MiB, not {value}") + return value class MacInstall(Install): diff --git a/src/agent_env/env/envs/computer/utils/probing.py b/src/agent_env/env/envs/computer/utils/probing.py index 5a26c9ad1..56c931137 100644 --- a/src/agent_env/env/envs/computer/utils/probing.py +++ b/src/agent_env/env/envs/computer/utils/probing.py @@ -168,7 +168,7 @@ async def _probe(links: list[SandboxProvider], env: ComputerEnv, driver: Compute booted = await boot.boot(link, image=image, port=driver.port, environment=environment, token=token, network_policy=None, timeout=_PROBE_TTL_S, attribution=None, ready_s=boot.ready_timeout(env.composed_apps(), platform_of(env.computer.os)), - form=form, os_type=env.computer.os.type) + form=form, os_type=env.computer.os.type, shm_mb=env.shm_mb()) except Exception as e: problems.append(f"{type(link).__name__}: {e}") continue diff --git a/src/agent_env/providers/env_providers/env_computer_provider.py b/src/agent_env/providers/env_providers/env_computer_provider.py index d088f4e06..869856e4f 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -38,6 +38,8 @@ recorded_app_settings, recorded_settings, settings_environment, + shm_environment, + shm_kwargs, ) from agent_env.env.gateway.constants import WELL_KNOWN_PATH from agent_env.providers.env_providers.env_provider import DeployRefused, _SandboxEnvironmentProvider @@ -167,6 +169,7 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par app_settings = recorded_app_settings(env.composed_apps(), computer_params.app_settings(env, params.app_settings), platform_of(env.computer.os)) policy = _reaching(NetworkPolicy.from_dict(env.network) if env.network else None, sandbox_provider, reach) + shm_mb = env.shm_mb() booted = await boot( sandbox_provider, image=build["image"], port=env.driver.port, network_policy=policy, token=token, environment=_environment(env, computer, values, params, tool_prefix, sites_later=sites_later), @@ -174,7 +177,7 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par attribution=attribution, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, ready_s=ready_timeout(env.composed_apps(), platform_of(env.computer.os)), form=form, - held=bool(params.site_routing), os_type=env.computer.os.type) + held=bool(params.site_routing), os_type=env.computer.os.type, shm_mb=shm_mb) self._container_sandboxes.append(booted.sandbox) self._environment_sandboxes[name] = booted.sandbox try: @@ -190,6 +193,7 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par "computer": {"id": env.metadata["computer"]["id"], "version": env.metadata["computer"]["version"]}, "port": env.driver.port, "display": values.get("resolution"), "network": env.network, "computer_settings": recorded_settings(computer, values), + **({"shm_mb": shm_mb} if shm_mb else {}), **({"app_settings": app_settings} if app_settings else {}), **({"tool_prefix": tool_prefix} if tool_prefix else {}), "env_params": params.model_dump(exclude_defaults=True)}}, @@ -214,14 +218,16 @@ async def reboot(self, env: ComputerEnv, deployed: DeployedSandboxEnv) -> None: await end_recordings(deployed, "reset") sandbox_provider = build_sandbox_provider(deployed.sandbox_type) sandbox = await sandbox_provider.get_sandbox(deployed.sandbox_id) + shm_mb = record.get("shm_mb") environment = {**_environment(env, computer, values, params, record.get("tool_prefix", "")), - **control_environment(deployed.control_token)} + **control_environment(deployed.control_token), + **shm_environment(record.get("form", "container"), env.computer.os.type, shm_mb)} try: if record.get("form") in ("disk", "vm_bundle"): await sandbox_provider.reset_vm(sandbox, image=record["image"], env=environment) else: await sandbox_provider.reset_container(sandbox, image_name=record["image"], port=env.driver.port, - env=environment) + env=environment, **shm_kwargs(sandbox_provider, shm_mb)) except NotImplementedError as e: # a reset the env supports, which this provider can't carry out raise RuntimeError(f"'{env.id}' can't be reset on {type(sandbox_provider).__name__}: {e}") from None card = await ready(deployed.environment_url, ready_timeout(env.composed_apps(), platform_of(env.computer.os)), diff --git a/src/agent_env/providers/sandbox_providers/AGENTS.md b/src/agent_env/providers/sandbox_providers/AGENTS.md index 5c669d3d8..4b45b4fe3 100644 --- a/src/agent_env/providers/sandbox_providers/AGENTS.md +++ b/src/agent_env/providers/sandbox_providers/AGENTS.md @@ -54,8 +54,8 @@ with `exposed_ports=[port]` and returns the VM, and the deploy then loads and st | `create_sandbox(*, image_name, port, env, cpu, memory, disk_size_gb, timeout, attribution, network_policy) -> Sandbox` | yes (a VM backend wraps `create_vm`) | an agent's sandbox (`provider.create_sandbox`); `create_container` of a container-only backend | | `get_sandbox(sandbox_id) -> Sandbox` | yes | every step after the one that created the sandbox, and teardown | | `terminate_sandbox(sandbox_id)` | no | teardown. The default calls `get_sandbox` then `terminate()`, and treats `SandboxNotFoundError` as done; override when you can terminate without a live handle | -| `create_container(...)` | inherited | containers that run an env or agent image. Override only when the platform boots images natively | -| `reset_container(sandbox, *, image_name, port, env)` | inherited | a computer env's reset, which replaces the container `create_container` ran with a fresh one on the same sandbox and port. The default works on a VM backend; one whose sandbox is the container itself raises `NotImplementedError` | +| `create_container(...)` | inherited | containers that run an env or agent image. Override only when the platform boots images natively. Its `shm_mb` is the MiB the container's `/dev/shm` holds (`--shm-size` on the default VM path), passed only where `SHARED_MEMORY` is on | +| `reset_container(sandbox, *, image_name, port, env, shm_mb)` | inherited | a computer env's reset, which replaces the container `create_container` ran with a fresh one on the same sandbox and port, its `/dev/shm` as before. The default works on a VM backend; one whose sandbox is the container itself raises `NotImplementedError` | | `native_platforms()` | no | a computer env's deploy and gate 3, which boot a build only on a provider that runs its arch without emulation. The default is `linux/amd64`; local Docker returns this machine's | | `disk_image_platforms()` | no | the same for a computer's disk form: the platforms whose disks `create_vm` boots without emulation. The default is none; local Docker returns this machine's where it can pass `/dev/kvm` to a container, and `modal_vm` `linux/amd64` and `windows/amd64`, booting the launcher as its sandbox's image under nested virtualization, a Windows disk's base carried beside it | | `vm_bundle_platforms()` | no | the same for a computer's vm_bundle form, a whole saved VM (`format = "lume"`): the platforms, as `/`, whose VM bundles `create_vm` boots. The default is none; `lume` returns `macos/arm64` on an Apple Silicon Mac. A provider that names one implements the VM-bundle methods below too | @@ -87,6 +87,7 @@ sandboxes off this machine, runs containers' images by name, has no private netw | `SANDBOX_ACCEPTS` | an agent's sandbox runs different images than the default (a provider that implements `create_vm` defaults to `LOADABLE`) | | `PRIVATE_NETWORK` | `create_container(private_network=True)` puts containers on a network they share | | `WEBSOCKETS` | its sandboxes' exposed ports carry WebSockets both ways, as `tst/integration/providers/sandbox_providers/websockets_live_test.py` or its tunnel's documentation show; a computer with a website browser among its apps, whose CDP address agents reach by WebSocket, refuses a provider without it at preflight, and a chain skips it at deploy | +| `SHARED_MEMORY` | the container `create_container` or `reset_container` runs has the `/dev/shm` their `shm_mb` asks for, or more: sized to it, or larger by default, as Modal's host-sized tmpfs is. A deploy passes `shm_mb` to no other provider and logs that the container keeps its default. Derived when undeclared: on for the default VM path, off for a container-only backend and for a class that overrides either method | ## Sandbox handle contract diff --git a/src/agent_env/providers/sandbox_providers/local_docker/provider.py b/src/agent_env/providers/sandbox_providers/local_docker/provider.py index a5c5bd4fc..e9007c4a1 100644 --- a/src/agent_env/providers/sandbox_providers/local_docker/provider.py +++ b/src/agent_env/providers/sandbox_providers/local_docker/provider.py @@ -721,6 +721,7 @@ class LocalSandboxProvider(SandboxProvider): SANDBOX_ACCEPTS = Accepts.NAME_OR_CONTEXT CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT WEBSOCKETS = True + SHARED_MEMORY = True @property def _prepared(self) -> set[str]: @@ -829,11 +830,12 @@ async def create_container( timeout: int = 3600 * 2, attribution: Optional[Attribution] = None, network_policy: Optional[NetworkPolicy] = None, + shm_mb: Optional[int] = None, ) -> LocalSandbox: refuse_unenforceable_policy(self, network_policy) if network_policy is not None and network_policy.closed: sandbox = await self._create_closed(image_name=image_name, port=port, env=env, timeout=timeout, - attribution=attribution) + attribution=attribution, shm_mb=shm_mb) else: sandbox = await super().create_container( image_name=image_name, @@ -845,6 +847,7 @@ async def create_container( timeout=timeout, attribution=attribution, network_policy=network_policy, + shm_mb=shm_mb, ) # The marker is how a later get_sandbox() (post-run teardown rebuilds the sandbox from disk) # learns this is a container and removes it. If we can't persist it, that reconstructed @@ -877,7 +880,7 @@ async def create_container( return sandbox async def _create_closed(self, *, image_name: str, port: int, env: dict[str, str], timeout: int, - attribution: Optional[Attribution]) -> LocalSandbox: + attribution: Optional[Attribution], shm_mb: Optional[int] = None) -> LocalSandbox: """The container on an internal network of its own, with no route out, and a relay on that network and the default bridge publishing its ``port``: Docker publishes no port of a container on an internal network alone.""" relay = await relay_image() @@ -888,7 +891,7 @@ async def _create_closed(self, *, image_name: str, port: int, env: dict[str, str label = shlex.quote(f"{SANDBOX_LABEL}={sandbox.sandbox_id}") (sandbox.work_dir / _CLOSED_NETWORK_FILE).write_text(network) await sandbox.exec_script(f"docker network create --internal --label {label} {network} >/dev/null") - await self._run_closed(sandbox, relay, image_name=image_name, port=port, env=env) + await self._run_closed(sandbox, relay, image_name=image_name, port=port, env=env, shm_mb=shm_mb) sandbox.mode = SANDBOX_MODE_CONTAINER except BaseException: try: @@ -900,22 +903,23 @@ async def _create_closed(self, *, image_name: str, port: int, env: dict[str, str sandbox.network_policy = NetworkPolicy(mode="allowlist") return sandbox - async def reset_container(self, sandbox: Sandbox, *, image_name: str, port: int, env: dict[str, str]) -> None: + async def reset_container(self, sandbox: Sandbox, *, image_name: str, port: int, env: dict[str, str], + shm_mb: Optional[int] = None) -> None: """Reset the sandbox's container to a fresh one, and on a closed network its relay too, since the fresh container's address on that network may differ.""" if not (isinstance(sandbox, LocalSandbox) and sandbox.closed_network): - await super().reset_container(sandbox, image_name=image_name, port=port, env=env) + await super().reset_container(sandbox, image_name=image_name, port=port, env=env, shm_mb=shm_mb) return await sandbox.exec_script(f"docker rm -f {shlex.quote(sandbox.container_name)} " f"{shlex.quote(sandbox.scoped_name('relay'))} > /dev/null") - await self._run_closed(sandbox, await relay_image(), image_name=image_name, port=port, env=env) + await self._run_closed(sandbox, await relay_image(), image_name=image_name, port=port, env=env, shm_mb=shm_mb) async def _run_closed(self, sandbox: LocalSandbox, relay: str, *, image_name: str, port: int, - env: dict[str, str]) -> None: + env: dict[str, str], shm_mb: Optional[int] = None) -> None: """The container on the sandbox's closed network, then the relay publishing its ``port`` at its address there.""" network, relay_name = sandbox.closed_network, sandbox.scoped_name("relay") label = shlex.quote(f"{SANDBOX_LABEL}={sandbox.sandbox_id}") - await self._start_container(sandbox, image_name=image_name, port=port, env=env) + await self._start_container(sandbox, image_name=image_name, port=port, env=env, shm_mb=shm_mb) address = await sandbox.exec_script( f"docker inspect -f '{{{{(index .NetworkSettings.Networks \"{network}\").IPAddress}}}}' " f"{shlex.quote(sandbox.container_name)}") @@ -929,14 +933,16 @@ def _publish_args(self, sandbox: VmSandbox, port: int) -> str: network = sandbox.closed_network if isinstance(sandbox, LocalSandbox) else None return f"--network {shlex.quote(network)}" if network is not None else super()._publish_args(sandbox, port) - async def _start_container(self, sandbox: VmSandbox, *, image_name: str, port: int, env: dict[str, str]) -> None: + async def _start_container(self, sandbox: VmSandbox, *, image_name: str, port: int, env: dict[str, str], + shm_mb: Optional[int] = None) -> None: """Where the configured store hands out local grants, create the container, copy the local transfer CA's trust files in, then start it, so its TLS clients trust the grant server. Variables the caller sets win.""" trust_dir = await asyncio.to_thread(local_grant_trust) if trust_dir is None: - await super()._start_container(sandbox, image_name=image_name, port=port, env=env) + await super()._start_container(sandbox, image_name=image_name, port=port, env=env, shm_mb=shm_mb) return - args = self._container_args(sandbox, image_name=image_name, port=port, env={**LOCAL_TRUST_ENV, **env}) + args = self._container_args(sandbox, image_name=image_name, port=port, env={**LOCAL_TRUST_ENV, **env}, + shm_mb=shm_mb) await sandbox.exec_script(f"docker create {args} > /dev/null") await start_trusting(sandbox, sandbox.container_name, trust_dir) diff --git a/src/agent_env/providers/sandbox_providers/modal_sandbox.py b/src/agent_env/providers/sandbox_providers/modal_sandbox.py index 35fe40c92..ac52f2d32 100644 --- a/src/agent_env/providers/sandbox_providers/modal_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_sandbox.py @@ -233,6 +233,9 @@ class ModalSandboxProvider(SandboxProvider): CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT PRIVATE_NETWORK = True WEBSOCKETS = True + # A sandbox's /dev/shm is a tmpfs as large as its host's memory, more than an app can ask for, which create_container + # can't size: Modal's SDK has no option for it. + SHARED_MEMORY = True @classmethod def supports_network_policy(cls, policy: NetworkPolicy) -> bool: @@ -326,6 +329,7 @@ async def create_container( expose_externally: bool = True, vnc_port: Optional[int] = None, private_network: bool = False, + shm_mb: Optional[int] = None, ) -> Sandbox: # The private network is i6pn, Modal's network between sandboxes, in the configured region. i6pn = private_network diff --git a/src/agent_env/providers/sandbox_providers/sail_vm/provider.py b/src/agent_env/providers/sandbox_providers/sail_vm/provider.py index 88b6be959..7bf4acc66 100644 --- a/src/agent_env/providers/sandbox_providers/sail_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/sail_vm/provider.py @@ -134,6 +134,8 @@ class SailVmSandboxProvider(SandboxProvider): EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.sail.box",) WEBSOCKETS = True + # Its create_container is the default VM path's, which sizes /dev/shm. + SHARED_MEMORY = True def __init__( self, diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 7ca34a629..1cc76fcca 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -110,12 +110,14 @@ async def create_container( attribution: Optional[Attribution] = None, network_policy: Optional[NetworkPolicy] = None, private_network: bool = False, + shm_mb: Optional[int] = None, ) -> Sandbox: """Provision a sandbox with the registry image already running as a container. On return, ``tunnel_urls[port]`` is populated and the container listens on ``port``. Caller still owns readiness polling for the container's own endpoints. ``private_network`` puts it on a - network this provider's other such containers share, at its ``private_host``. + network this provider's other such containers share, at its ``private_host``. ``shm_mb`` is the MiB the + container's ``/dev/shm`` holds, which a deploy passes only to a provider whose SHARED_MEMORY says it gives them. Default implementation: a container-only backend uses create_sandbox; a VM backend creates a VM, pulls the image, and starts it. Backends that bake the image in at creation override. @@ -138,7 +140,7 @@ async def create_container( ) try: await sandbox.pull_images([image_name]) - await self._start_container(sandbox, image_name=image_name, port=port, env=env) + await self._start_container(sandbox, image_name=image_name, port=port, env=env, shm_mb=shm_mb) sandbox.mode = SANDBOX_MODE_CONTAINER return sandbox except BaseException: @@ -148,15 +150,16 @@ async def create_container( pass raise - async def reset_container(self, sandbox: Sandbox, *, image_name: str, port: int, env: dict[str, str]) -> None: - """Reset the container create_container ran on ``sandbox`` to a fresh one of ``image_name`` with ``env``, reached - on ``port`` as before: the sandbox, its id and its URLs stay. A backend whose sandbox is the container itself - can't reset it in place.""" + async def reset_container(self, sandbox: Sandbox, *, image_name: str, port: int, env: dict[str, str], + shm_mb: Optional[int] = None) -> None: + """Reset the container create_container ran on ``sandbox`` to a fresh one of ``image_name`` with ``env`` and + ``shm_mb``, reached on ``port`` as before: the sandbox, its id and its URLs stay. A backend whose sandbox is the + container itself can't reset it in place.""" if not isinstance(sandbox, VmSandbox): raise NotImplementedError(f"{type(self).__name__}'s sandbox is the container itself, so it can't be reset " "in place") await sandbox.exec_script(f"docker rm -f {shlex.quote(sandbox.container_name)} > /dev/null") - await self._start_container(sandbox, image_name=image_name, port=port, env=env) + await self._start_container(sandbox, image_name=image_name, port=port, env=env, shm_mb=shm_mb) async def reset_vm(self, sandbox: Sandbox, *, image: str, env: dict[str, str]) -> None: """Boot the disk create_vm booted on ``sandbox`` afresh, from ``image`` with ``env``, reached on its ports as @@ -168,18 +171,22 @@ async def prepare_image(self, image: DockerImageArtifact, *, attribution: Option any deadline on creating one, for the deploy ``attribution`` describes, as it describes the sandbox. Does nothing here; a provider that builds a context-only image itself builds it.""" - async def _start_container(self, sandbox: VmSandbox, *, image_name: str, port: int, env: dict[str, str]) -> None: - """Run the pulled ``image_name`` as the sandbox's container, publishing ``port``.""" - args = self._container_args(sandbox, image_name=image_name, port=port, env=env) + async def _start_container(self, sandbox: VmSandbox, *, image_name: str, port: int, env: dict[str, str], + shm_mb: Optional[int] = None) -> None: + """Run the pulled ``image_name`` as the sandbox's container, publishing ``port``, with ``shm_mb`` MiB of + ``/dev/shm``.""" + args = self._container_args(sandbox, image_name=image_name, port=port, env=env, shm_mb=shm_mb) await sandbox.exec_script(f"docker run -d {args} > /dev/null") - def _container_args(self, sandbox: VmSandbox, *, image_name: str, port: int, env: dict[str, str]) -> str: + def _container_args(self, sandbox: VmSandbox, *, image_name: str, port: int, env: dict[str, str], + shm_mb: Optional[int] = None) -> str: """The ``docker run`` / ``docker create`` arguments for the sandbox's container.""" env_flags = " \\\n ".join(f"-e {shlex.quote(k)}={shlex.quote(v)}" for k, v in env.items()) extra_args = f"{self.EXTRA_CONTAINER_RUN_ARGS} " if self.EXTRA_CONTAINER_RUN_ARGS else "" + shm_args = f"--shm-size {int(shm_mb)}m " if shm_mb else "" return ( - f"--name {shlex.quote(sandbox.container_name)} {self._publish_args(sandbox, port)} {extra_args}\\\n " - f"{env_flags} \\\n {shlex.quote(image_name)}" + f"--name {shlex.quote(sandbox.container_name)} {self._publish_args(sandbox, port)} {extra_args}{shm_args}" + f"\\\n {env_flags} \\\n {shlex.quote(image_name)}" ) def _publish_args(self, sandbox: VmSandbox, port: int) -> str: @@ -240,6 +247,11 @@ def from_config(cls, **config: Any) -> Self: # Whether its sandboxes' exposed ports carry WebSockets both ways, as a website browser's CDP address needs: on # where our own tests or its tunnel's documentation say so. WEBSOCKETS: ClassVar[bool] = False + # Whether the container create_container or reset_container runs has the /dev/shm their shm_mb asks for, or more: + # sized to it, as the default VM path's docker run does, or larger by default. A deploy passes shm_mb to no other + # provider. Derived for a provider class that doesn't declare it (``__init_subclass__``): on for the default VM + # path, off for a container-only backend and for a class that overrides either method. + SHARED_MEMORY: ClassVar[bool] = False def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) @@ -251,6 +263,11 @@ def __init_subclass__(cls, **kwargs: Any) -> None: elif not cls._DECLARES_SANDBOX_ACCEPTS: cls.SANDBOX_ACCEPTS = (Accepts.LOADABLE if SANDBOX_MODE_VM in cls.SUPPORTED_SANDBOX_MODES else cls.CONTAINER_ACCEPTS) + if "SHARED_MEMORY" not in cls.__dict__: + if {"create_container", "reset_container"} & cls.__dict__.keys(): + cls.SHARED_MEMORY = False + elif cls.create_container is SandboxProvider.create_container: + cls.SHARED_MEMORY = SANDBOX_MODE_VM in cls.SUPPORTED_SANDBOX_MODES @property def links(self) -> tuple[SandboxProvider, ...]: diff --git a/src/agent_env/providers/sandbox_providers/smol_vm/provider.py b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py index 0cad68694..112459ae3 100644 --- a/src/agent_env/providers/sandbox_providers/smol_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py @@ -10,7 +10,7 @@ import socket import uuid from urllib.parse import urlsplit -from typing import ClassVar +from typing import ClassVar, Optional try: from smol import ConnectOptions, Machine, MachineConfig, PortSpec, ResourceSpec @@ -208,15 +208,18 @@ async def _configure_host_access(self, sandbox: SmolVmSandbox) -> None: ) sandbox.extra_hosts = (f"host.docker.internal:{gateway}",) - def _container_args(self, sandbox: SmolVmSandbox, *, image_name: str, port: int, env: dict[str, str]) -> str: - args = super()._container_args(sandbox, image_name=image_name, port=port, env=env) + def _container_args(self, sandbox: SmolVmSandbox, *, image_name: str, port: int, env: dict[str, str], + shm_mb: Optional[int] = None) -> str: + args = super()._container_args(sandbox, image_name=image_name, port=port, env=env, shm_mb=shm_mb) return " ".join(f"--add-host {shlex.quote(entry)}" for entry in sandbox.extra_hosts) + " " + args - async def _start_container(self, sandbox: SmolVmSandbox, *, image_name: str, port: int, env: dict[str, str]) -> None: + async def _start_container(self, sandbox: SmolVmSandbox, *, image_name: str, port: int, env: dict[str, str], + shm_mb: Optional[int] = None) -> None: trust_dir = await asyncio.to_thread(local_grant_trust) if trust_dir is None: - await super()._start_container(sandbox, image_name=image_name, port=port, env=env) + await super()._start_container(sandbox, image_name=image_name, port=port, env=env, shm_mb=shm_mb) return - args = self._container_args(sandbox, image_name=image_name, port=port, env={**LOCAL_TRUST_ENV, **env}) + args = self._container_args(sandbox, image_name=image_name, port=port, env={**LOCAL_TRUST_ENV, **env}, + shm_mb=shm_mb) await sandbox.exec_script(f"docker create {args} > /dev/null") await start_trusting(sandbox, sandbox.container_name, trust_dir) diff --git a/tst/integration/computer/chromium_app_test.py b/tst/integration/computer/chromium_app_test.py index 5e52d5b17..01695e50f 100644 --- a/tst/integration/computer/chromium_app_test.py +++ b/tst/integration/computer/chromium_app_test.py @@ -2,9 +2,10 @@ machine's arch ready with it; deployed on two start_urls, it shows one Chromium window, on the first, with no first-run, sign-in or permission window beside it, and the second a tab away, which an agent reaches with Control+Tab; its managed policies are where Chrome for Testing reads them; and a reset of its data clears its profile and opens it again. As a -website browser it runs Chrome itself with agentenv's flags on its debugging pipe, keeps its shared memory in files on -Docker's 64 MiB /dev/shm, answers location prompts and leaves notification ones to ask as the deploy says, and its menu -launcher, run while it runs, opens another window of it with no second copy, its cache kept and it still ready; and the +website browser it runs Chrome itself with agentenv's flags on its debugging pipe, its computer's /dev/shm the 1 GiB it +asks for, as a container or a disk, so Chrome keeps its shared memory there, a reset_env too, answers location prompts +and leaves notification ones to ask as the deploy says, and its menu launcher, run while it runs, opens another window +of it with no second copy, its cache kept and it still ready; and the desktop that gives it French defaults deploys with them, a deploy's time zone over them, each recorded; and the record its control connection keeps lists a tab an agent opened and closed, a download with its size and sha256, a confirm() accepted for it, and each launch, exit and stop with its cause, across a kill and a restart of its server; and browser/v1 reads and sets its @@ -29,16 +30,18 @@ from agent_env.env import Env from agentenv_protocol.types import WELL_KNOWN_PATH -from agentenv_protocol.website_browser import BROWSER +from agentenv_protocol.website_browser import BROWSER, SHM_MIN_BYTES from tst.integration.computer.browser_data import SITE as DATA_SITE, check_browser_data from tst.integration.computer.browser_steps import check_browser_steps from tst.integration.computer.browser_state import check_browser_state from tst.integration.computer.start_tabs import host_pages, tabs_of from tst.integration.computer.website_browser_test import _app, _browser_process, _keys, _ready +from tst.util.capabilities import skip_without_kvm from agent_env.env.envs.computer.utils import inside from agent_env.store.routing import namespace_routing from agent_env.task.teardown import teardown_run from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.reset_env import ResetEnvTaskStep pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -48,6 +51,8 @@ ALPHA = "data:text/html,Alpha page

Alpha

" BRAVO = "data:text/html,Bravo page

Bravo

" LOOPBACK = "http://127.0.0.1:8766/" +#: The size of the computer's /dev/shm, in bytes, as the browser base measures it. +SHM_BYTES = "import os; s = os.statvfs('/dev/shm'); print(s.f_frsize * s.f_blocks)" # What chromium's menu launcher runs. LAUNCHER = ("/opt/agentenv/apps/chromium/venv/bin/python -m agentenv_protocol.computer.launch " "/opt/agentenv/apps/chromium") @@ -186,8 +191,8 @@ async def test_it_runs_chrome_itself_on_its_pipe_answers_prompts_as_the_deploy_s argv = browser["argv"] assert argv[0].endswith("chromium/chrome/chrome") and argv[1:3] == ["--no-sandbox", "--class=Chromium"] assert argv[3].endswith("/.config/chromium") and argv[4].endswith("/.cache/chromium") - assert argv[5:10] == ["--remote-debugging-pipe", "--no-first-run", "--no-default-browser-check", "--lang=en-US", - "--disable-dev-shm-usage"] and argv[10:] == ["about:blank"] + assert argv[5:9] == ["--remote-debugging-pipe", "--no-first-run", "--no-default-browser-check", "--lang=en-US"] + assert argv[9:] == ["about:blank"] assert browser["listening"] == 0 assert permissions == ["denied prompt - Google Chrome for Testing"] assert launched["exit_code"] == 0 and len(windows) == 2 and after["pid"] == browser["pid"] @@ -197,6 +202,31 @@ async def test_it_runs_chrome_itself_on_its_pipe_answers_prompts_as_the_deploy_s assert not report.failed + +@pytest.mark.asyncio +@pytest.mark.parametrize("form", ["container", pytest.param("disk", marks=skip_without_kvm())]) +async def test_its_computer_gets_the_shared_memory_it_asks_for_so_chrome_keeps_it_there_and_a_reset_env_keeps_it( + chromium_desktop, form): + with namespace_routing(): + deployed = await Env.get(chromium_desktop.id).deploy(env_params={"form": form}) + context = TaskStepContext(deployed_envs=[deployed]) + sizes, in_files = [], [] + try: + for reset in (False, True): + if reset: + context = await ResetEnvTaskStep(id="reset", version=None, env_id=chromium_desktop.id, + fail_task_on_error=True).execute(context) + sizes.append(int((await inside.run(deployed, ["python3", "-c", SHM_BYTES], timeout_s=30))["stdout"])) + in_files.append("--disable-dev-shm-usage" in (await _browser_process(deployed))["argv"]) + finally: + report = await teardown_run(context) + + recorded = deployed.metadata["computer"] + assert (recorded["form"], recorded["shm_mb"]) == (form, SHM_MIN_BYTES >> 20) + assert sizes == [SHM_MIN_BYTES, SHM_MIN_BYTES] and in_files == [False, False] + assert not report.failed + + @pytest.mark.asyncio async def test_a_desktop_that_gives_it_defaults_deploys_with_them_and_a_deploys_own_over_them(chromium_desktop): with namespace_routing(): diff --git a/tst/unit/env/envs/test_chromium_app.py b/tst/unit/env/envs/test_chromium_app.py index a2b052b1e..255710fcd 100644 --- a/tst/unit/env/envs/test_chromium_app.py +++ b/tst/unit/env/envs/test_chromium_app.py @@ -1,6 +1,7 @@ """agentenv's chromium app as a put reads it: the installed ``browsers`` bundle names it ``chromium``; its class, a website browser, declares the pinned Chrome for Testing archive for each arch and its -packages, its profile as its data, a launch of Chrome itself matched by its window class, the base's settings, and its -install step, which unpacks the archive keeping its execute bits, beside the base's, which writes the policies.""" +packages, its profile as its data, 1 GiB of shared memory, a launch of Chrome itself matched by its window +class, the base's settings, and its install step, which unpacks the archive keeping its execute bits, beside +the base's, which writes the policies.""" import os import sys import zipfile @@ -42,7 +43,7 @@ def test_chromium_is_a_website_browser_that_declares_its_archives_profile_launch ("arm64", "chromium/chrome-arm64.zip")] assert all(f"/chrome-for-testing-public/{chromium.VERSION}/" in d["url"] for d in linux.install["downloads"]) assert {"libnss3", "libasound2t64", "unzip"} <= set(linux.install["packages"]["apt"]) - assert linux.paths == [".config/chromium"] + assert linux.paths == [".config/chromium"] and linux.install["shm_mb"] == chromium.SHM_MB == 1024 assert linux.launch.command == ["chromium/chrome/chrome", "--no-sandbox", "--class=Chromium"] assert linux.launch.at == "boot" and linux.launch.window == {"class": "Chromium"} and linux.launch.ready.window assert {(step.name, step.scope) for step in declared.install_steps} == {("chromium", "system"), diff --git a/tst/unit/env/envs/test_computer_apps.py b/tst/unit/env/envs/test_computer_apps.py index 247573789..5278b072a 100644 --- a/tst/unit/env/envs/test_computer_apps.py +++ b/tst/unit/env/envs/test_computer_apps.py @@ -4,10 +4,12 @@ (``clashes``). Docker, the registry and the VMs are faked.""" import pytest +from pydantic import ValidationError from agent_env.env import Env from agent_env.env.envs.computer.utils import clashes from agent_env.env.envs.computer.utils.composer import Composing +from agent_env.env.envs.computer.utils.install import MacAppInstall, WindowsAppInstall from agent_env.store.base import NotFoundError from tst.unit.env.envs._computer import MAIL_SERVER, FakeVmProvider, _app, _put @@ -201,3 +203,33 @@ def test_a_builder_vm_compares_what_each_part_wrote_in_a_container_of_the_image_ assert not list((tmp_path / "vms").rglob("image.tar")) + + +def _install(**changes): + """The mail app's Linux install, with ``changes``.""" + return {"python": "requirements.txt", "services": [{"name": "imap", "command": "venv/bin/python imap.py"}], + **changes} + + +def test_a_computer_takes_the_most_shared_memory_its_apps_ask_for_and_keeps_it_with_its_computer(base, driver, + tmp_path): + asking = [_app(base, driver, tmp_path / name, compose=False, name=name, install=_install(**size)) + for name, size in (("mail", {"shm_mb": 512}), ("chat", {"shm_mb": 1024}), ("notes", {}))] + none = [_app(base, driver, tmp_path / "plain", compose=False, name="plain")] + + env, plain = _put(base, driver, apps=asking), _put(base, driver, apps=none, id="@local/~/kit/plain-desktop") + + assert env.shm_mb() == 1024 and Env.get(env.id).metadata["computer"]["shm_mb"] == 1024 + assert plain.shm_mb() is None and "shm_mb" not in Env.get(plain.id).metadata["computer"] + + +@pytest.mark.parametrize("shm_mb", [0, 65537]) +def test_a_shared_memory_size_out_of_range_is_refused_at_put(base, driver, tmp_path, shm_mb): + with pytest.raises(ValueError, match=f"shm_mb is 1 to 65536 MiB, not {shm_mb}"): + _app(base, driver, tmp_path / "mail", compose=False, install=_install(shm_mb=shm_mb)) + + +@pytest.mark.parametrize("model", [MacAppInstall, WindowsAppInstall]) +def test_shared_memory_is_a_linux_installs_alone(model): + with pytest.raises(ValidationError, match="shm_mb"): + model.model_validate({"shm_mb": 1024}) diff --git a/tst/unit/env/envs/test_computer_disk.py b/tst/unit/env/envs/test_computer_disk.py index 2dd36c3b6..ec3fc8936 100644 --- a/tst/unit/env/envs/test_computer_disk.py +++ b/tst/unit/env/envs/test_computer_disk.py @@ -21,6 +21,7 @@ from agent_env.artifact.store import get_artifact_store from agent_env.env import Env from agent_env.config.runtime import Config +from agent_env.env.envs.computer.env import ComputerEnv from agent_env.env.envs.computer.utils import boot, composer, probing from agent_env.env.envs.computer.utils.composer import Composing from agent_env.env.published_images import DISK @@ -567,6 +568,27 @@ async def test_a_reset_of_a_disk_boots_it_afresh_in_its_vm_with_the_records_sett assert kwargs["env"]["AGENTENV_SETTING_RESOLUTION"] == "1600x900" + +@pytest.mark.asyncio +@pytest.mark.parametrize("asks", [1024, None]) +async def test_a_disks_init_is_told_the_shared_memory_its_apps_ask_for_at_boot_and_at_a_reset(disk_base, driver, docker, + vms, monkeypatch, asks): + monkeypatch.setattr(ComputerEnv, "shm_mb", lambda self: asks) + env = _put(disk_base, driver, display={"width": 1600, "height": 900}) + deployed = await Env.get(env.id).deploy(env_params={"form": "disk"}) + monkeypatch.setattr(local_sandbox.LocalSandboxProvider, "get_sandbox", AsyncMock( + return_value=local_sandbox.LocalSandbox(port_map={18765: 20001}, sandbox_id="local-vm1"))) + monkeypatch.setattr(env_computer_provider, "end_recordings", AsyncMock()) + + await ResetEnvTaskStep(id="reset", version=None, env_id=env.id, fail_task_on_error=True).execute( + TaskStepContext(deployed_envs=[deployed])) + + ((created,), ((_, reset),)) = vms.created, vms.reset + told = {boot.SHM_MB_ENV: str(asks)} if asks else {} + assert {key: created["env"][key] for key in created["env"] if key == boot.SHM_MB_ENV} == told + assert {key: reset["env"][key] for key in reset["env"] if key == boot.SHM_MB_ENV} == told + assert "shm_mb" not in created and deployed.metadata["computer"].get("shm_mb") == asks + def test_a_computer_artifact_takes_a_disk_form_and_reads_its_image(base, docker): registry = docker.registry good = registry.index(amd64=registry.image(disk_layer()), arm64=registry.image(disk_layer(), arch="arm64")) diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 2e1baafd5..7b74f0872 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -56,6 +56,7 @@ from agent_env import providers from agent_env.providers.env_providers import env_computer_provider from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider +from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider from agent_env.providers.env_providers.env_provider import DeployRefused from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep from agent_env.task_step.task_steps.reset_env import ResetEnvTaskStep @@ -266,6 +267,58 @@ async def fake_boot(link, **kwargs): "theme": "dark"}} + +@pytest.mark.parametrize("asks", [1024, None]) +def test_the_probe_boots_a_build_with_the_shared_memory_its_apps_ask_for_as_a_deploy_does(base, driver, docker, + tmp_path, monkeypatch, asks): + monkeypatch.setenv("MAIL_TOKEN", "t0ken") + install = {"python": "requirements.txt", "services": [{"name": "imap", "command": "venv/bin/python imap.py"}], + **({"shm_mb": asks} if asks else {})} + env = _put(base, driver, compose=False, apps=[_app(base, driver, tmp_path / "mail", compose=False, install=install, + defaults={"token": "env:MAIL_TOKEN"})]) + booted = [] + + async def fake_boot(link, **kwargs): + booted.append(kwargs) + return boot.Booted(sandbox=None, url="http://computer", card=CARD) + + monkeypatch.setattr(boot, "boot", fake_boot) + monkeypatch.setattr(boot, "discard", AsyncMock()) + monkeypatch.setattr(probing, "run_probe", AsyncMock(return_value="report")) + + asyncio.run(_probe([SimpleNamespace()], env, driver, "img")) + + assert booted[0]["shm_mb"] == asks + + +@pytest.mark.asyncio +async def test_a_boot_asks_for_shared_memory_only_where_its_provider_gives_it_and_logs_where_it_doesnt(monkeypatch, + caplog): + created, sandbox = [], SimpleNamespace(tunnel_urls={18765: "http://computer"}) + + class Gives(SandboxProvider): + SHARED_MEMORY = True + + async def create_container(self, **kwargs): + created.append(kwargs) + return sandbox + + class OldPlugin(SandboxProvider): + async def create_container(self, *, image_name, port, env, network_policy=None, disk_size_gb=10, timeout=7200, + attribution=None): + created.append({"image_name": image_name}) + return sandbox + + monkeypatch.setattr(boot, "ready", AsyncMock(return_value=CARD)) + with caplog.at_level(logging.INFO, logger="agent_env.env.envs.computer.utils.boot"): + for provider in (Gives(), OldPlugin()): + await boot.boot(provider, image="img", port=18765, environment={}, token="t", network_policy=None, + timeout=60, attribution=None, shm_mb=1024) + + assert created[0]["shm_mb"] == 1024 and "shm_mb" not in created[1] + assert "OldPlugin can't size /dev/shm: the container keeps its default, not the 1024 MiB asked for" in caplog.text + assert "Gives can't size" not in caplog.text + @pytest.mark.parametrize("left", [{"status": "building"}, {"status": "unverified", "image": "built", "key": "k"}], ids=["interrupted", "unprobed"]) def test_the_pairs_left_to_compose_are_those_failed_interrupted_or_unprobed_on_a_native_arch(base, driver, docker, @@ -876,6 +929,35 @@ async def reset_container(self, sandbox, **kwargs): assert boots.calls == [(COMPUTER_RECORDING, "end_all", {"reason": "reset"}), "reset_container", (COMPUTER_DISPLAY, "get", None)] +@pytest.mark.asyncio +@pytest.mark.parametrize("asks", [1024, None]) +async def test_a_computer_boots_with_the_shared_memory_its_apps_ask_for_records_it_and_a_reset_keeps_it( + base, driver, docker, boots, monkeypatch, tmp_path, asks): + monkeypatch.setenv("MAIL_TOKEN", "t0ken") + boots.display = (1280, 800) + install = {"python": "requirements.txt", "services": [{"name": "imap", "command": "venv/bin/python imap.py"}], + **({"shm_mb": asks} if asks else {})} + env = _put(base, driver, apps=[_app(base, driver, tmp_path / "mail", compose=False, install=install, + defaults={"token": "env:MAIL_TOKEN"})]) + deployed = await Env.get(env.id).deploy() + (sandbox,) = boots.sandboxes + resets = [] + + async def reset_container(self, sandbox, **kwargs): + resets.append(kwargs) + + monkeypatch.setattr(local_sandbox.LocalSandboxProvider, "get_sandbox", AsyncMock(return_value=sandbox)) + monkeypatch.setattr(local_sandbox.LocalSandboxProvider, "reset_container", reset_container) + monkeypatch.setattr(env_computer_provider, "ready", AsyncMock(return_value=DRIVER_CARD)) + + await ResetEnvTaskStep(id="reset", version=None, env_id=env.id, fail_task_on_error=True).execute( + TaskStepContext(deployed_envs=[deployed])) + + ((booted,), (reset,)) = boots.boots, resets + assert booted["shm_mb"] == asks and deployed.metadata["computer"].get("shm_mb") == asks + assert reset.get("shm_mb") == asks + + @pytest.mark.asyncio @pytest.mark.parametrize("tools, changes", [ ("standard", [(DISABLE_TOOL_EXTENSION_URI, "disable", {"role": "default", "tools": ["*"]}), diff --git a/tst/unit/providers/sandbox_providers/local_sandbox_test.py b/tst/unit/providers/sandbox_providers/local_sandbox_test.py index 0e6453c11..aeac86461 100644 --- a/tst/unit/providers/sandbox_providers/local_sandbox_test.py +++ b/tst/unit/providers/sandbox_providers/local_sandbox_test.py @@ -654,8 +654,9 @@ async def _rec(script, *, max_retries=0): @pytest.mark.asyncio +@pytest.mark.parametrize("shm_mb", [None, 1024]) async def test_a_container_with_no_network_runs_on_an_internal_network_that_its_relay_publishes_and_is_replaced_with_it( - monkeypatch, tmp_path): + monkeypatch, tmp_path, shm_mb): import agent_env.config as cfg monkeypatch.setattr(cfg, "get_config", lambda: _StubConfig()) @@ -678,7 +679,7 @@ async def _record(script, *, max_retries=0): return sandbox sandbox = await _RecordingProvider().create_container(image_name="img:v1", port=8000, env={}, - network_policy=NetworkPolicy(mode="allowlist")) + network_policy=NetworkPolicy(mode="allowlist"), shm_mb=shm_mb) network, label = f"closed-{sandbox.sandbox_id}", f"agentenv.sandbox={sandbox.sandbox_id}" steps = [s.split()[1] for s in sandbox.scripts] @@ -686,6 +687,7 @@ async def _record(script, *, max_retries=0): assert steps == ["pull", "network", "create", "start", "inspect", "run"] assert sandbox.scripts[1] == f"docker network create --internal --label {label} {network} >/dev/null" assert f"--network {network} " in create and "-p " not in create + assert ("--shm-size 1024m " in create) == bool(shm_mb) assert sandbox.scripts[-1] == (f"docker run -d --name relay-{sandbox.sandbox_id} --label {label} " "-p 127.0.0.1:41337:8000 agentenv-relay:test 8000=172.30.0.2:8000 >/dev/null && " f"docker network connect {network} relay-{sandbox.sandbox_id}") @@ -693,11 +695,12 @@ async def _record(script, *, max_retries=0): assert sandbox.network_policy.closed and sandbox.tunnel_urls[8000] == "http://127.0.0.1:41337" sandbox.scripts.clear() - await _RecordingProvider().reset_container(sandbox, image_name="img:v1", port=8000, env={"A": "1"}) + await _RecordingProvider().reset_container(sandbox, image_name="img:v1", port=8000, env={"A": "1"}, shm_mb=shm_mb) assert [s.split()[1] for s in sandbox.scripts] == ["rm", "create", "start", "inspect", "run"] assert sandbox.scripts[0] == f"docker rm -f agent-{sandbox.sandbox_id} relay-{sandbox.sandbox_id} > /dev/null" assert f"--network {network} " in sandbox.scripts[1] and "-e A=1 " in sandbox.scripts[1] + assert ("--shm-size 1024m " in sandbox.scripts[1]) == bool(shm_mb) assert "-p 127.0.0.1:41337:8000 " in sandbox.scripts[-1] diff --git a/tst/unit/providers/sandbox_providers/modal_sandbox_test.py b/tst/unit/providers/sandbox_providers/modal_sandbox_test.py index f891bcbb2..5c745ec58 100644 --- a/tst/unit/providers/sandbox_providers/modal_sandbox_test.py +++ b/tst/unit/providers/sandbox_providers/modal_sandbox_test.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging from unittest.mock import AsyncMock, MagicMock, patch import modal @@ -51,6 +52,25 @@ def _local_image_store(): yield +@pytest.mark.asyncio +async def test_a_container_asking_for_shm_is_created_as_any_other_its_host_sized_shm_holding_it(_patched_provider, + caplog): + fake_sb = MagicMock() + fake_sb.object_id = "sb-FAKEFAKEFAKEFAKEFAKE" + fake_sb.wait_until_ready.aio = AsyncMock(side_effect=asyncio.CancelledError) + fake_sb.terminate.aio = AsyncMock() + + with patch("agent_env.providers.sandbox_providers.modal_sandbox.modal.Sandbox.create") as mock_create, \ + caplog.at_level(logging.INFO, logger="agent_env"): + mock_create.aio = AsyncMock(return_value=fake_sb) + with pytest.raises(asyncio.CancelledError): + await _patched_provider.create_container(image_name="registry.example/mcp-server-example:v1", port=18765, + env={}, shm_mb=1024) + + mock_create.aio.assert_awaited_once() + assert ModalSandboxProvider.SHARED_MEMORY and "/dev/shm" not in caplog.text + + @pytest.mark.asyncio async def test_create_container_wraps_modal_create_failure_with_context(_patched_provider): with patch("agent_env.providers.sandbox_providers.modal_sandbox.modal.Sandbox._experimental_create") as mock_create: diff --git a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py index fe9b8489c..a7002acce 100644 --- a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py +++ b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py @@ -135,6 +135,69 @@ async def create_sandbox(self, **kwargs): await provider.reset_container(_FakeSandbox(), image_name="nginx:latest", port=8080, env={}) +@pytest.mark.asyncio +async def test_a_vms_container_gets_the_shm_asked_for_at_create_and_reset(): + class _FakeVm(VmSandbox): + type = "fake-vm" + + def __init__(self): + self.sandbox_id, self.tunnel_urls, self.vnc_url, self.mode = "vm-fake", {}, None, "vm" + self.scripts: list[str] = [] + + async def terminate(self) -> None: + pass + + async def exec_script(self, script: str) -> str: + self.scripts.append(script) + return "" + + fake_vm = _FakeVm() + + class _VmStyleProvider(SandboxProvider): + async def create_vm(self, **kwargs): + return fake_vm + + async def create_sandbox(self, **kwargs): + raise NotImplementedError + + vm_provider = _VmStyleProvider() + await vm_provider.create_container(image_name="img:v1", port=8080, env={}, shm_mb=1024) + sized = fake_vm.scripts[-1] + await vm_provider.reset_container(fake_vm, image_name="img:v1", port=8080, env={}, shm_mb=1024) + await vm_provider.reset_container(fake_vm, image_name="img:v1", port=8080, env={}) + + assert sized.startswith("docker run -d ") and " --shm-size 1024m " in sized + assert " --shm-size 1024m " in fake_vm.scripts[-3] and "--shm-size" not in fake_vm.scripts[-1] + + +def test_shared_memory_is_on_for_the_default_vm_path_and_off_where_a_class_overrides_create_or_reset_unasked(): + class Vm(SandboxProvider): + async def create_vm(self, **kwargs): + raise NotImplementedError + + class ContainerOnly(SandboxProvider): + async def create_sandbox(self, **kwargs): + raise NotImplementedError + + class OldPlugin(Vm): + async def create_container(self, *, image_name, port, env): + raise NotImplementedError + + class Declared(Vm): + SHARED_MEMORY = True + + async def reset_container(self, sandbox, *, image_name, port, env, shm_mb=None): + raise NotImplementedError + + class Inherits(Declared): + pass + + assert [cls.SHARED_MEMORY for cls in (Vm, ContainerOnly, OldPlugin, Declared, Inherits)] == [ + True, False, False, True, True] + assert LocalSandboxProvider.SHARED_MEMORY and ModalVmSandboxProvider.SHARED_MEMORY + assert ModalSandboxProvider.SHARED_MEMORY + + @pytest.mark.asyncio async def test_a_create_container_cancelled_during_the_pull_terminates_the_vm(): pulling = asyncio.Event()