diff --git a/AGENTS.md b/AGENTS.md index 6c7dffc9f..16acab266 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,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`), `GatewayEnv` (`gateway_server`), `ComputerDriverEnv` (`computer_driver`, `env/envs/computer/driver_env.py`: a computer driver's files and what its class declares for each platform it supports, read at put in a process of its own, `agentenv_protocol.computer._declare`, where the driver's own dependencies needn't be installed, with the `port` it serves on and its install by OS, `[install.]` (`env/envs/computer/utils/install.py`): files, apt `packages` with extra `sources`, a `python` requirements file for a venv, `downloads` by URL and sha256, `run` scripts, the services the base's init runs, and `ports`; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch on a builder of the put's sandbox provider, by default the env's (`utils/builder.py`; `--sandbox` on `env build`, `bundle register` and `run`): this machine's Docker for `local`, else a VM of the provider, which fetches the put's stored files by signed URL, takes the rest inline and pushes to the image store itself, so it builds only with stores a remote VM reaches; each arch goes to the first link that runs it natively, else one that emulates it (a VM registers QEMU; `--no-emulate` leaves it `skipped` instead, for a later put), and its pair records the provider that built it (`builder`); a run or register that reuses a computer env composes what it has left (`pending`); tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile, build context and lock so any env with the same inputs reuses it; every layer's packages, requirements and downloads are resolved at put in a container of the base for each arch (`utils/packages.py`, `utils/resolve_in_base.py`), stored in the object store by sha256 under `agentenv-packages/`, and listed in a lock stored by a digest of its inputs, which any env with the same inputs reuses, and builds install only the lock's files, every install step `RUN --network=none`; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 then boots each native build on the put's sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`, which declares each OS with its own decorator (`@linux(...)`, or `@install(os_type, spec)` for any OS type) holding its install, its launch and, optionally, a skill and settings and build settings that add to the class's there, and install steps and ready checks for every OS or those their `os=` names; everything that reads its settings, launch or steps asks for the computer's OS type; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference; its env.toml's `build_settings`, values of its `BuildSettings` checked at put, are written to `.agentenv/build_settings.json` in its folder, so they're part of the image's key, its install steps read them, and a deploy can't change them). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `env/gateway/gateway.py` is the env-side gateway server, container-only code. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | +| `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset`, `close` and `load_file_artifact_universe`; teardown closes each env a run deployed before terminating its sandboxes by id. Built-ins in `env/envs/`: `MCPServerEnv` (`mcp_server`), `MultiEnv` (`multi`, MCP servers plus websites), `ServiceDBEnv` (`service_db`, PostgreSQL), `WebsiteEnv` (`website`), `GatewayEnv` (`gateway_server`), `ComputerDriverEnv` (`computer_driver`, `env/envs/computer/driver_env.py`: a computer driver's files and what its class declares for each platform it supports, read at put in a process of its own, `agentenv_protocol.computer._declare`, where the driver's own dependencies needn't be installed, with the `port` it serves on and its install by OS, `[install.]` (`env/envs/computer/utils/install.py`): files, apt `packages` with extra `sources`, a `python` requirements file for a venv, `downloads` by URL and sha256, `run` scripts, the services the base's init runs, and `ports`; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch on a builder of the put's sandbox provider, by default the env's (`utils/builder.py`; `--sandbox` on `env build`, `bundle register` and `run`): this machine's Docker for `local`, else a VM of the provider, which fetches the put's stored files by signed URL, takes the rest inline and pushes to the image store itself, so it builds only with stores a remote VM reaches; each arch goes to the first link that runs it natively, else one that emulates it (a VM registers QEMU; `--no-emulate` leaves it `skipped` instead, for a later put), and its pair records the provider that built it (`builder`); a run or register that reuses a computer env composes what it has left (`pending`); tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile, build context and lock so any env with the same inputs reuses it; every layer's packages, requirements and downloads are resolved at put in a container of the base for each arch (`utils/packages.py`, `utils/resolve_in_base.py`), stored in the object store by sha256 under `agentenv-packages/`, and listed in a lock stored by a digest of its inputs, which any env with the same inputs reuses, and builds install only the lock's files, every install step `RUN --network=none`; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 then boots each native build on the put's sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`, which declares each OS with its own decorator (`@linux(...)`, or `@install(os_type, spec)` for any OS type) holding its install, its launch and, optionally, a skill and settings and build settings that add to the class's there, and install steps and ready checks for every OS or those their `os=` names; everything that reads its settings, launch or steps asks for the computer's OS type; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference, 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. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | | `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Every artifact but an environment universe, whose `metadata` key holds its legacy metadata file ids, carries a `metadata` dict outside its versioned fields, changed by compare-and-swap (`merge_metadata`). Built-ins: `CliArtifact` (`cli`), `ComputerArtifact` (`computer`, a base computer: its architectures, OS, session user, settings and container form, whose image `put` reads from its registry into `metadata.validated_images`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`: its environment's files by path, stored as the one wrapped `file_artifact_ref` every environment artifact had when it has one file at its filename, else as `file_artifact_refs`; `get_file_artifacts()` reads either, and a load hands each file to the env's `data/add` with its path, which a pre-v1 load, taking one file, refuses), `EnvironmentUniverseArtifact` (`environment_universe`), `SkillArtifact` (`skill`); `[artifacts] type_aliases` maps another stored spelling of a type onto one of them. A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull, or a published image's tag with its `image_digest` and its build context, which sandboxes pull by the digest and build from the context when they can't (the infra images, and a bundle agent built from a shipped agent's sources, `Plan.published`), or, written by `put_context`, only its build context, which a VM sandbox builds before running it (`VmSandbox.load_docker_images`) and Modal builds before creating its container (`ModalSandboxProvider.prepare_image`, outside a fallback chain's deadline); anything else that runs images by name (a container on the local provider, a lone server on any provider but Modal) refuses such an image. A build context (`utils/docker/context.py`) is what `docker build` would send, `.dockerignore` applied, written so equal files give equal bytes and an equal `source_digest`. | | `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, `start_computer_recording` and `end_computer_recording`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. A computer's framework-only routes (everything but MCP and the card) take the per-deploy control token its provider boots it with (`DeployedEnv.control_token`, `AGENTENV_CONTROL_TOKEN`); framework calls send `deployed.control_headers`, `Task.run` runs every step under `protocol_v1.authorized` with each deployed env's (a `verify` included), and a `run_code` script inside the computer gets `input["env"]["headers"]`. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each env still up closes (`Env.close`; a computer env ends any recording still open), what stayed open is registered from its namespace, then every sandbox is terminated by id. `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB, DynamoDB, Firestore with MongoDB compatibility), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. `store/routing.py` is the `@local` namespace: while the CLI or a `build_app` server runs, an `@local/…` id and what an `@local` task's run records go to local stores beside the configured ones, and that run's writes to the configured stores are refused. A new backend must pass the conformance kits in `tst/store/`. | diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index 1269289f6..828fe8299 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -661,8 +661,9 @@ class MailApp(ComputerAppEnvironment): - **`@computer_app`** names the platforms the app supports, as a driver names them, and the `permissions` it needs of the computer. Its env.toml names the rest: the app itself - (`environment_name`), the `computer` and `driver` it deploys alone on, and `build_settings`, its - build settings' values (`build_settings = { locale = "fr_FR" }`). + (`environment_name`), the `computer` and `driver` it deploys alone on, `build_settings`, its + build settings' values (`build_settings = { locale = "fr_FR" }`), and `[settings]`, defaults for its + `Settings` each deploy starts from. - **`@linux(...)`** is how a Linux computer installs it, `Linux`'s fields: `files`, a folder of the app's copied under its prefix, which holds the class's file; `packages`, the OS's, as apt takes them (`Packages(apt=["name", "name=version", "name/suite"])`); `python`, a requirements file there, @@ -698,9 +699,13 @@ class MailApp(ComputerAppEnvironment): - **Its data** is under the `paths` its OS's decorator names, relative to the session user's home, none inside another (`@linux(..., paths=["Mail", ".config/mail/accounts.json"])`), or what its own `@add_data`, `@get_data` and `@reset_data` hooks serve instead (below). -- **`Settings`** is typed; a computer hands each app the values a deploy gives it, as one JSON value - in `APP_SETTINGS_ENV` (`AGENTENV_APP_SETTINGS`, the apps' values by name), and the base class reads - its own, by `ENVIRONMENT_NAME`, as `self.settings`. A `SecretStr` field is a secret. +- **`Settings`** is typed. Its values come from the app env's `[settings]`, then the computer env's + `settings` beside the app in its `apps` (`apps = [{ env = "mail", settings = { signature = "-- Ada" } }]`), + then the deploy's `env_params` `app_settings`, each over the one before by top-level field (a table + or list is replaced whole). Each is checked at put; a required one may be left to the deploy, which + checks them merged. A computer hands each app its values as one JSON value in `APP_SETTINGS_ENV` + (`AGENTENV_APP_SETTINGS`, the apps' values by name), and the base class reads its own, by + `ENVIRONMENT_NAME`, as `self.settings`. A `SecretStr` field is a secret. - **`BuildSettings`** is typed too: values the app's env fixes in its computer's image, which only another image changes, so a deploy can't give one. The composer writes them to `.agentenv/build_settings.json` in the app's folder, and the base class reads them, over the @@ -752,9 +757,12 @@ are the profile's template. The base class adds: - **Its settings**, `BrowserSettings`, which a subclass's `Settings` extends: `start_urls`, opened in tabs at launch, the first active (`["about:blank"]` by default; none may start with `-`), headless the first from its command line and the rest over its pipe, since headless Chromium takes one; `window`, - 1280 × 800, alone only; `locale`; and `time_zone`, set as `TZ` in its launch's `env`. -- **The flags agentenv owns**, after the subclass's command and before the start tabs: its profile, - `--remote-debugging-pipe`, `--no-first-run`, `--no-default-browser-check` and `--lang`. With + 1280 × 800, alone only; `locale`; `time_zone`, set as `TZ` in its launch's `env`; and `geolocation` and + `notifications`, `deny` (the default), which answers a page's prompt for it, or `ask`. +- **The flags agentenv owns**, after the subclass's command and before the start tabs: its profile, its + HTTP cache under `~/.cache` (`--disk-cache-dir`), `--remote-debugging-pipe`, `--no-first-run`, + `--no-default-browser-check`, `--lang` and, when `/dev/shm` holds under 1 GiB (Docker gives 64 MiB), + `--disable-dev-shm-usage`, so it keeps its shared memory in files. With `AGENTENV_SITE_PROXY` set, it goes out through that proxy, with WebRTC's non-proxied UDP off. Alone it bypasses nothing, loopback included; in a computer it bypasses loopback (`127.0.0.1`, `[::1]`, `localhost`), so the apps beside it stay reachable while `*.localhost` names still go @@ -762,6 +770,9 @@ are the profile's template. The base class adds: except on the open web (`AGENTENV_BROWSER_OPEN_WEB=1`), and refuses to start with no proxy. `declaration()` refuses a subclass whose command carries any of these, or `--remote-allow-origins`, or any other way to open a debugging port. +- **Before each launch** it empties its cache, so no launch inherits another's, and writes the deploy's + prompt answers into its profile's `Default/Preferences` (`profile.default_content_setting_values`, + `2` to deny, left out to ask), which it reads as it starts. - **Alone, no desktop**: its launch matches and waits on no window, its server adds no launcher and, without `DISPLAY`, waits for no display, and its ready checks say when it's ready. - **`write_policies`**, an install step that writes agentenv's unattended-use policies (`POLICIES`) @@ -772,7 +783,9 @@ are the profile's template. The base class adds: the result or raises `DevToolsError`, and `ConnectionError` with no browser connected. No port listens and no `DevToolsActivePort` is written, so nothing else in the computer can reach it over CDP. Since only the server that launched it holds the pipe, a server that restarts stops it and - launches it again. The ready check `devtools_answers` asks `Browser.getVersion` over the pipe. + launches it again, and a launch while it runs, from its menu launcher say, opens another window over + the pipe rather than a second copy, which would hand its window to the first and exit. The ready check + `devtools_answers` asks `Browser.getVersion` over the pipe. - **`browser/v1`** on its card, params only: `chromium` (its `version`), `clock: "real"` and, alone, `window`. - **Its skill**: sites by their real names, files to upload in `~/Uploads` and downloads in diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py index 890c0af2b..22877b7b2 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app.py @@ -16,7 +16,7 @@ import os from dataclasses import dataclass from pathlib import Path, PurePosixPath -from typing import Annotated, Any, Callable, ClassVar, Literal, Optional, Union +from typing import Annotated, Any, Awaitable, Callable, ClassVar, Literal, Optional, Union from pydantic import (AfterValidator, BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, create_model, field_validator, model_validator) @@ -409,7 +409,8 @@ def __init__(self) -> None: self.launch, {fn.__name__: getattr(self, fn.__name__) for fn in _methods(cls) if hasattr(fn, _READY_CHECK_ATTR) and _runs_on(getattr(fn, _READY_CHECK_ATTR), on)}, ready.timeout_s if ready is not None else DEFAULT_TIMEOUT_S, desktop=self._on_desktop(), - launch_fds=self._launch_fds, relaunch_on_start=self._relaunch_on_start()) + launch_fds=self._launch_fds, relaunch_on_start=self._relaunch_on_start(), + launch_running=self._launch_running()) self._computer_app_data = AppDataPlane( self._computer_app, {op: getattr(self, name) for op, name in cls._data_hooks.items()}, self.paths, cls.data_timeout_s) @@ -431,6 +432,10 @@ def _relaunch_on_start(self) -> bool: """Whether only the server that launched it can talk to it, so a server that restarts launches it again.""" return False + def _launch_running(self) -> Optional[Callable[[], Awaitable[None]]]: + """What a launch does while it runs instead of starting a second copy: nothing, so another copy starts.""" + return None + @classmethod def settings_on(cls, os_type: Optional[str]) -> type[BaseModel]: """Its ``Settings`` on ``os_type``, with the fields its OS adds.""" diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py index 071eda606..fc30ec1bf 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py @@ -50,13 +50,15 @@ class AppRuntime: def __init__(self, launch: Optional[Launch], ready_checks: dict[str, Callable[[], Any]], timeout_s: float, *, desktop: bool = True, launch_fds: Optional[Callable[[], Awaitable[dict[int, int]]]] = None, - relaunch_on_start: bool = False) -> None: + relaunch_on_start: bool = False, + launch_running: Optional[Callable[[], Awaitable[None]]] = None) -> None: self.launch = launch self.ready_checks = ready_checks self.timeout_s = timeout_s self.desktop = desktop self.launch_fds = launch_fds self.relaunch_on_start = relaunch_on_start + self.launch_running = launch_running self.started = time.time() self.launch_failed: Optional[str] = None self.checking: dict[str, asyncio.Future] = {} @@ -84,7 +86,11 @@ async def start(self) -> None: async def launch_app(self) -> int: """Start the app from its folder, marked as launched, with the fds the app gives it, whose copies here are - closed once it has them; its pid.""" + closed once it has them; its pid. With ``launch_running``, an app this server launched that still runs does + that instead of starting a second copy, as a browser opens another window.""" + if self.launch_running is not None and (running := _running()) is not None: + await self.launch_running() + return running fds = await self.launch_fds() if self.launch_fds is not None else {} try: pid = await session.launch(self.launch.command, Path.cwd(), @@ -156,6 +162,15 @@ def _launched_at() -> Optional[float]: return None +def _running() -> Optional[int]: + """The pid of the app this server launched last, while it runs.""" + try: + pid = int(PID.read_text()) + except (FileNotFoundError, ValueError): + return None + return pid if session.alive(pid) else None + + async def _bounded(name: str, check: Awaitable[Optional[str]]) -> Optional[str]: """What ``check`` finds wrong, within ``CHECK_TIMEOUT_S``; a check that runs past it or raises says so.""" try: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser.py index 27597160f..8d7c354ee 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website_browser.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website_browser.py @@ -1,16 +1,19 @@ """WebsiteBrowserEnvironment: a website browser as a computer app, run inside a computer or alone. A subclass says how its build installs and starts; this class adds what agentenv owns, the same in both: its flags after the subclass's command, the unattended-use policies, a CDP connection over the browser's debugging pipe, the settings every browser -takes, its skill and ``browser/v1`` on its card. Whether it runs alone, on the open web and through which site proxy -come from variables agentenv sets, never from its settings.""" +takes, a fresh cache and the deploy's prompt answers at every launch, another window for a launch while it runs, its +skill and ``browser/v1`` on its card. Whether it runs alone, on the open web and through which site proxy come from +variables agentenv sets, never from its settings.""" from __future__ import annotations import asyncio import itertools import json +import logging import os +import shutil from pathlib import Path -from typing import Any, ClassVar, Optional +from typing import Any, Awaitable, Callable, ClassVar, Literal, Optional from pydantic import BaseModel, Field, field_validator @@ -18,6 +21,8 @@ from .computer import AppDeclaration, ComputerAppEnvironment, Launch, install_step, ready_check from .types import EnvironmentExtension +logger = logging.getLogger(__name__) + ALONE_ENV = "AGENTENV_BROWSER_ALONE" OPEN_WEB_ENV = "AGENTENV_BROWSER_OPEN_WEB" SITE_PROXY_ENV = "AGENTENV_SITE_PROXY" @@ -25,7 +30,12 @@ #: The flags agentenv sets, by prefix, which a subclass's command may not carry: among them every way to open a debugging #: port, since the browser is reached only over its pipe. OWNED_FLAGS = ("--user-data-dir", "--remote-debugging-", "--remote-allow-origins", "--proxy-", "--no-proxy-server", - "--headless", "--window-size", "--lang", "--force-webrtc-ip-handling-policy") + "--headless", "--window-size", "--lang", "--force-webrtc-ip-handling-policy", "--disk-cache-dir", + "--disable-dev-shm-usage") +#: Below this much /dev/shm, as Docker's 64 MiB default, the browser keeps its shared memory in files instead. +SHM_MIN_BYTES = 1 << 30 +#: Chromium's content setting that denies a permission without a prompt. +BLOCK = 2 #: Alone, loopback goes through the proxy too. In a computer, the apps on loopback beside it stay reachable; ``<-loopback>`` #: removes Chromium's own rule, so ``*.localhost`` names still don't. ALONE_BYPASS = "<-loopback>" @@ -147,6 +157,9 @@ class BrowserSettings(BaseModel): locale: str = "en-US" #: The IANA time zone its pages see. time_zone: str = "UTC" + #: Whether a page asking for the location, or to show notifications, is denied without a prompt or asks. + geolocation: Literal["deny", "ask"] = "deny" + notifications: Literal["deny", "ask"] = "deny" @field_validator("start_urls") @classmethod @@ -189,12 +202,20 @@ def declaration(cls) -> AppDeclaration: def profile_dir(self) -> Path: return Path.home() / self.profile + def cache_dir(self) -> Path: + """Its HTTP cache, kept out of its profile, which holds only its data.""" + return Path.home() / ".cache" / Path(self.profile).name + def agentenv_flags(self) -> list[str]: """The flags agentenv owns. A browser alone goes out only through its site proxy, so it won't launch without one; in a computer it goes through one when the computer has one, loopback aside.""" settings, alone, proxy = self.settings, _alone(), os.environ.get(SITE_PROXY_ENV) - flags = [f"--user-data-dir={self.profile_dir()}", "--remote-debugging-pipe", "--no-first-run", - "--no-default-browser-check", f"--lang={settings.locale}"] + flags = [f"--user-data-dir={self.profile_dir()}", f"--disk-cache-dir={self.cache_dir()}", + "--remote-debugging-pipe", "--no-first-run", "--no-default-browser-check", f"--lang={settings.locale}"] + if (shm := _shm_bytes()) < SHM_MIN_BYTES: + logger.info(f"/dev/shm holds {shm // 2**20} MiB, under {SHM_MIN_BYTES // 2**20} MiB: the browser keeps its " + "shared memory in files") + flags.append("--disable-dev-shm-usage") if proxy: flags += [f"--proxy-server={proxy}", f"--proxy-bypass-list={ALONE_BYPASS if alone else COMPUTER_BYPASS}", "--force-webrtc-ip-handling-policy=disable_non_proxied_udp"] @@ -228,8 +249,11 @@ def _command_line_tabs(self) -> list[str]: return self.settings.start_urls[:1] if _headless() else self.settings.start_urls async def _launch_fds(self) -> dict[int, int]: - """A new debugging pipe for this launch: the browser's ends as its fds 3 and 4, this server's ends its CDP - connection, over which the start tabs its command line can't open are opened.""" + """Before each launch, an empty cache and the deploy's prompt answers in its profile; and a new debugging pipe: + the browser's ends as its fds 3 and 4, this server's ends its CDP connection, over which the start tabs its + command line can't open are opened.""" + shutil.rmtree(self.cache_dir(), ignore_errors=True) + self._write_prompt_answers() to_browser_read, to_browser_write = os.pipe() from_browser_read, from_browser_write = os.pipe() await self.devtools.connect(to_browser_write, from_browser_read) @@ -239,6 +263,24 @@ async def _launch_fds(self) -> dict[int, int]: self._start_tabs.add_done_callback(lambda done: done.cancelled() or done.exception()) return {PIPE_IN: to_browser_read, PIPE_OUT: from_browser_write} + def _write_prompt_answers(self) -> None: + """Its profile's preferences, which it reads as it starts, denying location and notification prompts or + leaving them to ask, as the deploy's settings say.""" + preferences = self.profile_dir() / "Default" / "Preferences" + try: + current = json.loads(preferences.read_text(encoding="utf-8")) + except (OSError, ValueError): + current = {} + current = current if isinstance(current, dict) else {} + defaults = current.setdefault("profile", {}).setdefault("default_content_setting_values", {}) + for name in ("geolocation", "notifications"): + if getattr(self.settings, name) == "deny": + defaults[name] = BLOCK + else: + defaults.pop(name, None) + preferences.parent.mkdir(parents=True, exist_ok=True) + preferences.write_text(json.dumps(current), encoding="utf-8") + async def _open_tabs(self, urls: list[str]) -> None: """Each of ``urls`` in a tab behind the active one, in order.""" for url in urls: @@ -247,6 +289,14 @@ async def _open_tabs(self, urls: list[str]) -> None: def _relaunch_on_start(self) -> bool: return True + def _launch_running(self) -> Callable[[], Awaitable[None]]: + return self._open_window + + async def _open_window(self) -> None: + """Another window, which a launch of the browser while it runs opens over its pipe: a second copy would only + hand its window to the first and exit, taking its new pipe with it.""" + await self.devtools.call("Target.createTarget", {"url": "about:blank", "newWindow": True}) + @install_step(scope="system") def write_policies(self) -> None: """agentenv's unattended-use policies, with the subclass's over them, where its build reads them.""" @@ -286,6 +336,14 @@ def _open_web() -> bool: return os.environ.get(OPEN_WEB_ENV) == "1" +def _shm_bytes(path: str = "/dev/shm") -> int: + try: + stats = os.statvfs(path) + except OSError: + return 0 + return stats.f_frsize * stats.f_blocks + + def _headless() -> bool: """Alone it runs headless, but headful on Xvfb for the open web, whose sites block headless browsers.""" return _alone() and not _open_web() diff --git a/packages/agentenv-protocol/tests/test_website_browser.py b/packages/agentenv-protocol/tests/test_website_browser.py index b6d7aa2d5..a4470a2ad 100644 --- a/packages/agentenv-protocol/tests/test_website_browser.py +++ b/packages/agentenv-protocol/tests/test_website_browser.py @@ -1,17 +1,20 @@ """WebsiteBrowserEnvironment: agentenv's flags after a subclass's command, in a computer and alone, and the proxy rule for each; a subclass refused for carrying one of them; the start tabs, the time zone and the window from its settings; its launch run with no desktop when alone; a CDP connection over a new debugging pipe at each launch, which its ready -check asks, and a browser launched again when its server restarts; its policies, card params and skill; and an app that -isn't a browser launched and declared as before.""" +check asks, and a browser launched again when its server restarts; its shared memory in files only on a small +/dev/shm; an empty cache and the deploy's prompt answers in its profile at each launch; its policies, card params and +skill; and an app that isn't a browser launched and declared as before.""" from __future__ import annotations import asyncio import json +import os import sys import pytest from agentenv_protocol.computer import APP_SETTINGS_ENV, ComputerAppEnvironment, Launch, Ready, app, linux, session +from agentenv_protocol import website_browser from agentenv_protocol.computer import app_runtime from agentenv_protocol.computer.platform import Platform from agentenv_protocol.website_browser import (ALONE_BYPASS, ALONE_ENV, BROWSER, COMPUTER_BYPASS, OPEN_WEB_ENV, @@ -19,9 +22,11 @@ WebsiteBrowserEnvironment) PROXY = "http://127.0.0.1:3128" -OWNED = ["--user-data-dir", "--remote-debugging-pipe", "--no-first-run", "--no-default-browser-check", "--lang=en-US"] -# A browser that answers CDP on its debugging pipe, fds 3 and 4, as a Chromium does: Browser.getVersion with its product, -# Target.createTarget by writing the tab's URL to tabs.txt beside it, anything else with an error. +OWNED = ["--user-data-dir", "--disk-cache-dir", "--remote-debugging-pipe", "--no-first-run", + "--no-default-browser-check", "--lang=en-US"] +# A browser that answers CDP on its debugging pipe, fds 3 and 4, as a Chromium does: Browser.getVersion with its +# product, Target.createTarget by writing the tab's URL to tabs.txt beside it, a new window's after "window ", anything +# else with an error. FAKE_BROWSER = """import json, os buffer = b"" while chunk := os.read(3, 65536): @@ -31,9 +36,9 @@ message = json.loads(raw) if message["method"] == "Browser.getVersion": answer = {"result": {"product": "Fake/1"}} - elif message["method"] == "Target.createTarget" and message["params"]["background"]: + elif message["method"] == "Target.createTarget": with open("tabs.txt", "a") as tabs: - tabs.write(message["params"]["url"] + "\\n") + tabs.write(("window " if message["params"].get("newWindow") else "") + message["params"]["url"] + "\\n") answer = {"result": {"targetId": message["params"]["url"]}} else: answer = {"error": {"code": -32601, "message": "unknown method"}} @@ -60,8 +65,10 @@ class OnDemandBrowser(WebsiteBrowserEnvironment): @pytest.fixture(autouse=True) def browser_home(monkeypatch, tmp_path): - """A browser on Linux, its session user's home under ``tmp_path``, inside a computer with no site proxy.""" + """A browser on Linux, its session user's home under ``tmp_path``, inside a computer with no site proxy and 1 GiB + of /dev/shm.""" monkeypatch.setattr(app, "detect", lambda: Platform(os="linux")) + monkeypatch.setattr(website_browser, "_shm_bytes", lambda path="/dev/shm": 1 << 30) monkeypatch.setenv("HOME", str(tmp_path / "home")) (tmp_path / "home").mkdir() monkeypatch.setenv("ENVIRONMENT_NAME", "chromium") @@ -78,7 +85,8 @@ def test_in_a_computer_its_flags_follow_its_command_then_its_start_tabs_and_its_ launch = Browser().launch profile = f"--user-data-dir={browser_home / '.config/chromium'}" - assert launch.command == ["chromium/chrome", "--no-sandbox", profile, *OWNED[1:], "about:blank"] + cache = f"--disk-cache-dir={browser_home / '.cache/chromium'}" + assert launch.command == ["chromium/chrome", "--no-sandbox", profile, cache, *OWNED[2:], "about:blank"] assert launch.env == {"TZ": "UTC"} assert launch.window == {"class": "Chromium"} and launch.ready == Ready(window=True, timeout_s=30) @@ -89,7 +97,7 @@ def test_in_a_computer_with_a_site_proxy_only_loopback_bypasses_it(monkeypatch): command = Browser().launch.command assert [f"--proxy-server={PROXY}", f"--proxy-bypass-list={COMPUTER_BYPASS}", - "--force-webrtc-ip-handling-policy=disable_non_proxied_udp"] == command[7:10] + "--force-webrtc-ip-handling-policy=disable_non_proxied_udp"] == command[8:11] assert not any(flag.startswith(("--headless", "--window-size")) for flag in command) @@ -142,7 +150,8 @@ def test_alone_it_refuses_to_launch_without_a_site_proxy(monkeypatch): @pytest.mark.parametrize("flag", ["--remote-debugging-port=9222", "--remote-allow-origins=*", "--headless", - "--proxy-server=http://elsewhere:3128", "--user-data-dir=/tmp/profile"]) + "--proxy-server=http://elsewhere:3128", "--user-data-dir=/tmp/profile", + "--disk-cache-dir=/tmp/cache", "--disable-dev-shm-usage"]) def test_a_subclass_whose_command_carries_a_flag_agentenv_owns_is_refused_when_declared(flag): @linux(launch=Launch(command=["chromium/chrome", flag])) class Widened(WebsiteBrowserEnvironment): @@ -152,6 +161,37 @@ class Widened(WebsiteBrowserEnvironment): Widened.declaration() +@pytest.mark.parametrize("mib, in_files", [(64, True), (1024, False)], ids=["docker-default", "sized"]) +def test_it_keeps_its_shared_memory_in_files_only_when_dev_shm_is_under_1_gib(monkeypatch, mib, in_files): + monkeypatch.setattr(website_browser, "_shm_bytes", lambda path="/dev/shm": mib * 2**20) + + assert ("--disable-dev-shm-usage" in Browser().launch.command) is in_files + + +@pytest.mark.asyncio +@pytest.mark.parametrize("settings, prompts", [ + ({}, {"geolocation": 2, "notifications": 2}), + ({"notifications": "ask"}, {"geolocation": 2}), +], ids=["deny", "ask"]) +async def test_each_launch_starts_with_an_empty_cache_and_the_deploys_prompt_answers_in_its_profile( + browser_home, monkeypatch, settings, prompts): + _settings(monkeypatch, **settings) + preferences = browser_home / ".config/chromium/Default/Preferences" + preferences.parent.mkdir(parents=True) + preferences.write_text(json.dumps({"homepage": "about:blank", "profile": {"default_content_setting_values": { + "notifications": 1, "cookies": 1}}})) + (browser_home / ".cache/chromium/Cache").mkdir(parents=True) + browser = Browser() + + for fd in (await browser._launch_fds()).values(): + os.close(fd) + browser.devtools.close() + + assert json.loads(preferences.read_text()) == {"homepage": "about:blank", "profile": { + "default_content_setting_values": {"cookies": 1, **prompts}}} + assert not (browser_home / ".cache/chromium").exists() + + def test_its_settings_give_its_start_tabs_language_and_time_zone_and_no_start_url_is_a_flag(monkeypatch): _settings(monkeypatch, start_urls=["https://slack.com", "https://linear.app"], locale="fr-FR", time_zone="Europe/Paris") @@ -242,6 +282,26 @@ async def test_each_launch_gets_a_new_pipe_its_server_talks_cdp_over_and_its_rea assert "devtools_answers" in runtime.ready_checks +@pytest.mark.asyncio +async def test_a_launch_while_it_runs_opens_a_window_over_its_pipe_and_starts_no_second_copy(monkeypatch, tmp_path, + browser_home): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(app_runtime, "LAUNCHED", tmp_path / "launched") + browser = FakeBrowser() + runtime = browser._computer_app + + first = await runtime.launch_app() + (browser_home / ".cache/chromium").mkdir(parents=True, exist_ok=True) + (browser_home / ".cache/chromium/kept").touch() + second = await runtime.launch_app() + answers = await browser.devtools_answers() + await runtime.stop_app() + browser.devtools.close() + + assert second == first and answers is True and (browser_home / ".cache/chromium/kept").exists() + assert (tmp_path / "tabs.txt").read_text().splitlines() == ["window about:blank"] + + @pytest.mark.asyncio async def test_headless_it_opens_one_start_tab_from_its_command_line_and_the_rest_over_its_pipe(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) @@ -356,6 +416,7 @@ def test_an_app_that_isnt_a_browser_launches_and_declares_as_before(): stored = Mail.declaration().model_dump(mode="json", exclude_none=True) assert mail.launch is Mail._installs["linux"].launch and mail._computer_app.desktop is True + assert mail._computer_app.launch_running is None assert stored["os"]["linux"]["launch"] == {"at": "boot", "command": ["mail"], "window": {"class": "Mail"}, "ready": {"window": True, "timeout_s": 60.0}} diff --git a/src/agent_env/browsers/README.md b/src/agent_env/browsers/README.md index 777085b4a..18e3e0901 100644 --- a/src/agent_env/browsers/README.md +++ b/src/agent_env/browsers/README.md @@ -3,11 +3,13 @@ The website browsers that come with agent-env, today `chromium`, a `computer_app `envs/chromium/` is Chromium for an X11 desktop on Linux (`amd64` and `arm64`): Chrome for Testing 156.0.8078.4, the build Playwright 1.64.1 pins, fetched by sha256 when a computer env that lists it is put and unpacked into -`/opt/agentenv/apps/chromium/` by its install step, with managed policies in -`/etc/opt/chrome_for_testing/policies/managed/` for unattended use: no first-run screens, sign-in, sync, promotional -tabs, default-browser prompt, telemetry, password, autofill or translation prompts, updates, location or notification -prompts, and no warning bar. It runs without Chromium's sandbox and `/dev/shm`, which a container can't give it, and -with no proxy. It opens at boot on its `start_urls` setting, a list, the first tab active, none by default -(`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`, +`/opt/agentenv/apps/chromium/` by its install step. It's a website browser +(`agentenv_protocol.website_browser.WebsiteBrowserEnvironment`): agentenv launches it on a debugging pipe with its own +flags, and writes managed policies to `/etc/opt/chrome_for_testing/policies/managed/` for unattended use. It runs +without Chromium's sandbox, which a container can't give it. Its env.toml spells out its settings and their defaults +under `[settings]`: `start_urls`, opened at boot, the first tab active; `window`, alone only; `locale`; `time_zone`; and +`geolocation` and `notifications`, `deny` or `ask`. A computer env sets its own beside it +(`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`. diff --git a/src/agent_env/browsers/envs/chromium/chromium.py b/src/agent_env/browsers/envs/chromium/chromium.py index 670643735..4fb265ae8 100644 --- a/src/agent_env/browsers/envs/chromium/chromium.py +++ b/src/agent_env/browsers/envs/chromium/chromium.py @@ -1,17 +1,11 @@ """agentenv's chromium app: Chrome for Testing at the build Playwright 1.64.1 pins, for amd64 and arm64 (Google ships -no Linux arm64 Chrome), set up for unattended use by managed policies and opened at boot on the deploy's -``start_urls``. Its data is its profile, which the image has none of, so every deploy and every reset starts with a -clean one; its HTTP cache is kept out of it. Run as a script, it is the app's launch.""" -import json -import os +no Linux arm64 Chrome), a website browser whose flags, policies and settings agentenv's base class gives it. Its data is +its profile, which the image has none of, so every deploy and every reset starts with a clean one.""" import subprocess from pathlib import Path -from typing import Iterable -from pydantic import BaseModel - -from agentenv_protocol.computer import (ComputerAppEnvironment, Download, Launch, Packages, Ready, computer_app, - install_step, linux) +from agentenv_protocol.computer import Download, Launch, Packages, Ready, computer_app, install_step, linux +from agentenv_protocol.website_browser import WebsiteBrowserEnvironment VERSION = "156.0.8078.4" _ARCHIVES = {"amd64": ("linux64/chrome-linux64.zip", @@ -28,46 +22,29 @@ "libxcomposite1", "libxdamage1", "libxext6", "libxfixes3", "libxkbcommon0", "libxrandr2", "libxshmfence1", "unzip"] WINDOW_CLASS = "Chromium" -#: Where Chrome for Testing reads managed policies; it reads neither Chromium's folder nor Chrome's. -POLICY_DIR = Path("/etc/opt/chrome_for_testing/policies/managed") -#: No sign-in, sync, welcome or promotional tabs, default-browser prompt, telemetry, password prompts, autofill on any -#: site, translation offers, updates, or location and notification prompts; and no warning bar for the flags a container -#: needs, which also takes away Chrome for Testing's own bar. -POLICIES = { - "BrowserSignin": 0, - "SyncDisabled": True, - "PromotionalTabsEnabled": False, - "DefaultBrowserSettingEnabled": False, - "MetricsReportingEnabled": False, - "PasswordManagerEnabled": False, - "AutofillSettings": [{"url_pattern": "*", "blocked_types": ["all"]}], - "TranslateEnabled": False, - "ComponentUpdatesEnabled": False, - "DefaultGeolocationSetting": 2, - "DefaultNotificationsSetting": 2, - "CommandLineFlagSecurityWarningsEnabled": False, -} PROFILE = ".config/chromium" -CACHE = ".cache/chromium" @computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) @linux(packages=Packages(apt=PACKAGES), downloads=DOWNLOADS, paths=[PROFILE], - launch=Launch(at="boot", command=["venv/bin/python", "chromium.py"], window={"class": WINDOW_CLASS}, - ready=Ready(window=True))) -class Chromium(ComputerAppEnvironment): - class Settings(BaseModel): - #: Opened in tabs at launch, the first active; none opens a new tab. - start_urls: list[str] = [] + 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): + """Chrome for Testing without its sandbox, which a container can't give it.""" + + version = VERSION + #: Where Chrome for Testing reads managed policies; it reads neither Chromium's folder nor Chrome's. + policy_dir = Path("/etc/opt/chrome_for_testing/policies/managed") + profile = PROFILE @install_step(scope="system", os="linux") def chromium(self) -> None: - install_chromium(Path.cwd()) + unpack(Path.cwd()) -def install_chromium(folder: Path, policies: Path = POLICY_DIR) -> None: +def unpack(folder: Path) -> None: """Unpack the arch's archive under ``folder``'s ``chromium/`` to ``chromium/chrome/``, with unzip, which keeps its - execute bits, in its place, and write ``POLICIES`` to ``policies``.""" + execute bits, in its place.""" [archive] = (folder / "chromium").glob("chrome-*.zip") unpacked = folder / "chromium" / "unpacked" subprocess.run(["unzip", "-q", str(archive), "-d", str(unpacked)], check=True) @@ -75,20 +52,3 @@ def install_chromium(folder: Path, policies: Path = POLICY_DIR) -> None: top.rename(folder / "chromium" / "chrome") unpacked.rmdir() archive.unlink() - policies.mkdir(parents=True, exist_ok=True) - (policies / "agentenv.json").write_text(json.dumps(POLICIES, indent=1) + "\n", encoding="utf-8") - - -def chromium_command(folder: Path, *, home: Path, urls: Iterable[str]) -> list[str]: - """Chromium's command: no sandbox, which a container can't give it, and no /dev/shm, which no computer sizes; no - first run or default-browser check; ``WINDOW_CLASS`` for its windows; its profile and, outside it, its cache, under - ``home``; and ``urls`` in tabs, the first active. It takes no proxy: a computer has no site proxy, and its sites on - loopback must stay reachable.""" - return [str(folder / "chromium" / "chrome" / "chrome"), "--no-sandbox", "--disable-dev-shm-usage", "--no-first-run", - "--no-default-browser-check", f"--class={WINDOW_CLASS}", f"--user-data-dir={home / PROFILE}", - f"--disk-cache-dir={home / CACHE}", *urls] - - -if __name__ == "__main__": - command = chromium_command(Path(__file__).resolve().parent, home=Path.home(), urls=Chromium().settings.start_urls) - os.execv(command[0], command) diff --git a/src/agent_env/browsers/envs/chromium/env.toml b/src/agent_env/browsers/envs/chromium/env.toml index f2c05cc7b..520321950 100644 --- a/src/agent_env/browsers/envs/chromium/env.toml +++ b/src/agent_env/browsers/envs/chromium/env.toml @@ -2,3 +2,11 @@ type = "computer_app" module = "chromium.py:Chromium" computer = "ubuntu-24.04" driver = "reference" + +[settings] +start_urls = ["about:blank"] +window = { width = 1280, height = 800 } +locale = "en-US" +time_zone = "UTC" +geolocation = "deny" +notifications = "deny" diff --git a/src/agent_env/env/envs/computer/app_env.py b/src/agent_env/env/envs/computer/app_env.py index af60f796c..d57d0ba5a 100644 --- a/src/agent_env/env/envs/computer/app_env.py +++ b/src/agent_env/env/envs/computer/app_env.py @@ -1,5 +1,6 @@ """Computer app env: software on a computer, a ``ComputerAppEnvironment`` subclass, with its files, what it declares, -read from the class at put, and the values of its build settings the env fixes in the image. Deployed alone it is a +read from the class at put, the values of its build settings the env fixes in the image, and the defaults for its +settings each deploy starts from. Deployed alone it is a one-app computer env: the computer and driver its env.toml names, composed with the app installed under its prefix, its card named after it.""" @@ -37,12 +38,13 @@ class ComputerAppEnv(ComputerEnv): def __init__(self, id: str, version: Optional[int], environment_name: str, module: str, files: FileArtifactUniverse, declaration: AppDeclaration, computer: Optional[ComputerArtifact] = None, driver: Optional[ComputerDriverEnv] = None, build_settings: Optional[dict[str, Any]] = None, - metadata: Optional[dict[str, Any]] = None): + settings: Optional[dict[str, Any]] = None, metadata: Optional[dict[str, Any]] = None): super().__init__(id, version, environment_name, computer, driver, metadata=metadata) self.module = module self.files = files self.declaration = declaration self.build_settings = build_settings or {} + self.settings = settings or {} @property def install(self) -> dict[str, AppInstall]: @@ -83,7 +85,8 @@ def to_dict(self) -> dict: "declaration": self.declaration.model_dump(mode="json", exclude_none=True), "computer": {"id": self.computer.id, "version": self.computer.version} if self.computer else None, "driver": {"id": self.driver.id, "version": self.driver.version} if self.driver else None, - **({"build_settings": self.build_settings} if self.build_settings else {})} + **({"build_settings": self.build_settings} if self.build_settings else {}), + **({"settings": self.settings} if self.settings else {})} @classmethod def from_dict(cls, data: dict) -> ComputerAppEnv: @@ -94,23 +97,28 @@ def from_dict(cls, data: dict) -> ComputerAppEnv: return cls(id=data["id"], version=data.get("version"), environment_name=data["environment_name"], module=data["module"], files=Artifact.get(data["files"]["id"], version=data["files"]["version"]), declaration=AppDeclaration.model_validate(data["declaration"]), computer=computer, driver=driver, - build_settings=data.get("build_settings"), metadata=data.get("metadata") or {}) + build_settings=data.get("build_settings"), settings=data.get("settings"), + metadata=data.get("metadata") or {}) @classmethod def put(cls, *, id: str, environment_name: str, module: str, files: FileArtifactUniverse, declaration: AppDeclaration, computer: Optional[ComputerArtifact] = None, driver: Optional[ComputerDriverEnv] = None, build_settings: Optional[dict[str, Any]] = None, - compose: bool = True, composing: Composing = Composing()) -> Self: - """Write the app once what it declares and ``build_settings``, its build settings' values, check out, and with - a computer and a driver, once it fits them (gate 1); then compose it with them as ``composing`` says unless - ``compose`` is False. Refused, naming every problem, before anything is stored.""" + settings: Optional[dict[str, Any]] = None, compose: bool = True, + composing: Composing = Composing()) -> Self: + """Write the app once what it declares, ``build_settings``, its build settings' values, and ``settings``, + defaults for some of its settings, check out, and with a computer and a driver, once it fits them (gate 1); then + compose it with them as ``composing`` says unless ``compose`` is False. Refused, naming every problem, before + anything is stored.""" app = cls(id=id, version=None, environment_name=environment_name, module=module, files=files, - declaration=declaration, computer=computer, driver=driver, build_settings=build_settings) + declaration=declaration, computer=computer, driver=driver, build_settings=build_settings, + settings=settings) + os_type = computer.os.type if computer is not None else None _, problems = _installs(declaration) problems += _class_beyond_reach(module, declaration) problems += _in_server_folder(declaration, files.get_file_artifacts()) - problems += gate.check_app_build_settings(app, app.build_settings, "build_settings", - computer.os.type if computer is not None else None) + problems += gate.check_app_build_settings(app, app.build_settings, "build_settings", os_type) + problems += gate.check_app_settings(app, app.settings, "settings", os_type, partial=True) if (computer is None) != (driver is None): problems.append("a computer app deploys alone on a computer and a driver; name both or neither") elif computer is not None and not problems: @@ -127,8 +135,8 @@ def put(cls, *, id: str, environment_name: str, module: str, files: FileArtifact @classmethod def from_toml(cls, data: dict, ctx: AuthoringContext) -> ComputerAppEnv: """Write the app an env.toml declares, its class read in a process of its own (``declared``), its files stored - as a file universe, with the computer and driver and the build settings' values env.toml names; its - environment_name the folder's when it sets none.""" + as a file universe, with the computer and driver, the build settings' values and the default settings env.toml + names; its environment_name the folder's when it sets none.""" fields = cls.accept_toml(data, ctx) declaration = AppDeclaration.model_validate(declared(ctx, fields["module"], cls.type)) files = ctx.files() @@ -147,7 +155,8 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> ComputerAppEnv: try: return cls.put(id=ctx.id, environment_name=fields["environment_name"], module=fields["module"], files=universe, declaration=declaration, computer=computer, driver=driver, - build_settings=fields.get("build_settings"), composing=ctx.composing) + build_settings=fields.get("build_settings"), settings=fields.get("settings"), + composing=ctx.composing) except ValueError as e: ctx.refuse([str(e)]) @@ -155,10 +164,10 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> ComputerAppEnv: def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: """The keys of ``data``, an env.toml, a computer app env takes: ``module = ":"``, naming a file of its folder; ``environment_name``, the folder's when it sets none; the ``computer`` and ``driver`` it deploys - alone on; and ``build_settings``, the values of its build settings. Raises BundleError listing every - problem.""" + alone on; ``build_settings``, the values of its build settings; and ``settings``, defaults for some of its + settings that a deploy's ``env_params`` override field by field. Raises BundleError listing every problem.""" fields, refused = ctx.accepted(data, environment_name=str, module=str, computer=object, driver=object, - build_settings=dict) + build_settings=dict, settings=dict) files, module, problems = ctx.files(), fields.get("module"), [] if module is None and "module" not in data: problems.append('module is required: the app\'s file and class, such as "app.py:MailApp"') diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index a92ccd9e7..279a5ad73 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -53,17 +53,19 @@ class ComputerEnv(Env): env_provider_type: ClassVar[str] = "computer" toml_refs: ClassVar[tuple[EntityRef, ...]] = (EntityRef.artifact("computer", artifact_type="computer"), EntityRef.env("driver", env_type=ComputerDriverEnv.type), - EntityRef.env("apps[]", env_type=APP_TYPE, beside=("install",))) + EntityRef.env("apps[]", env_type=APP_TYPE, + beside=("install", "settings"))) def __init__(self, id: str, version: Optional[int], environment_name: str, computer: ComputerArtifact, driver: ComputerDriverEnv, display: Optional[dict] = None, network: Optional[dict] = None, computer_settings: Optional[dict[str, SettingValue]] = None, metadata: Optional[dict[str, Any]] = None, - apps: Optional[list[ComputerAppEnv]] = None): + apps: Optional[list[ComputerAppEnv]] = None, app_settings: Optional[dict[str, dict]] = None): super().__init__(id, version, metadata=metadata) self.environment_name = environment_name self.computer, self.driver, self.apps = computer, driver, apps or [] self.display, self.network = display, network self.computer_settings = computer_settings or {} + self.app_settings = app_settings or {} self._sandbox = None self._env_provider: Optional[EnvironmentProvider] = builtin_provider_for(self.env_provider_type) self._gateway_url = None @@ -76,35 +78,47 @@ def to_dict(self) -> dict: "computer": {"id": self.computer.id, "version": self.computer.version}, "driver": {"id": self.driver.id, "version": self.driver.version}, "display": self.display, "network": self.network, "computer_settings": self.computer_settings, - **({"apps": [{"id": app.id, "version": app.version} for app in self.apps]} if self.apps else {})} + **({"apps": [{"id": app.id, "version": app.version, + **({"settings": own} if (own := self.app_settings.get(app.environment_name)) else {})} + for app in self.apps]} if self.apps else {})} @classmethod def from_dict(cls, data: dict) -> ComputerEnv: driver = Env.get(data["driver"]["id"], data["driver"]["version"]) if not isinstance(driver, ComputerDriverEnv): raise TypeError(f"{data['driver']['id']} is a {driver.type} env, not a computer_driver env") + refs = data.get("apps") or [] + apps = [_app(Env.get(ref["id"], ref["version"])) for ref in refs] return cls(id=data["id"], version=data.get("version"), environment_name=data["environment_name"] if "environment_name" in data else data["service_name"], computer=ComputerArtifact.get(data["computer"]["id"], data["computer"]["version"]), driver=driver, display=data.get("display"), network=data.get("network"), - computer_settings=data.get("computer_settings"), metadata=data.get("metadata") or {}, - apps=[_app(Env.get(ref["id"], ref["version"])) for ref in data.get("apps") or []]) + computer_settings=data.get("computer_settings"), metadata=data.get("metadata") or {}, apps=apps, + app_settings={app.environment_name: ref["settings"] for app, ref in zip(apps, refs) + if ref.get("settings")}) @classmethod def put(cls, *, id: str, computer: ComputerArtifact, driver: ComputerDriverEnv, environment_name: str, apps: Any = (), display: Any = None, network: Any = None, computer_settings: Any = None, - compose: bool = True, composing: Composing = Composing()) -> Self: + app_settings: Optional[dict[str, dict]] = None, compose: bool = True, + composing: Composing = Composing()) -> Self: """Write a computer env of ``computer``, ``driver`` and ``apps``, computer app envs built into it in their - order, once gate 1 passes, then compose it as ``composing`` says unless ``compose`` is False. Refused, naming - every mismatch, before anything is stored.""" - apps = list(apps) + order, with ``app_settings``, defaults for some of their settings by app name, once gate 1 passes, then compose + it as ``composing`` says unless ``compose`` is False. Refused, naming every mismatch, before anything is + stored.""" + apps, app_settings = list(apps), app_settings or {} values, problems = gate.check(computer, driver, environment_name=environment_name, apps=apps, display=display, network=network, computer_settings=computer_settings or {}) + by_name = {app.environment_name: app for app in apps} + for name, defaults in app_settings.items(): + problems += ([f"apps.{name}.settings: {id} has no app {name!r}"] if name not in by_name else + gate.check_app_settings(by_name[name], defaults, f"apps.{name}.settings", computer.os.type, + partial=True)) if problems: raise ValueError(f"computer env {id!r}: " + "; ".join(problems)) env = get_env_store().put_document(cls( id=id, version=None, environment_name=environment_name, computer=computer, driver=driver, apps=apps, - display=display, network=network, computer_settings=values)) + display=display, network=network, computer_settings=values, app_settings=app_settings)) if compose: composer.compose(env, composing=composing) return env @@ -117,7 +131,7 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> ComputerEnv: fields = cls.accept_toml(data, ctx) base, driver = ctx.artifact(fields.pop("computer"), ComputerArtifact), ctx.env(fields.pop("driver"), ComputerDriverEnv) - fields["apps"] = _listed_apps(fields.get("apps", []), ctx) + fields["apps"], fields["app_settings"] = _listed_apps(fields.get("apps", []), ctx) try: return cls.put(id=ctx.id, computer=base, driver=driver, composing=ctx.composing, **fields) except ValueError as e: @@ -140,6 +154,12 @@ 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 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.""" + return {app.environment_name: {**app.settings, **self.app_settings.get(app.environment_name, {})} + for app in self.composed_apps()} + def computer_artifact(self) -> ComputerArtifact: """The env's computer: its base composed with its driver, over the images built.""" return ComputerArtifact.get(self.metadata["computer"]["id"], self.metadata["computer"]["version"]) @@ -296,23 +316,32 @@ def _app(env: Env) -> ComputerAppEnv: return env -def _listed_apps(refs: list, ctx: AuthoringContext) -> list[ComputerAppEnv]: - """The computer app envs an env.toml's ``apps`` names, each a ref, or a table of one with its ``install`` beside it; - refused, naming each problem, unless every one is a computer app env installed at build.""" - apps, problems = [], [] +def _listed_apps(refs: list, ctx: AuthoringContext) -> tuple[list[ComputerAppEnv], dict[str, dict]]: + """The computer app envs an env.toml's ``apps`` names, each a ref, or a table of one with its ``install`` and its + ``settings``, defaults for some of its settings, beside it, and those defaults by app name; refused, naming each + problem, unless every one is a computer app env installed at build.""" + apps, app_settings, problems = [], {}, [] for i, ref in enumerate(refs): install = ref.get("install", "build") if isinstance(ref, dict) else "build" + settings = ref.get("settings", {}) if isinstance(ref, dict) else {} if install != "build": problems.append(ctx.config_problem(f"apps[{i}]: install = {install!r}: an app installs at \"build\", into " "the computer's image; \"boot\" isn't supported yet")) continue - app = ctx.env({key: value for key, value in ref.items() if key != "install"} if isinstance(ref, dict) else ref) + if not isinstance(settings, dict): + problems.append(ctx.config_problem(f"apps[{i}]: settings must be a table of the app's setting values, not " + f"{settings!r}")) + continue + app = ctx.env({key: value for key, value in ref.items() if key not in ("install", "settings")} + if isinstance(ref, dict) else ref) if refusal := _not_an_app(app): problems.append(ctx.config_problem(f"apps[{i}]: {refusal}")) + elif settings: + app_settings[app.environment_name] = settings apps.append(app) if problems: ctx.refuse(problems) - return apps + return apps, app_settings def _not_an_app(env: Env) -> str | None: diff --git a/src/agent_env/env/envs/computer/utils/boot.py b/src/agent_env/env/envs/computer/utils/boot.py index daa8fd269..c03585b51 100644 --- a/src/agent_env/env/envs/computer/utils/boot.py +++ b/src/agent_env/env/envs/computer/utils/boot.py @@ -84,6 +84,15 @@ def app_settings_environment(apps: list[ComputerAppEnv], app_settings: dict[str, for name, value in values.items()} for app, values in given})} +def recorded_app_settings(apps: list[ComputerAppEnv], app_settings: dict[str, dict], + os_type: str) -> dict[str, dict[str, Any]]: + """The values ``app_settings`` hands each of ``apps`` on a computer of ``os_type``, by its name, as a record keeps + them, a secret's withheld; its class's defaults fill in the rest.""" + return {app.environment_name: {name: WITHHELD if name in app.secret_settings(os_type) else value + for name, value in values.items()} + for app in apps if (values := app_settings.get(app.environment_name))} + + def recorded_settings(computer: ComputerArtifact, values: dict[str, Any]) -> dict[str, Any]: """The deploy-phase settings a boot applies as a record keeps them, a secret's and a generated one's withheld.""" return {name: WITHHELD if setting.secret or setting.generate else values.get(name, setting.default) diff --git a/src/agent_env/env/envs/computer/utils/composer.py b/src/agent_env/env/envs/computer/utils/composer.py index 195e2c9d6..c256c287e 100644 --- a/src/agent_env/env/envs/computer/utils/composer.py +++ b/src/agent_env/env/envs/computer/utils/composer.py @@ -182,11 +182,12 @@ def _probed(env: ComputerEnv, driver: ComputerDriverEnv, form: str, arch: str, r async def _probe(links: list[SandboxProvider], env: ComputerEnv, driver: ComputerDriverEnv, image: str) -> tuple[ProbeReport, dict, str]: - """``image`` booted at the env's settings with a control token of its own, as a deploy boots it, on the first of - ``links`` that boots it, probed at ``full`` with that token within ``_PROBE_TIMEOUT_S`` and torn down; the report, - the driver's card and the provider's name.""" + """``image`` booted at the env's settings and its apps' defaults with a control token of its own, as a deploy with + no ``env_params`` boots it, on the first of ``links`` that boots it, probed at ``full`` with that token within + ``_PROBE_TIMEOUT_S`` and torn down; the report, the driver's card and the provider's name.""" token = boot.control_token() - environment = boot.settings_environment(env.computer, _values(env), environment_name=env.environment_name) + environment = {**boot.settings_environment(env.computer, _values(env), environment_name=env.environment_name), + **boot.app_settings_environment(env.composed_apps(), env.app_defaults(), env.computer.os.type)} problems = [] for link in links: try: diff --git a/src/agent_env/env/envs/computer/utils/gate.py b/src/agent_env/env/envs/computer/utils/gate.py index b0b0a3126..056d78f19 100644 --- a/src/agent_env/env/envs/computer/utils/gate.py +++ b/src/agent_env/env/envs/computer/utils/gate.py @@ -136,13 +136,15 @@ def _driver_ports(driver: ComputerDriverEnv, os_type: str) -> list[int]: return [driver.port, *(install.ports or [] if install is not None else [])] -def check_app_settings(app: ComputerAppEnv, values: dict[str, Any], at: str, os_type: str) -> list[str]: +def check_app_settings(app: ComputerAppEnv, values: dict[str, Any], at: str, os_type: str | None, + partial: bool = False) -> list[str]: """The problems with ``values``, of ``app``'s settings on ``os_type`` by name, each named from ``at``: a setting it doesn't declare, a secret's given as anything but a bare reference (or null, where it's optional), a required one - missing, and what the schema of its ``Settings`` refuses of the rest. A secret's reference never meets the - constraints on its value, and its value never reaches a message.""" + missing unless ``values`` are ``partial``, defaults a deploy completes, and what the schema of its ``Settings`` + refuses of the rest. A secret's reference never meets the constraints on its value, and its value never reaches a + message.""" schema, secrets = app.settings_schema(os_type), app.secret_settings(os_type) - properties, required = schema.get("properties", {}), schema.get("required", []) + properties, required = schema.get("properties", {}), [] if partial else schema.get("required", []) build = (app.build_settings_schema(os_type) or {}).get("properties", {}) problems = [f"{at}.{name} is one of {app.id}'s build settings, which only another image changes" if name in build else f"{at}.{name}: {app.id} declares no setting {name!r}" for name in values if name not in properties] diff --git a/src/agent_env/env/envs/computer/utils/params.py b/src/agent_env/env/envs/computer/utils/params.py index 3e765cf57..c675a6364 100644 --- a/src/agent_env/env/envs/computer/utils/params.py +++ b/src/agent_env/env/envs/computer/utils/params.py @@ -52,11 +52,17 @@ def checked(env: ComputerEnv, env_params: Optional[dict]) -> tuple[ComputerParam apps = {app.environment_name: app for app in env.composed_apps()} problems += [f"env_params.app_settings.{name}: {env.id} has no app {name!r}" + (f"; its apps are {', '.join(apps)}" if apps else "") for name in params.app_settings if name not in apps] + merged = app_settings(env, params.app_settings) for name, app in apps.items(): - problems += gate.check_app_settings(app, params.app_settings.get(name, {}), f"env_params.app_settings.{name}", - env.computer.os.type) + problems += gate.check_app_settings(app, merged[name], f"env_params.app_settings.{name}", env.computer.os.type) return params, values, problems +def app_settings(env: ComputerEnv, given: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + """The settings each of ``env``'s apps deploys with, by name: the env's defaults for it under ``given``, a deploy's + ``app_settings``, field by field.""" + return {name: {**defaults, **given.get(name, {})} for name, defaults in env.app_defaults().items()} + + def _problems(at: str, error: ValidationError) -> list[str]: return [f"{at}.{'.'.join(map(str, problem['loc']))}: {problem['msg']}" for problem in error.errors()] 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 3db560b1b..bd7ca5c34 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -22,6 +22,7 @@ discard, ready, ready_timeout, + recorded_app_settings, recorded_settings, settings_environment, ) @@ -94,6 +95,8 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par form, arch, build = native[0] computer, name, token = env.computer_artifact(), env.environment_name, control_token() values = _values(computer, values) + app_settings = recorded_app_settings(env.composed_apps(), computer_params.app_settings(env, params.app_settings), + env.computer.os.type) policy = NetworkPolicy.from_dict(env.network) if env.network else None booted = await boot( sandbox_provider, image=build["image"], port=env.driver.port, network_policy=policy, token=token, @@ -115,6 +118,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), + **({"app_settings": app_settings} if app_settings else {}), "env_params": params.model_dump(exclude_defaults=True)}}, ) await _require_display(record, values.get("resolution")) @@ -167,7 +171,8 @@ def _environment(env: ComputerEnv, computer: ComputerArtifact, values: dict, params: computer_params.ComputerParams) -> dict[str, str]: return {**settings_environment(computer, values, environment_name=env.environment_name, driver_settings=params.driver_settings), - **app_settings_environment(env.composed_apps(), params.app_settings, env.computer.os.type)} + **app_settings_environment(env.composed_apps(), computer_params.app_settings(env, params.app_settings), + env.computer.os.type)} def _require_name(env: ComputerEnv, card: dict) -> None: diff --git a/tst/integration/computer/chromium/envs/desktop-fr/env.toml b/tst/integration/computer/chromium/envs/desktop-fr/env.toml new file mode 100644 index 000000000..10ebaeaf1 --- /dev/null +++ b/tst/integration/computer/chromium/envs/desktop-fr/env.toml @@ -0,0 +1,4 @@ +type = "computer" +computer = "ubuntu-24.04" +driver = "reference" +apps = [{ env = "chromium", settings = { locale = "fr-FR", time_zone = "Europe/Paris" } }] diff --git a/tst/integration/computer/chromium/tasks/desktop-fr.json b/tst/integration/computer/chromium/tasks/desktop-fr.json new file mode 100644 index 000000000..a6c0ce985 --- /dev/null +++ b/tst/integration/computer/chromium/tasks/desktop-fr.json @@ -0,0 +1,3 @@ +[ + {"id": "env", "type": "deploy_env", "env_id": "desktop-fr"} +] diff --git a/tst/integration/computer/chromium_app_test.py b/tst/integration/computer/chromium_app_test.py index 59bee68d4..f0e121f60 100644 --- a/tst/integration/computer/chromium_app_test.py +++ b/tst/integration/computer/chromium_app_test.py @@ -1,8 +1,12 @@ -"""agentenv's chromium app on the ubuntu-24.04 base, as the chromium test bundle's desktop lists it: gate 3 makes this +"""agentenv's chromium app on the ubuntu-24.04 base, as the chromium test bundle's desktops list it: gate 3 makes this 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. Needs -Docker and the local registry on localhost:5000.""" +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 +desktop that gives it French defaults deploys with them, a deploy's time zone over them, each recorded. Needs Docker and +the local registry on localhost:5000.""" import asyncio import json @@ -16,6 +20,8 @@ from agentenv_protocol import client as protocol_v1 from agent_env.env import Env +from agentenv_protocol.types import WELL_KNOWN_PATH +from tst.integration.computer.website_browser_test import _app, _browser_process, _keys, _ready 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 @@ -26,6 +32,28 @@ ARCH = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}[platform.machine().lower()] ALPHA = "data:text/html,Alpha page

Alpha

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

Bravo

" +LOOPBACK = "http://127.0.0.1:8766/" +# What chromium's menu launcher runs. +LAUNCHER = ("/opt/agentenv/apps/chromium/venv/bin/python -m agentenv_protocol.computer.launch " + "/opt/agentenv/apps/chromium") +# Serves, on loopback for the seconds it's given, a page whose title is its location and notification permissions. +SERVE_PERMISSIONS = """import http.server, sys, threading, time +class Page(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = (b"asking") + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *args): + pass +server = http.server.ThreadingHTTPServer(("127.0.0.1", 8766), Page) +threading.Thread(target=server.serve_forever, daemon=True).start() +time.sleep(float(sys.argv[1])) +""" # Each window the window manager manages, its class and its title. WINDOWS = ('for w in $(xprop -root _NET_CLIENT_LIST | sed "s/.*# //; s/,//g"); do ' 'xprop -id "$w" WM_CLASS _NET_WM_NAME; done') @@ -46,11 +74,7 @@ async def test_a_deploy_opens_its_start_urls_unprompted_and_a_reset_clears_its_p context = TaskStepContext(deployed_envs=[deployed]) try: opened = await _chromium_windows(deployed, "Alpha page") - async with streamablehttp_client(f"{deployed.environment_url}/mcp") as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - keyed = await session.call_tool("computer_key", {"keys": ["Control", "Tab"]}) - tabbed = await _chromium_windows(deployed, "Bravo page") + keyed, tabbed = await _tab_to(deployed, "Bravo page") policies = await inside.run( deployed, ["cat", "/etc/opt/chrome_for_testing/policies/managed/agentenv.json"], timeout_s=30) await inside.run(deployed, ["sh", "-c", "touch ~/.config/chromium/marker"], timeout_s=30) @@ -68,13 +92,86 @@ async def test_a_deploy_opens_its_start_urls_unprompted_and_a_reset_clears_its_p assert not report.failed -async def _chromium_windows(deployed, title: str) -> list[str]: - """The titles of the computer's Chromium windows once one of them starts with ``title``.""" - for _ in range(60): +@pytest.mark.asyncio +async def test_it_runs_chrome_itself_on_its_pipe_answers_prompts_as_the_deploy_says_and_a_launch_opens_a_window( + chromium_desktop): + settings = {"start_urls": [LOOPBACK], "notifications": "ask"} + with namespace_routing(): + deployed = await Env.get(chromium_desktop.id).deploy(env_params={"app_settings": {"chromium": settings}}) + context = TaskStepContext(deployed_envs=[deployed]) + try: + browser = await _browser_process(deployed) + serving = asyncio.ensure_future(inside.run(deployed, ["python3", "-c", SERVE_PERMISSIONS, "60"], + timeout_s=90)) + await asyncio.sleep(3) + await _keys(deployed, ["F5"]) + permissions = await _chromium_windows(deployed, "denied") + await serving + await inside.run(deployed, ["sh", "-c", "mkdir -p ~/.cache/chromium && touch ~/.cache/chromium/kept"], + timeout_s=30) + launched = await inside.run(deployed, ["sh", "-c", LAUNCHER], timeout_s=60) + windows = await _chromium_windows(deployed, "denied", count=2) + after = await _browser_process(deployed) + kept = await inside.run(deployed, ["sh", "-c", "test -e ~/.cache/chromium/kept"], timeout_s=30) + readiness = await _ready(deployed, await _app(deployed, WELL_KNOWN_PATH, "chromium"), "chromium") + finally: + report = await teardown_run(context) + + 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:] == [LOOPBACK] + 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"] + assert kept["exit_code"] == 0 and readiness["ready"] is True, readiness + recorded = deployed.metadata["computer"]["app_settings"]["chromium"] + assert (recorded["geolocation"], recorded["notifications"], recorded["start_urls"]) == ("deny", "ask", [LOOPBACK]) + 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(): + french = Env.get(chromium_desktop.id.rsplit("/", 1)[0] + "/desktop-fr") + deployed = await french.deploy(env_params={"app_settings": {"chromium": {"time_zone": "Asia/Tokyo"}}}) + context = TaskStepContext(deployed_envs=[deployed]) + try: + browser = await _browser_process(deployed) + finally: + report = await teardown_run(context) + + assert "--lang=fr-FR" in browser["argv"] + recorded = deployed.metadata["computer"]["app_settings"]["chromium"] + assert (recorded["locale"], recorded["time_zone"]) == ("fr-FR", "Asia/Tokyo") + assert deployed.metadata["computer"]["env_params"] == {"app_settings": {"chromium": {"time_zone": "Asia/Tokyo"}}} + assert not report.failed + + +async def _tab_to(deployed, title: str): + """Control+Tab pressed as an agent presses it, again while the window still shows the tab before, which a press + can reach before the next tab opens; the last press's result, and the window titles once one starts with + ``title``.""" + async with streamablehttp_client(f"{deployed.environment_url}/mcp") as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + for attempt in range(3): + keyed = await session.call_tool("computer_key", {"keys": ["Control", "Tab"]}) + try: + return keyed, await _chromium_windows(deployed, title, seconds=20) + except TimeoutError: + if attempt == 2: + raise + + +async def _chromium_windows(deployed, title: str, seconds: int = 60, count: int = 1) -> list[str]: + """The titles of the computer's Chromium windows once there are ``count`` and one starts with ``title``.""" + for _ in range(seconds): listed = (await inside.run(deployed, ["sh", "-c", WINDOWS], timeout_s=30))["stdout"] classes = re.findall(r'WM_CLASS\(STRING\) = .*"([^"]*)"\n_NET_WM_NAME\(UTF8_STRING\) = "(.*)"', listed) titles = [name for cls, name in classes if cls == "Chromium"] - if any(name.startswith(title) for name in titles): + if any(name.startswith(title) for name in titles) and len(titles) >= count: return titles await asyncio.sleep(1) raise TimeoutError(f"no Chromium window titled {title!r}: {listed}") diff --git a/tst/integration/computer/website-browser/envs/browser/browser.py b/tst/integration/computer/website-browser/envs/browser/browser.py index 0f74539ea..a83d362b1 100644 --- a/tst/integration/computer/website-browser/envs/browser/browser.py +++ b/tst/integration/computer/website-browser/envs/browser/browser.py @@ -26,7 +26,7 @@ @computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) @linux(packages=Packages(apt=PACKAGES), downloads=DOWNLOADS, paths=[PROFILE], - launch=Launch(command=["chromium/chrome/chrome", "--no-sandbox", "--disable-dev-shm-usage", "--class=Chromium"], + launch=Launch(command=["chromium/chrome/chrome", "--no-sandbox", "--class=Chromium"], window={"class": "Chromium"}, ready=Ready(window=True))) class FixtureBrowser(WebsiteBrowserEnvironment): version = VERSION diff --git a/tst/integration/computer/website_browser_test.py b/tst/integration/computer/website_browser_test.py index 303e204b3..9f22a7c58 100644 --- a/tst/integration/computer/website_browser_test.py +++ b/tst/integration/computer/website_browser_test.py @@ -3,8 +3,8 @@ of; deployed, Chromium runs with agentenv's flags after the fixture's command, on the deploy's locale, through no proxy, with agentenv's policies where its build reads them and with no debugging port anything in the computer could find; its first start tab sees the deploy's time zone, and a page on loopback loads; its card declares browser/v1; and a reset -relaunches it on its profile template, on a new pipe its ready check answers over. Needs Docker and the local registry -on localhost:5000.""" +relaunches it on its profile template, on a new pipe its ready check answers over. Its computer's /dev/shm is Docker's +64 MiB, so it keeps its shared memory in files. Needs Docker and the local registry on localhost:5000.""" import asyncio import json @@ -117,11 +117,12 @@ async def test_chromium_runs_with_agentenvs_flags_on_its_pipe_and_a_reset_relaun report = await teardown_run(context) argv = browser["argv"] - assert argv[0].endswith("chromium/chrome/chrome") and argv[1:4] == ["--no-sandbox", "--disable-dev-shm-usage", - "--class=Chromium"] - assert argv[4].startswith("--user-data-dir=") and argv[4].endswith("/.config/chromium") - assert argv[5:9] == ["--remote-debugging-pipe", "--no-first-run", "--no-default-browser-check", "--lang=fr-FR"] - assert argv[9:] == [TIME_ZONE_PAGE, LOOPBACK] + assert argv[0].endswith("chromium/chrome/chrome") and argv[1:3] == ["--no-sandbox", "--class=Chromium"] + assert argv[3].startswith("--user-data-dir=") and argv[3].endswith("/.config/chromium") + assert argv[4].startswith("--disk-cache-dir=") and argv[4].endswith("/.cache/chromium") + assert argv[5:10] == ["--remote-debugging-pipe", "--no-first-run", "--no-default-browser-check", "--lang=fr-FR", + "--disable-dev-shm-usage"] + assert argv[10:] == [TIME_ZONE_PAGE, LOOPBACK] assert browser["listening"] == 0 and port_file["exit_code"] != 0 assert zone.startswith("Europe/Paris") and loopback.startswith("Loopback page") assert {"BrowserSignin": 0, "DownloadDirectory": "${user_home}/Downloads"}.items() <= json.loads( @@ -165,17 +166,17 @@ async def _browser_process(deployed, other_than: int | None = None) -> dict: raise TimeoutError(f"no Chromium on --remote-debugging-pipe other than {other_than}: {found}") -async def _app(deployed, path: str) -> dict: +async def _app(deployed, path: str, app: str = "browser") -> dict: async with httpx.AsyncClient(timeout=30) as http: - response = await http.get(f"{deployed.environment_url}/svc/browser{path}", headers=deployed.control_headers) + response = await http.get(f"{deployed.environment_url}/svc/{app}{path}", headers=deployed.control_headers) response.raise_for_status() return response.json() -async def _ready(deployed, card: dict) -> dict: +async def _ready(deployed, card: dict, app: str = "browser") -> dict: """The app's readiness once it says it's ready, or as it last said within a minute.""" for _ in range(60): - readiness = await _app(deployed, apps.endpoint(card, "ready")) + readiness = await _app(deployed, apps.endpoint(card, "ready"), app) if readiness["ready"]: return readiness await asyncio.sleep(1) diff --git a/tst/unit/bundle/computer_app_env_test.py b/tst/unit/bundle/computer_app_env_test.py index 8457d5c23..c1b567348 100644 --- a/tst/unit/bundle/computer_app_env_test.py +++ b/tst/unit/bundle/computer_app_env_test.py @@ -100,6 +100,16 @@ async def test_an_app_folder_is_written_with_its_files_and_what_its_class_declar await env.deploy() +def test_an_app_folders_settings_table_is_stored_as_the_defaults_each_deploy_starts_from(bundle_dir): + _bundle(bundle_dir, toml=f'{TOML}\n[settings]\nsignature = "-- Ada"\npassword = "env:MAIL_PASSWORD"\n') + + materialize(plan_of(bundle_dir)) + env = Env.get(f"{ROOT}/mail") + + assert env.settings == {"signature": "-- Ada", "password": "env:MAIL_PASSWORD"} + assert env.app_defaults() == {"mail": env.settings} + + @pytest.mark.parametrize("toml, extra, skill, problem", [ ('type = "computer_app"\n', {}, "skill.md", "module is required"), ('type = "computer_app"\nmodule = "app.py:Desk"\n', {"envs/mail/app.py": DRIVER.format(supports=LINUX, @@ -112,7 +122,10 @@ async def test_an_app_folder_is_written_with_its_files_and_what_its_class_declar (f'{TOML}image = "x"\n', {}, "skill.md", "unknown key 'image'"), (f'{TOML}build_settings = {{ colour = "blue" }}\n', {}, "skill.md", "build_settings.colour: @local/~/apps/mail declares no build setting 'colour'"), -], ids=["no-module", "a-driver", "install-python", "skill", "linux-skill", "unknown-key", "build-setting"]) + (f'{TOML}[settings]\ncolour = "blue"\n', {}, "skill.md", + "settings.colour: @local/~/apps/mail declares no setting 'colour'"), +], ids=["no-module", "a-driver", "install-python", "skill", "linux-skill", "unknown-key", "build-setting", + "setting"]) def test_an_app_folder_that_cant_be_read_is_refused_before_any_write(bundle_dir, toml, extra, skill, problem): _bundle(bundle_dir, toml=toml, skill=skill, **{path: data for path, data in extra.items() if data is not None}) for path in (path for path, data in extra.items() if data is None): diff --git a/tst/unit/bundle/computer_env_folder_test.py b/tst/unit/bundle/computer_env_folder_test.py index 33efd2499..bef522b39 100644 --- a/tst/unit/bundle/computer_env_folder_test.py +++ b/tst/unit/bundle/computer_env_folder_test.py @@ -145,6 +145,17 @@ def test_the_env_alone_is_planned_whatever_the_bundles_tasks_would_deploy(tmp_pa assert f"{resolved.bundle.id_root}/desktop" in {write.id for write in plan.writes} +def test_an_apps_settings_beside_it_are_stored_as_the_computer_envs_defaults_for_it(tmp_path, composed): + with_settings = WITH_APPS.replace('install = "build"', 'install = "build", settings = { signature = "-- Ada" }') + plan = _plan(_bundle(tmp_path / "desk", with_settings, APPS)) + + materialize(plan, build_here=local_builds(plan)) + + env = Env.get(f"{plan.bundle.bundle.id_root}/desktop") + assert env.app_settings == {"mail": {"signature": "-- Ada"}} + assert env.app_defaults() == {"notes": {}, "mail": {"signature": "-- Ada"}} + + def test_a_computer_env_folder_names_its_apps_in_order_each_written_before_it(tmp_path, composed): plan = _plan(_bundle(tmp_path / "desk", WITH_APPS, APPS)) @@ -193,7 +204,12 @@ def loading(root, *seeded): (WITH_APPS.replace('["notes", ', '["notes", "notes", '), "apps: @local/~/desk/notes is listed twice"), (WITH_APPS.replace('install = "build"', 'mode = "build"'), 'apps[1].env: expected an id or { env = "", version = ' ' }, the version optional'), -], ids=["no-computer", "no-driver", "unknown-key", "display", "boot", "twice", "key-beside"]) + (WITH_APPS.replace('install = "build"', 'settings = "dark"'), "apps[1]: settings must be a table of the app's " + "setting values, not 'dark'"), + (WITH_APPS.replace('install = "build"', 'settings = { colour = "blue" }'), "apps.mail.settings.colour: " + "@local/~/desk/mail declares no setting 'colour'"), +], ids=["no-computer", "no-driver", "unknown-key", "display", "boot", "twice", "key-beside", "settings-type", + "setting"]) def test_a_computer_env_folder_that_declares_what_cant_compose_is_refused_naming_the_folder(tmp_path, composed, toml, problem): with pytest.raises(BundleError) as refused: diff --git a/tst/unit/env/envs/_computer.py b/tst/unit/env/envs/_computer.py index 374d76484..298a90df9 100644 --- a/tst/unit/env/envs/_computer.py +++ b/tst/unit/env/envs/_computer.py @@ -325,11 +325,12 @@ async def create_vm(self, **size): def _app(base, driver, folder, compose=True, supports=None, install=None, settings=MAIL_SETTINGS, launch=None, - extra=None, steps=(), build=MAIL_BUILD, linux_settings=None, name="mail", paths=None, **changes): + extra=None, steps=(), build=MAIL_BUILD, linux_settings=None, name="mail", paths=None, defaults=None, + **changes): """A mail app on ``base`` and ``driver``, which serves IMAP from a venv of its requirements; ``extra`` files beside them, by name, ``steps``, its install steps by name, scope and the OS types they run on, ``build``, the schema of its build settings, ``linux_settings``, that of its settings on Linux, ``name``, its id's last part and its - environment name, and ``paths``, where it keeps its data on Linux.""" + environment name, ``paths``, where it keeps its data on Linux, and ``defaults``, its env's ``[settings]``.""" folder.mkdir(parents=True, exist_ok=True) (folder / "app.py").write_text("class Mail: ...\n") (folder / "requirements.txt").write_text("agentenv-framework-protocol\n") @@ -347,4 +348,5 @@ def _app(base, driver, folder, compose=True, supports=None, install=None, settin "python": "requirements.txt", "services": [{"name": "imap", "command": "venv/bin/python imap.py"}]}}}}) return ComputerAppEnv.put(**{"id": f"@local/~/kit/{name}", "environment_name": name, "module": "app.py:Mail", "files": files, "declaration": declaration, "computer": base, "driver": driver, - "compose": compose, **changes}) + "compose": compose, **({"settings": defaults} if defaults is not None else {}), + **changes}) diff --git a/tst/unit/env/envs/test_chromium_app.py b/tst/unit/env/envs/test_chromium_app.py index 99cdbb5a1..a2b052b1e 100644 --- a/tst/unit/env/envs/test_chromium_app.py +++ b/tst/unit/env/envs/test_chromium_app.py @@ -1,8 +1,6 @@ -"""agentenv's chromium app as a put reads it: the installed ``browsers`` bundle names it ``chromium``; its class declares -the pinned Chromium archive for each arch and its packages, its profile as its data, a launch matched by its window -class and started on its ``start_urls``, none by default, and its install step, which unpacks the archive keeping its -execute bits and writes the policies; and its command.""" -import json +"""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.""" import os import sys import zipfile @@ -11,6 +9,7 @@ class and started on its ``start_urls``, none by default, and its install step, from agentenv_protocol.computer._load import load_class from agentenv_protocol.computer.app import ComputerAppEnvironment +from agentenv_protocol.website_browser import BrowserSettings, WebsiteBrowserEnvironment from agent_env.bundle import parse_bundle from agent_env.bundle.installed import find_bundle @@ -34,23 +33,25 @@ def test_the_browsers_bundle_names_its_chromium_app_chromium(): assert parsed.aliases == {"envs": {"chromium": ("chromium",)}} -def test_chromium_declares_its_archives_profile_launch_start_urls_and_install_step(chromium): +def test_chromium_is_a_website_browser_that_declares_its_archives_profile_launch_and_install_step(chromium): declared = chromium.Chromium.declaration() linux = declared.os["linux"] + assert issubclass(chromium.Chromium, WebsiteBrowserEnvironment) assert [(d["arch"], d["to"]) for d in linux.install["downloads"]] == [("amd64", "chromium/chrome-amd64.zip"), ("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.launch.command == ["venv/bin/python", "chromium.py"] and linux.launch.at == "boot" - assert linux.launch.window == {"class": "Chromium"} and linux.launch.ready.window - assert [(step.name, step.scope, step.os) for step in declared.install_steps] == [("chromium", "system", ["linux"])] - assert declared.settings["properties"]["start_urls"]["default"] == [] + 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"), + ("write_policies", "system")} + assert declared.settings == BrowserSettings.model_json_schema() assert declared.supports[0]["arch"] == ["amd64", "arm64"] -def test_its_install_unpacks_the_archive_with_its_execute_bits_in_its_place_and_writes_the_policies(chromium, tmp_path): +def test_its_install_unpacks_the_archive_with_its_execute_bits_in_its_place(chromium, tmp_path): (tmp_path / "chromium").mkdir() with zipfile.ZipFile(tmp_path / "chromium" / "chrome-amd64.zip", "w") as archive: executable = zipfile.ZipInfo("chrome-linux64/chrome") @@ -58,22 +59,8 @@ def test_its_install_unpacks_the_archive_with_its_execute_bits_in_its_place_and_ archive.writestr(executable, "#!/bin/sh\n") archive.writestr("chrome-linux64/resources.pak", "data") - chromium.install_chromium(tmp_path, tmp_path / "policies") + chromium.unpack(tmp_path) assert sorted(p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("*") if p.is_file()) == [ - "chromium/chrome/chrome", "chromium/chrome/resources.pak", "policies/agentenv.json"] + "chromium/chrome/chrome", "chromium/chrome/resources.pak"] assert os.access(tmp_path / "chromium/chrome/chrome", os.X_OK) - policies = json.loads((tmp_path / "policies" / "agentenv.json").read_text()) - assert policies == chromium.POLICIES and policies["BrowserSignin"] == 0 - assert not policies["CommandLineFlagSecurityWarningsEnabled"] - - -def test_its_command_runs_unsandboxed_without_dev_shm_first_runs_or_proxy_and_opens_the_urls_in_order(chromium, - tmp_path): - command = chromium.chromium_command(tmp_path, home=tmp_path / "home", urls=["https://a.test/", "https://b.test/"]) - - assert command == [str(tmp_path / "chromium/chrome/chrome"), "--no-sandbox", "--disable-dev-shm-usage", - "--no-first-run", "--no-default-browser-check", "--class=Chromium", - f"--user-data-dir={tmp_path / 'home/.config/chromium'}", - f"--disk-cache-dir={tmp_path / 'home/.cache/chromium'}", "https://a.test/", "https://b.test/"] - assert not [arg for arg in command if "proxy" in arg] diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 9aaf728a2..8594ed34d 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -235,6 +235,27 @@ def _protocol_checkout(folder): return source +def test_gate_3_probes_a_build_with_its_apps_defaults_as_a_deploy_with_no_env_params_boots_it(base, driver, docker, + tmp_path, monkeypatch): + mail = _app(base, driver, tmp_path / "mail", compose=False, settings={**MAIL_SETTINGS, "required": ["signature"]}, + defaults={"signature": "-- Ada"}) + env = _put(base, driver, compose=False, apps=[mail], app_settings={"mail": {"theme": "dark"}}) + 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(composer, "run_probe", AsyncMock(return_value="report")) + + asyncio.run(_probe([SimpleNamespace()], env, driver, "img")) + + assert json.loads(booted[0]["environment"]["AGENTENV_APP_SETTINGS"]) == {"mail": {"signature": "-- Ada", + "theme": "dark"}} + + @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, @@ -1243,3 +1264,62 @@ async def test_app_settings_an_app_doesnt_declare_are_refused_at_save_and_at_dep await Env.get(app.id).deploy(env_params=env_params) assert len(saved) == 1 and problem in saved[0] and boots.boots == [] + + +@pytest.mark.asyncio +async def test_a_deploy_starts_from_its_app_envs_and_computer_envs_defaults_and_overrides_them_field_by_field( + base, driver, docker, boots, tmp_path, monkeypatch): + monkeypatch.setenv("MAIL_TOKEN", "t0ken") + boots.display = (1280, 800) + mail = _app(base, driver, tmp_path / "mail", defaults={"signature": "-- Ada", "theme": "dark"}) + env = _put(base, driver, apps=[mail], app_settings={"mail": {"theme": "light", "token": "env:MAIL_TOKEN"}}) + env_params = {"app_settings": {"mail": {"signature": "-- Bo"}}} + + deployed = await Env.get(env.id).deploy(env_params=env_params) + + assert json.loads(boots.boots[0]["environment"]["AGENTENV_APP_SETTINGS"]) == {"mail": { + "signature": "-- Bo", "theme": "light", "token": "t0ken"}} + assert deployed.metadata["computer"]["app_settings"] == {"mail": { + "signature": "-- Bo", "theme": "light", "token": ""}} + assert deployed.metadata["computer"]["env_params"] == env_params + + +def test_default_settings_are_stored_with_each_env_and_left_out_when_there_are_none(base, driver, docker, tmp_path): + mail = _app(base, driver, tmp_path / "mail", compose=False, defaults={"theme": "dark"}) + plain = _app(base, driver, tmp_path / "notes", compose=False, name="notes") + env = _put(base, driver, compose=False, apps=[mail, plain], app_settings={"mail": {"signature": "-- Ada"}}) + + stored = Env.get(env.id) + assert (Env.get(mail.id).settings, stored.app_settings) == ({"theme": "dark"}, {"mail": {"signature": "-- Ada"}}) + assert stored.to_dict()["apps"] == [{"id": mail.id, "version": mail.version, "settings": {"signature": "-- Ada"}}, + {"id": plain.id, "version": plain.version}] + assert "settings" not in plain.to_dict() + assert stored.app_defaults() == {"mail": {"theme": "dark", "signature": "-- Ada"}, "notes": {}} + + +@pytest.mark.parametrize("values, problem", [ + ({"colour": "blue"}, "settings.colour: @local/~/kit/mail declares no setting 'colour'"), + ({"theme": "blue"}, "settings.theme: 'blue' is not one of ['light', 'dark']"), + ({"password": "hunter2"}, "settings.password is a secret, so it takes a secret:KEY"), + ({"edition": "pro"}, "settings.edition is one of @local/~/kit/mail's build settings"), +], ids=["unknown", "enum", "secret-value", "build-setting"]) +def test_default_settings_an_app_refuses_are_refused_at_put_in_its_own_env_and_in_a_computer_env( + base, driver, docker, tmp_path, values, problem): + with pytest.raises(ValueError, match=re.escape(problem)): + _app(base, driver, tmp_path / "mail", compose=False, defaults=values) + mail = _app(base, driver, tmp_path / "mail", compose=False) + with pytest.raises(ValueError, match=re.escape(f"apps.mail.{problem}")): + _put(base, driver, compose=False, apps=[mail], app_settings={"mail": values}) + with pytest.raises(ValueError, match=re.escape("apps.notes.settings: @local/~/kit/desktop has no app 'notes'")): + _put(base, driver, compose=False, apps=[mail], app_settings={"notes": {}}) + + +def test_a_required_setting_may_be_left_to_each_deploy_and_one_an_env_gives_satisfies_it(base, driver, docker, + tmp_path): + settings = {**MAIL_SETTINGS, "required": ["signature"]} + left = _app(base, driver, tmp_path / "mail", settings=settings) + given = _app(base, driver, tmp_path / "signed", settings=settings, name="signed", defaults={"signature": "-- Ada"}) + + (missing,) = DeployEnvTaskStep(id="d", version=None, env_id=left.id).preflight() + assert "env_params.app_settings.mail: 'signature' is a required property" in missing + assert DeployEnvTaskStep(id="d", version=None, env_id=given.id).preflight() == []