From d86c1f609d9f15dce1d6449e852ecab93f5132fb Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 16:24:33 -0800 Subject: [PATCH 1/5] feat(computer): a chromium computer app, and Chromium's install for any app agentenv_protocol.website_browser holds Chromium's install for a computer: Chrome for Testing 156.0.8078.4, the build Playwright 1.64.1 pins, for amd64 and arm64, by sha256; the packages it needs; ChromiumInstall, whose install step unpacks it with unzip and writes managed policies where Chrome for Testing reads them (no sign-in, sync, first-run or promotional tabs, prompts, updates or warning bars); and chromium_command, unsandboxed, without /dev/shm and with no proxy. The new apps bundle ships chromium, a computer app on that install that opens at boot on its start_urls, its profile its data, so each deploy and each reset starts clean. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + packages/agentenv-protocol/README.md | 30 +++++++ .../website_browser/__init__.py | 7 ++ .../website_browser/chromium.py | 87 +++++++++++++++++++ .../tests/test_website_browser.py | 58 +++++++++++++ pyproject.toml | 1 + src/agent_env/apps/README.md | 16 ++++ src/agent_env/apps/aliases.toml | 3 + src/agent_env/apps/envs/chromium/chromium.py | 31 +++++++ src/agent_env/apps/envs/chromium/env.toml | 4 + .../computer/chromium/envs/desktop/env.toml | 4 + tst/integration/computer/chromium_app_test.py | 80 +++++++++++++++++ tst/integration/computer/conftest.py | 11 ++- tst/unit/bundle/installed_test.py | 4 +- tst/unit/env/envs/test_chromium_app.py | 38 ++++++++ 15 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website_browser/__init__.py create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website_browser/chromium.py create mode 100644 packages/agentenv-protocol/tests/test_website_browser.py create mode 100644 src/agent_env/apps/README.md create mode 100644 src/agent_env/apps/aliases.toml create mode 100644 src/agent_env/apps/envs/chromium/chromium.py create mode 100644 src/agent_env/apps/envs/chromium/env.toml create mode 100644 tst/integration/computer/chromium/envs/desktop/env.toml create mode 100644 tst/integration/computer/chromium_app_test.py create mode 100644 tst/unit/env/envs/test_chromium_app.py diff --git a/AGENTS.md b/AGENTS.md index 55aa1c75..5d49d5de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,7 @@ and deserialized through a registry. | `cli/` | Click CLI with the groups `a2a-agent`, `artifact`, `bundle`, `config`, `env`, `eval`, `plugin`, `run`, `task`, `up`. `task run`, `task run-batch` and `eval run` hand their runs to the configured `[runner]` and follow them. | | `agents/` | The `agents` bundle: the agents agent-env ships, one folder each under `agents/agents/`, with a smoke task each under `agents/tasks/`. Its Claude Code, `@local/agentenv-framework/agents/claude-code`, is the default agent. An agent's Python packages are its folder's `requirements.txt`, installed in its image, never in agent-env's own environment. A run that pulls one of its agents reads the whole bundle, so `tst/unit/bundle/shipped_bundles_test.py` parses and checks every shipped bundle. | | `computers/` | The `computers` bundle: the base computers agent-env ships as `computer` artifacts, `ubuntu-24.04` (XFCE on Xvfb, supervisord as the session user, settings as `AGENTENV_SETTING_`), aliased through `[artifacts]`. It has no tasks either. | +| `apps/` | The `apps` bundle: the computer apps agent-env ships as `computer_app` envs, today `chromium` (Chrome for Testing at the build Playwright pins, for amd64 and arm64, set up for unattended use by managed policies in `/etc/opt/chrome_for_testing/policies/managed`, opened at boot on its `start_urls`, its profile its data). Its install lives in `agentenv_protocol.website_browser` (`CHROMIUM_DOWNLOADS`, `CHROMIUM_PACKAGES`, `ChromiumInstall`, `chromium_command`), so an app that starts Chromium itself shares it; `tst/integration/computer/chromium_app_test.py` runs it. Like `drivers`, it has no tasks. | | `drivers/` | The `drivers` bundle: the computer drivers agent-env ships as `computer_driver` envs, the reference driver (X11 on Linux, recording through `x11grab`) aliased `reference`, and trycua's cua-driver aliased `cua-driver` (its daemon from the pinned PyPI wheel, its own instructions, tools and skill resources served to agents through `@server_instructions`, `forwarded_tools` and `@resource`, its tools beside `computer_*`, `computer/v1` on its desktop-scope calls, its skill pack leading the computer skill; `tst/integration/computer/cua_driver_test.py` runs it). It has no tasks: a bundle with neither tasks nor evals is refused unless its `aliases.toml` names something for other bundles to use. The shipped `desktop` example composes it with the `ubuntu-24.04` base into a computer env, which `tst/integration/computer/` deploys, probes, records and has an agent use. | | `examples/` | The example bundles agent-env ships (`hello`, and `echo-mcp`, which needs Docker, and `desktop`, a computer env of `ubuntu-24.04` and `reference` with a probe task and an agent task). Tests run them, but they are examples for users, not fixtures. Every shipped bundle is registered under `agent_env.bundles` in `pyproject.toml`, ` = ""` shipping `src///`. | diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index 5462c89e..53600d7e 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -739,6 +739,36 @@ requests to the app's server. env stores, and `python -m agentenv_protocol.computer._declare ` prints it as JSON, or a driver's `DriverDeclaration`, without the class's own dependencies installed. +### Chromium + +`agentenv_protocol.website_browser` holds Chromium's install for a computer, which agent-env's own `chromium` app and +any app that starts Chromium itself share: Chrome for Testing at the build Playwright pins (`CHROMIUM_VERSION`), for +amd64 and arm64, since Google ships no Linux arm64 Chrome. + +```python +from agentenv_protocol.computer import ComputerAppEnvironment, Launch, Packages, Ready, computer_app, linux +from agentenv_protocol.website_browser import (CHROMIUM_DOWNLOADS, CHROMIUM_PACKAGES, WINDOW_CLASS, ChromiumInstall, + chromium_command) + +@computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) +@linux(packages=Packages(apt=CHROMIUM_PACKAGES), downloads=CHROMIUM_DOWNLOADS, + launch=Launch(command=["venv/bin/python", "desk.py"], window={"class": WINDOW_CLASS}, ready=Ready(window=True))) +class Desk(ChromiumInstall, ComputerAppEnvironment): + ... +``` + +- `CHROMIUM_DOWNLOADS` is one `Download` per arch, by sha256; `CHROMIUM_PACKAGES` what it links against that Ubuntu + 24.04 lacks, a font and `unzip`. +- `ChromiumInstall` is a base of the app's class, since an OS decorator replaces a parent's install while install steps + are inherited. Its system install step unpacks the arch's archive with `unzip`, which keeps its execute bits, and + writes the managed policies (`POLICIES`) where Chrome for Testing reads them, `/etc/opt/chrome_for_testing/policies/managed`: + no sign-in, sync, promotional tabs, default-browser prompt, telemetry, password, autofill or translation prompts, + updates, location or notification prompts, and no warning bar for the flags a container needs. +- `chromium_command(folder, profile=..., urls=..., flags=...)` is its 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; `--class=Chromium`, which + a launch's `window` matches; the profile; the caller's own flags; and the URLs in tabs, the first active. It sets no + proxy, so sites on loopback stay reachable. + ## A2A agent framework The distribution exposes two unrelated decorators named `extension`: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/__init__.py new file mode 100644 index 00000000..84fb41cb --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/__init__.py @@ -0,0 +1,7 @@ +"""Website browsers for computers. Today Chromium's install, which agentenv's ``chromium`` app and any other app that +starts Chromium itself share.""" +from .chromium import (CHROMIUM_DOWNLOADS, CHROMIUM_PACKAGES, CHROMIUM_VERSION, POLICIES, POLICY_DIR, WINDOW_CLASS, + ChromiumInstall, chromium_binary, chromium_command, install_chromium) + +__all__ = ["CHROMIUM_DOWNLOADS", "CHROMIUM_PACKAGES", "CHROMIUM_VERSION", "POLICIES", "POLICY_DIR", "WINDOW_CLASS", + "ChromiumInstall", "chromium_binary", "chromium_command", "install_chromium"] diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/chromium.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/chromium.py new file mode 100644 index 00000000..165fa569 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/chromium.py @@ -0,0 +1,87 @@ +"""Chromium for a computer, as agentenv pins it: 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. An app installs it by +putting ``CHROMIUM_DOWNLOADS`` and ``CHROMIUM_PACKAGES`` in its install and ``ChromiumInstall`` among its bases, since an +OS decorator replaces a parent's install while install steps are inherited, and starts it with ``chromium_command``.""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Iterable + +from ..computer import Download, install_step + +CHROMIUM_VERSION = "156.0.8078.4" +_ARCHIVES = {"amd64": ("linux64/chrome-linux64.zip", + "43fd31766c7533a02dda2ded5ad50c2f861cd5400be7509c0abad71129f79d79"), + "arm64": ("linux-arm64/chrome-linux-arm64.zip", + "910a12fff6170202195f72ef3d353801c7063d301ab80b1f51b77d791fda60ce")} +CHROMIUM_DOWNLOADS = [Download(url=f"https://storage.googleapis.com/chrome-for-testing-public/{CHROMIUM_VERSION}/{path}", + sha256=sha256, to=f"chromium/chrome-{arch}.zip", arch=arch) + for arch, (path, sha256) in _ARCHIVES.items()] +#: What it links against that Ubuntu 24.04 lacks, a font for the pages it shows, and unzip for its install step. +CHROMIUM_PACKAGES = ["fonts-liberation", "libasound2t64", "libatk-bridge2.0-0t64", "libatk1.0-0t64", "libatspi2.0-0t64", + "libcairo2", "libcups2t64", "libdbus-1-3", "libdrm2", "libexpat1", "libgbm1", "libglib2.0-0t64", + "libgtk-3-0t64", "libnspr4", "libnss3", "libpango-1.0-0", "libvulkan1", "libx11-6", "libxcb1", + "libxcomposite1", "libxdamage1", "libxext6", "libxfixes3", "libxkbcommon0", "libxrandr2", + "libxshmfence1", "unzip"] +#: The window class it opens its windows with, which a launch matches. +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 or autofill prompts, +#: 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, + "AutofillAddressEnabled": False, + "AutofillCreditCardEnabled": False, + "TranslateEnabled": False, + "ComponentUpdatesEnabled": False, + "DefaultGeolocationSetting": 2, + "DefaultNotificationsSetting": 2, + "CommandLineFlagSecurityWarningsEnabled": False, +} +_BINARY = Path("chromium") / "chrome" / "chrome" + + +class ChromiumInstall: + """An app's install step for Chromium, a base of its class beside ``ComputerAppEnvironment``: the arch's archive, + which ``CHROMIUM_DOWNLOADS`` brought, unpacked in the app's folder, and the managed policies written.""" + + @install_step(scope="system", os="linux") + def chromium(self) -> None: + install_chromium(Path.cwd()) + + +def install_chromium(folder: Path, policies: Path = POLICY_DIR) -> None: + """Unpack the archive under ``folder``'s ``chromium/`` to ``chromium/chrome/``, with unzip, which keeps its execute + bits, in its place, and write ``POLICIES`` to ``policies``.""" + [archive] = (folder / "chromium").glob("chrome-*.zip") + unpacked = folder / "chromium" / "unpacked" + subprocess.run(["unzip", "-q", str(archive), "-d", str(unpacked)], check=True) + [top] = unpacked.iterdir() + 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_binary(folder: Path) -> Path: + """The binary ``ChromiumInstall`` unpacked under the app's ``folder``.""" + return folder / _BINARY + + +def chromium_command(folder: Path, *, profile: Path, urls: Iterable[str] = (), flags: 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; ``profile`` its profile; ``flags``, the + caller's own; 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(chromium_binary(folder)), "--no-sandbox", "--disable-dev-shm-usage", "--no-first-run", + "--no-default-browser-check", f"--class={WINDOW_CLASS}", f"--user-data-dir={profile}", *flags, *urls] diff --git a/packages/agentenv-protocol/tests/test_website_browser.py b/packages/agentenv-protocol/tests/test_website_browser.py new file mode 100644 index 00000000..ae5c8091 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_browser.py @@ -0,0 +1,58 @@ +"""Chromium's install, shared by agentenv's chromium app and any app that starts Chromium itself: a pinned archive per +arch, its packages, the command, the install step that unpacks it keeping its execute bits and writes the policies, and +another app that inherits that step.""" +import json +import os +import zipfile + +from agentenv_protocol.computer import ComputerAppEnvironment, Launch, Packages, Ready, computer_app, linux +from agentenv_protocol.website_browser import (CHROMIUM_DOWNLOADS, CHROMIUM_PACKAGES, CHROMIUM_VERSION, POLICIES, + WINDOW_CLASS, ChromiumInstall, chromium_command, install_chromium) + + +def test_each_arch_gets_its_pinned_archive_of_the_version_in_a_place_of_its_own(): + assert [(d.arch, d.to) for d in CHROMIUM_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 CHROMIUM_DOWNLOADS) + assert {"libnss3", "libasound2t64", "unzip"} <= set(CHROMIUM_PACKAGES) + + +def test_the_command_runs_unsandboxed_without_dev_shm_first_runs_or_proxy_and_opens_the_urls_in_order(tmp_path): + command = chromium_command(tmp_path, profile=tmp_path / "profile", urls=["https://a.test/", "https://b.test/"], + flags=["--disable-default-apps"]) + + 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 / 'profile'}", "--disable-default-apps", "https://a.test/", + "https://b.test/"] + assert not [arg for arg in command if "proxy" in arg] + + +def test_the_install_unpacks_the_archive_with_its_execute_bits_in_its_place_and_writes_the_policies(tmp_path): + (tmp_path / "chromium").mkdir() + with zipfile.ZipFile(tmp_path / "chromium" / "chrome-amd64.zip", "w") as archive: + executable = zipfile.ZipInfo("chrome-linux64/chrome") + executable.external_attr = 0o100755 << 16 + archive.writestr(executable, "#!/bin/sh\n") + archive.writestr("chrome-linux64/resources.pak", "data") + + install_chromium(tmp_path, tmp_path / "policies") + + 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"] + assert os.access(tmp_path / "chromium/chrome/chrome", os.X_OK) + policies = json.loads((tmp_path / "policies" / "agentenv.json").read_text()) + assert policies == POLICIES and policies["BrowserSignin"] == 0 and not policies["CommandLineFlagSecurityWarningsEnabled"] + + +def test_another_app_that_starts_chromium_itself_inherits_its_install_step(): + @computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) + @linux(packages=Packages(apt=[*CHROMIUM_PACKAGES, "xdotool"]), downloads=CHROMIUM_DOWNLOADS, + launch=Launch(command=["venv/bin/python", "desk.py"], window={"class": WINDOW_CLASS}, ready=Ready(window=True))) + class Desk(ChromiumInstall, ComputerAppEnvironment): + pass + + declared = Desk.declaration() + + assert [(step.name, step.scope, step.os) for step in declared.install_steps] == [("chromium", "system", ["linux"])] + assert [d["to"] for d in declared.os["linux"].install["downloads"]] == [d.to for d in CHROMIUM_DOWNLOADS] diff --git a/pyproject.toml b/pyproject.toml index 0720a1de..a815f18d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -158,6 +158,7 @@ agent-env = "agent_env.cli:cli" [project.entry-points."agent_env.bundles"] agents = "agent_env" +apps = "agent_env" computers = "agent_env" drivers = "agent_env" desktop = "agent_env.examples" diff --git a/src/agent_env/apps/README.md b/src/agent_env/apps/README.md new file mode 100644 index 00000000..736efd45 --- /dev/null +++ b/src/agent_env/apps/README.md @@ -0,0 +1,16 @@ +Computer apps that come with agent-env, each a `computer_app` env a computer env lists in its `apps`. + +`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`, +the alias `aliases.toml` gives it, or by its id, `@local/agentenv-framework/apps/chromium`. + +An app that starts Chromium itself, with its own profile and flags, shares this install through +`agentenv_protocol.website_browser`: `CHROMIUM_DOWNLOADS` and `CHROMIUM_PACKAGES` in its `@linux(...)`, +`ChromiumInstall` among its class's bases, and `chromium_command(...)` for the command. diff --git a/src/agent_env/apps/aliases.toml b/src/agent_env/apps/aliases.toml new file mode 100644 index 00000000..ee9927a0 --- /dev/null +++ b/src/agent_env/apps/aliases.toml @@ -0,0 +1,3 @@ +# Ids a computer env's apps, or a task, can name these apps by; a store's env of the id wins over an alias. +[envs] +chromium = ["chromium"] diff --git a/src/agent_env/apps/envs/chromium/chromium.py b/src/agent_env/apps/envs/chromium/chromium.py new file mode 100644 index 00000000..dfa4b49c --- /dev/null +++ b/src/agent_env/apps/envs/chromium/chromium.py @@ -0,0 +1,31 @@ +"""agentenv's chromium app: Chromium as ``agentenv_protocol.website_browser`` pins and sets it up, 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 os +from pathlib import Path + +from pydantic import BaseModel + +from agentenv_protocol.computer import ComputerAppEnvironment, Launch, Packages, Ready, computer_app, linux +from agentenv_protocol.website_browser import (CHROMIUM_DOWNLOADS, CHROMIUM_PACKAGES, WINDOW_CLASS, ChromiumInstall, + chromium_command) + +PROFILE = ".config/chromium" +CACHE = ".cache/chromium" + + +@computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) +@linux(packages=Packages(apt=CHROMIUM_PACKAGES), downloads=CHROMIUM_DOWNLOADS, paths=[PROFILE], + launch=Launch(at="boot", command=["venv/bin/python", "chromium.py"], window={"class": WINDOW_CLASS}, + ready=Ready(window=True))) +class Chromium(ChromiumInstall, ComputerAppEnvironment): + class Settings(BaseModel): + #: Opened in tabs at launch, the first active; none opens a new tab. + start_urls: list[str] = [] + + +if __name__ == "__main__": + command = chromium_command(Path(__file__).resolve().parent, profile=Path.home() / PROFILE, + urls=Chromium().settings.start_urls, + flags=[f"--disk-cache-dir={Path.home() / CACHE}"]) + os.execv(command[0], command) diff --git a/src/agent_env/apps/envs/chromium/env.toml b/src/agent_env/apps/envs/chromium/env.toml new file mode 100644 index 00000000..f2c05cc7 --- /dev/null +++ b/src/agent_env/apps/envs/chromium/env.toml @@ -0,0 +1,4 @@ +type = "computer_app" +module = "chromium.py:Chromium" +computer = "ubuntu-24.04" +driver = "reference" diff --git a/tst/integration/computer/chromium/envs/desktop/env.toml b/tst/integration/computer/chromium/envs/desktop/env.toml new file mode 100644 index 00000000..cce7e20f --- /dev/null +++ b/tst/integration/computer/chromium/envs/desktop/env.toml @@ -0,0 +1,4 @@ +type = "computer" +computer = "ubuntu-24.04" +driver = "reference" +apps = ["chromium"] diff --git a/tst/integration/computer/chromium_app_test.py b/tst/integration/computer/chromium_app_test.py new file mode 100644 index 00000000..ec0ef541 --- /dev/null +++ b/tst/integration/computer/chromium_app_test.py @@ -0,0 +1,80 @@ +"""agentenv's chromium app on the ubuntu-24.04 base, as the chromium test bundle's desktop lists 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.""" + +import asyncio +import json +import platform +import re + +import pytest +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +from agentenv_protocol import client as protocol_v1 +from agentenv_protocol.website_browser import POLICIES + +from agent_env.env import Env +from agent_env.env.envs.computer.utils import inside +from agent_env.store.routing import namespace_routing +from agent_env.task.teardown import teardown_run +from agent_env.task_step.context import TaskStepContext + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +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

" +# 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') + + +def test_gate_3_makes_this_machines_arch_ready_with_chromium(chromium_desktop): + results = chromium_desktop.metadata["composed"]["container"] + + assert results[ARCH]["status"] == "ready" and results[ARCH]["probe"]["passed"], results[ARCH] + assert [app.environment_name for app in chromium_desktop.composed_apps()] == ["chromium"] + + +@pytest.mark.asyncio +async def test_a_deploy_opens_its_start_urls_unprompted_and_a_reset_clears_its_profile(chromium_desktop): + with namespace_routing(): + deployed = await Env.get(chromium_desktop.id).deploy( + env_params={"app_settings": {"chromium": {"start_urls": [ALPHA, BRAVO]}}}) + 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") + 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) + await protocol_v1.reset_data(f"{deployed.environment_url}/svc/chromium", headers=deployed.control_headers) + marker = await inside.run(deployed, ["sh", "-c", "test -e ~/.config/chromium/marker"], timeout_s=30) + reopened = await _chromium_windows(deployed, "Alpha page") + finally: + report = await teardown_run(context) + + assert opened == ["Alpha page - Google Chrome for Testing"] + assert not keyed.isError and tabbed == ["Bravo page - Google Chrome for Testing"] + assert json.loads(policies["stdout"]) == POLICIES + assert marker["exit_code"] != 0 and reopened == ["Alpha page - Google Chrome for Testing"] + 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): + 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): + return titles + await asyncio.sleep(1) + raise TimeoutError(f"no Chromium window titled {title!r}: {listed}") diff --git a/tst/integration/computer/conftest.py b/tst/integration/computer/conftest.py index 4bfef907..ff25cc3f 100644 --- a/tst/integration/computer/conftest.py +++ b/tst/integration/computer/conftest.py @@ -1,5 +1,6 @@ """The ubuntu-24.04 base computer, built from its folder once for the tests here and tagged for the local registry; the -shipped desktop example's computer env, and the cua-driver test bundle's, each composed once for a module's tests; +shipped desktop example's computer env, the cua-driver test bundle's and the chromium test bundle's, each composed once +for a module's tests; ``state``, local stores for a test that runs a bundle; and ``docker``, which runs the docker CLI for them.""" import logging @@ -25,6 +26,7 @@ BASE = Path(agent_env.__file__).parent / "computers" / "artifacts" / "ubuntu-24.04" CUA_DRIVER = Path(__file__).parent / "cua-driver" +CHROMIUM = Path(__file__).parent / "chromium" @pytest.fixture(scope="session") @@ -50,6 +52,13 @@ def cua_desktop(tmp_path_factory): yield from _composed(tmp_path_factory.mktemp("cua-desktop"), parse_bundle(CUA_DRIVER)) +@pytest.fixture(scope="module") +def chromium_desktop(tmp_path_factory): + """The chromium test bundle's desktop, the ubuntu-24.04 base and the reference driver with agentenv's chromium app, + composed as ``desktop`` is.""" + yield from _composed(tmp_path_factory.mktemp("chromium-desktop"), parse_bundle(CHROMIUM)) + + def _composed(root: Path, bundle): patch = pytest.MonkeyPatch() patch.chdir(root) diff --git a/tst/unit/bundle/installed_test.py b/tst/unit/bundle/installed_test.py index 00238582..ecf09a41 100644 --- a/tst/unit/bundle/installed_test.py +++ b/tst/unit/bundle/installed_test.py @@ -127,8 +127,8 @@ def test_the_plugin_report_counts_the_bundles_agent_env_ships(monkeypatch, quiet assert result.exit_code == 0, result.output (core,) = json.loads(result.stdout)["plugins"] assert [(c["group"], c["name"], c["status"]) for c in core["contributions"]] == [ - (BUNDLES, "advance-to-next-event", "active"), (BUNDLES, "agents", "active"), (BUNDLES, "computers", "active"), - (BUNDLES, "desktop", "active"), (BUNDLES, "drivers", "active"), + (BUNDLES, "advance-to-next-event", "active"), (BUNDLES, "agents", "active"), (BUNDLES, "apps", "active"), + (BUNDLES, "computers", "active"), (BUNDLES, "desktop", "active"), (BUNDLES, "drivers", "active"), (BUNDLES, "echo-mcp", "active"), (BUNDLES, "hello", "active"), (BUNDLES, "mail-mcp", "active"), (BUNDLES, "next-env-event", "active")] assert "\n agent-env itself\n" in shown and "requires:" not in shown diff --git a/tst/unit/env/envs/test_chromium_app.py b/tst/unit/env/envs/test_chromium_app.py new file mode 100644 index 00000000..202e0c90 --- /dev/null +++ b/tst/unit/env/envs/test_chromium_app.py @@ -0,0 +1,38 @@ +"""agentenv's chromium app as a put reads it: the installed ``apps`` bundle names it ``chromium``, and its class declares +the pinned Chromium archives and packages, its profile as its data, a launch matched by its window class and started on +its ``start_urls``, none by default, and Chromium's install step.""" +import sys + +from agentenv_protocol.computer._load import load_class +from agentenv_protocol.computer.app import ComputerAppEnvironment +from agentenv_protocol.website_browser import CHROMIUM_DOWNLOADS, CHROMIUM_PACKAGES + +from agent_env.bundle import parse_bundle +from agent_env.bundle.installed import find_bundle + + +def test_the_apps_bundle_names_its_chromium_app_chromium(): + bundle = find_bundle("apps") + parsed = parse_bundle(bundle.root, id_root=bundle.id_root) + + assert [(entry.id, entry.type) for entry in parsed.entries] == [("@local/agentenv-framework/apps/chromium", + "computer_app")] + assert parsed.aliases == {"envs": {"chromium": ("chromium",)}} + + +def test_chromium_declares_its_archives_profile_launch_start_urls_and_install_step(): + sys.modules.pop("chromium", None) + chromium = load_class(find_bundle("apps").root / "envs" / "chromium" / "chromium.py", "Chromium", + ComputerAppEnvironment) + + declared = chromium.declaration() + linux = declared.os["linux"] + + assert linux.install["downloads"] == [d.model_dump(mode="json", exclude_none=True) for d in CHROMIUM_DOWNLOADS] + assert linux.install["packages"]["apt"] == CHROMIUM_PACKAGES + 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) for step in declared.install_steps] == [("chromium", "system")] + assert declared.settings["properties"]["start_urls"]["default"] == [] + assert declared.supports[0]["arch"] == ["amd64", "arm64"] From 1a5579a73926b0f7ec2e8d16da366c1c7910e7e0 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 16:30:58 -0800 Subject: [PATCH 2/5] test: the apps bundle among the shipped bundles, and a task for the chromium test bundle Co-Authored-By: Claude Opus 5.5 --- tst/integration/computer/chromium/tasks/desktop.json | 3 +++ tst/unit/bundle/shipped_bundles_test.py | 8 ++++---- tst/unit/test_clean_install_wheel.py | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 tst/integration/computer/chromium/tasks/desktop.json diff --git a/tst/integration/computer/chromium/tasks/desktop.json b/tst/integration/computer/chromium/tasks/desktop.json new file mode 100644 index 00000000..67f3b315 --- /dev/null +++ b/tst/integration/computer/chromium/tasks/desktop.json @@ -0,0 +1,3 @@ +[ + {"id": "env", "type": "deploy_env", "env_id": "desktop"} +] diff --git a/tst/unit/bundle/shipped_bundles_test.py b/tst/unit/bundle/shipped_bundles_test.py index 42891a4b..761f651b 100644 --- a/tst/unit/bundle/shipped_bundles_test.py +++ b/tst/unit/bundle/shipped_bundles_test.py @@ -26,8 +26,8 @@ def _shipped() -> dict: def test_agentenv_framework_ships_its_bundles(): - assert SHIPPED == ["advance-to-next-event", "agents", "computers", "desktop", "drivers", "echo-mcp", "hello", "mail-mcp", - "next-env-event"] + assert SHIPPED == ["advance-to-next-event", "agents", "apps", "computers", "desktop", "drivers", "echo-mcp", "hello", + "mail-mcp", "next-env-event"] @pytest.mark.parametrize("name", SHIPPED) @@ -53,13 +53,13 @@ def test_the_shipped_aliases_are_agent_envs_alone_and_claude_code_names_the_defa -def test_the_shipped_env_aliases_name_the_mail_env_the_validator_deploys_and_the_drivers(): +def test_the_shipped_env_aliases_name_the_mail_env_the_validator_deploys_the_drivers_and_the_apps(): table = installed_aliases(tuple(_shipped().values()), ENV_ALIASES) assert table.refused == {} and table.refused_claims == {} assert {alias: named.target_id for alias, named in table.aliases.items()} == { "mail": "@local/agentenv-framework/mail-mcp/mail", "reference": "@local/agentenv-framework/drivers/reference", - "cua-driver": "@local/agentenv-framework/drivers/cua-driver"} + "cua-driver": "@local/agentenv-framework/drivers/cua-driver", "chromium": "@local/agentenv-framework/apps/chromium"} assert A2AAgent.VALIDATION_ENV_ID == "mail" def test_a_task_naming_no_agent_plans_with_the_shipped_default(tmp_path, monkeypatch, local_stores, cli_routing): diff --git a/tst/unit/test_clean_install_wheel.py b/tst/unit/test_clean_install_wheel.py index f964e907..761c9b9c 100644 --- a/tst/unit/test_clean_install_wheel.py +++ b/tst/unit/test_clean_install_wheel.py @@ -63,7 +63,8 @@ def test_the_gate_checks_the_folder_of_every_shipped_bundle(): roots = {name: root.relative_to(SCRIPT.parents[2]).as_posix() for name, root in clean_install.bundle_roots().items()} assert roots == {"advance-to-next-event": "src/agent_env/examples/advance-to-next-event", - "agents": "src/agent_env/agents", "computers": "src/agent_env/computers", + "agents": "src/agent_env/agents", "apps": "src/agent_env/apps", + "computers": "src/agent_env/computers", "desktop": "src/agent_env/examples/desktop", "drivers": "src/agent_env/drivers", "echo-mcp": "src/agent_env/examples/echo-mcp", "hello": "src/agent_env/examples/hello", "mail-mcp": "src/agent_env/examples/mail-mcp", From c5ab57acb9262531c53e710e57fb44516b2e4bbd Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 16:48:37 -0800 Subject: [PATCH 3/5] fix(computer): an app that starts Chromium names its own window class A launch is ready on any window of its class, so two apps on one computer that start Chromium with the same class would each take the other's window for their own. `chromium_command` takes `window_class`, `WINDOW_CLASS` by default. The installer's container journey counts the apps bundle among the shipped ones. Co-Authored-By: Claude Opus 5.5 --- packages/agentenv-protocol/README.md | 14 +++++++------- .../agentenv_protocol/website_browser/chromium.py | 13 +++++++------ .../tests/test_website_browser.py | 5 +++-- tst/installer/test_container_journey.py | 2 +- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index 53600d7e..824d667f 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -747,12 +747,11 @@ amd64 and arm64, since Google ships no Linux arm64 Chrome. ```python from agentenv_protocol.computer import ComputerAppEnvironment, Launch, Packages, Ready, computer_app, linux -from agentenv_protocol.website_browser import (CHROMIUM_DOWNLOADS, CHROMIUM_PACKAGES, WINDOW_CLASS, ChromiumInstall, - chromium_command) +from agentenv_protocol.website_browser import CHROMIUM_DOWNLOADS, CHROMIUM_PACKAGES, ChromiumInstall, chromium_command @computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) @linux(packages=Packages(apt=CHROMIUM_PACKAGES), downloads=CHROMIUM_DOWNLOADS, - launch=Launch(command=["venv/bin/python", "desk.py"], window={"class": WINDOW_CLASS}, ready=Ready(window=True))) + launch=Launch(command=["venv/bin/python", "desk.py"], window={"class": "Desk"}, ready=Ready(window=True))) class Desk(ChromiumInstall, ComputerAppEnvironment): ... ``` @@ -764,10 +763,11 @@ class Desk(ChromiumInstall, ComputerAppEnvironment): writes the managed policies (`POLICIES`) where Chrome for Testing reads them, `/etc/opt/chrome_for_testing/policies/managed`: no sign-in, sync, promotional tabs, default-browser prompt, telemetry, password, autofill or translation prompts, updates, location or notification prompts, and no warning bar for the flags a container needs. -- `chromium_command(folder, profile=..., urls=..., flags=...)` is its 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; `--class=Chromium`, which - a launch's `window` matches; the profile; the caller's own flags; and the URLs in tabs, the first active. It sets no - proxy, so sites on loopback stay reachable. +- `chromium_command(folder, profile=..., urls=..., flags=..., window_class=...)` is its 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; the + window class, which the app's launch `window` matches (`desk.py` passes `window_class="Desk"`; the default, + `WINDOW_CLASS`, is agent-env's `chromium` app's, and a launch is ready on any window of its class); the profile; the + caller's own flags; and the URLs in tabs, the first active. It sets no proxy, so sites on loopback stay reachable. ## A2A agent framework diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/chromium.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/chromium.py index 165fa569..34fcab2f 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/chromium.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/chromium.py @@ -25,7 +25,7 @@ "libgtk-3-0t64", "libnspr4", "libnss3", "libpango-1.0-0", "libvulkan1", "libx11-6", "libxcb1", "libxcomposite1", "libxdamage1", "libxext6", "libxfixes3", "libxkbcommon0", "libxrandr2", "libxshmfence1", "unzip"] -#: The window class it opens its windows with, which a launch matches. +#: The window class agentenv's ``chromium`` app opens its windows with, which its launch matches. 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") @@ -78,10 +78,11 @@ def chromium_binary(folder: Path) -> Path: return folder / _BINARY -def chromium_command(folder: Path, *, profile: Path, urls: Iterable[str] = (), flags: Iterable[str] = ()) -> list[str]: +def chromium_command(folder: Path, *, profile: Path, urls: Iterable[str] = (), flags: Iterable[str] = (), + window_class: str = WINDOW_CLASS) -> 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; ``profile`` its profile; ``flags``, the - caller's own; 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.""" + first run or default-browser check; ``window_class`` for its windows, the app's own, since a launch is ready on any + window of its class; ``profile`` its profile; ``flags``, the caller's own; 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(chromium_binary(folder)), "--no-sandbox", "--disable-dev-shm-usage", "--no-first-run", - "--no-default-browser-check", f"--class={WINDOW_CLASS}", f"--user-data-dir={profile}", *flags, *urls] + "--no-default-browser-check", f"--class={window_class}", f"--user-data-dir={profile}", *flags, *urls] diff --git a/packages/agentenv-protocol/tests/test_website_browser.py b/packages/agentenv-protocol/tests/test_website_browser.py index ae5c8091..a8dd845d 100644 --- a/packages/agentenv-protocol/tests/test_website_browser.py +++ b/packages/agentenv-protocol/tests/test_website_browser.py @@ -7,7 +7,7 @@ from agentenv_protocol.computer import ComputerAppEnvironment, Launch, Packages, Ready, computer_app, linux from agentenv_protocol.website_browser import (CHROMIUM_DOWNLOADS, CHROMIUM_PACKAGES, CHROMIUM_VERSION, POLICIES, - WINDOW_CLASS, ChromiumInstall, chromium_command, install_chromium) + ChromiumInstall, chromium_command, install_chromium) def test_each_arch_gets_its_pinned_archive_of_the_version_in_a_place_of_its_own(): @@ -26,6 +26,7 @@ def test_the_command_runs_unsandboxed_without_dev_shm_first_runs_or_proxy_and_op f"--user-data-dir={tmp_path / 'profile'}", "--disable-default-apps", "https://a.test/", "https://b.test/"] assert not [arg for arg in command if "proxy" in arg] + assert "--class=Desk" in chromium_command(tmp_path, profile=tmp_path / "profile", window_class="Desk") def test_the_install_unpacks_the_archive_with_its_execute_bits_in_its_place_and_writes_the_policies(tmp_path): @@ -48,7 +49,7 @@ def test_the_install_unpacks_the_archive_with_its_execute_bits_in_its_place_and_ def test_another_app_that_starts_chromium_itself_inherits_its_install_step(): @computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) @linux(packages=Packages(apt=[*CHROMIUM_PACKAGES, "xdotool"]), downloads=CHROMIUM_DOWNLOADS, - launch=Launch(command=["venv/bin/python", "desk.py"], window={"class": WINDOW_CLASS}, ready=Ready(window=True))) + launch=Launch(command=["venv/bin/python", "desk.py"], window={"class": "Desk"}, ready=Ready(window=True))) class Desk(ChromiumInstall, ComputerAppEnvironment): pass diff --git a/tst/installer/test_container_journey.py b/tst/installer/test_container_journey.py index 94d0edfc..d13237c0 100644 --- a/tst/installer/test_container_journey.py +++ b/tst/installer/test_container_journey.py @@ -133,7 +133,7 @@ def test_a_new_user_installs_agent_env_adds_plugins_runs_a_task_and_removes_them container.ok("python", "-m", "pip", "install", "-q", "--no-index", "--find-links", "/wheels", "uv") container.ok("uv", "tool", "install", "agentenv-framework") rows = container.ok("agent-env", "plugin", "list").stdout.split("STATUS\n", 1)[1] - assert re.fullmatch(r"agentenv-framework +\S+ +9 bundles +ok\n", rows), rows + assert re.fullmatch(r"agentenv-framework +\S+ +10 bundles +ok\n", rows), rows added = container.ok("agent-env", "plugin", "add", browser, grader, "--yes") assert "toy_navigate active" in added.stdout and "toy_grade active" in added.stdout From 3bc15595f48d3aa95eb1223401ec8ce0dd01a827 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 17:05:03 -0800 Subject: [PATCH 4/5] refactor(computer): ship chromium in a browsers bundle The shipped bundles are named for what they hold (agents, computers, drivers), and chromium will also run outside a computer once it moves onto WebsiteBrowserEnvironment, so its bundle is `browsers`, not `apps`. Its id becomes `@local/agentenv-framework/browsers/chromium`; the `chromium` alias is unchanged. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- pyproject.toml | 2 +- src/agent_env/apps/aliases.toml | 3 --- src/agent_env/{apps => browsers}/README.md | 5 +++-- src/agent_env/browsers/aliases.toml | 3 +++ .../{apps => browsers}/envs/chromium/chromium.py | 0 .../{apps => browsers}/envs/chromium/env.toml | 0 tst/unit/bundle/installed_test.py | 2 +- tst/unit/bundle/shipped_bundles_test.py | 6 +++--- tst/unit/env/envs/test_chromium_app.py | 10 +++++----- tst/unit/test_clean_install_wheel.py | 2 +- 11 files changed, 18 insertions(+), 17 deletions(-) delete mode 100644 src/agent_env/apps/aliases.toml rename src/agent_env/{apps => browsers}/README.md (89%) create mode 100644 src/agent_env/browsers/aliases.toml rename src/agent_env/{apps => browsers}/envs/chromium/chromium.py (100%) rename src/agent_env/{apps => browsers}/envs/chromium/env.toml (100%) diff --git a/AGENTS.md b/AGENTS.md index 5d49d5de..bc48fa7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,7 @@ and deserialized through a registry. | `cli/` | Click CLI with the groups `a2a-agent`, `artifact`, `bundle`, `config`, `env`, `eval`, `plugin`, `run`, `task`, `up`. `task run`, `task run-batch` and `eval run` hand their runs to the configured `[runner]` and follow them. | | `agents/` | The `agents` bundle: the agents agent-env ships, one folder each under `agents/agents/`, with a smoke task each under `agents/tasks/`. Its Claude Code, `@local/agentenv-framework/agents/claude-code`, is the default agent. An agent's Python packages are its folder's `requirements.txt`, installed in its image, never in agent-env's own environment. A run that pulls one of its agents reads the whole bundle, so `tst/unit/bundle/shipped_bundles_test.py` parses and checks every shipped bundle. | | `computers/` | The `computers` bundle: the base computers agent-env ships as `computer` artifacts, `ubuntu-24.04` (XFCE on Xvfb, supervisord as the session user, settings as `AGENTENV_SETTING_`), aliased through `[artifacts]`. It has no tasks either. | -| `apps/` | The `apps` bundle: the computer apps agent-env ships as `computer_app` envs, today `chromium` (Chrome for Testing at the build Playwright pins, for amd64 and arm64, set up for unattended use by managed policies in `/etc/opt/chrome_for_testing/policies/managed`, opened at boot on its `start_urls`, its profile its data). Its install lives in `agentenv_protocol.website_browser` (`CHROMIUM_DOWNLOADS`, `CHROMIUM_PACKAGES`, `ChromiumInstall`, `chromium_command`), so an app that starts Chromium itself shares it; `tst/integration/computer/chromium_app_test.py` runs it. Like `drivers`, it has no tasks. | +| `browsers/` | The `browsers` bundle: the website browsers agent-env ships, today `chromium`, a `computer_app` env (Chrome for Testing at the build Playwright pins, for amd64 and arm64, set up for unattended use by managed policies in `/etc/opt/chrome_for_testing/policies/managed`, opened at boot on its `start_urls`, its profile its data). Its install lives in `agentenv_protocol.website_browser` (`CHROMIUM_DOWNLOADS`, `CHROMIUM_PACKAGES`, `ChromiumInstall`, `chromium_command`), so an app that starts Chromium itself shares it; `tst/integration/computer/chromium_app_test.py` runs it. Like `drivers`, it has no tasks. | | `drivers/` | The `drivers` bundle: the computer drivers agent-env ships as `computer_driver` envs, the reference driver (X11 on Linux, recording through `x11grab`) aliased `reference`, and trycua's cua-driver aliased `cua-driver` (its daemon from the pinned PyPI wheel, its own instructions, tools and skill resources served to agents through `@server_instructions`, `forwarded_tools` and `@resource`, its tools beside `computer_*`, `computer/v1` on its desktop-scope calls, its skill pack leading the computer skill; `tst/integration/computer/cua_driver_test.py` runs it). It has no tasks: a bundle with neither tasks nor evals is refused unless its `aliases.toml` names something for other bundles to use. The shipped `desktop` example composes it with the `ubuntu-24.04` base into a computer env, which `tst/integration/computer/` deploys, probes, records and has an agent use. | | `examples/` | The example bundles agent-env ships (`hello`, and `echo-mcp`, which needs Docker, and `desktop`, a computer env of `ubuntu-24.04` and `reference` with a probe task and an agent task). Tests run them, but they are examples for users, not fixtures. Every shipped bundle is registered under `agent_env.bundles` in `pyproject.toml`, ` = ""` shipping `src///`. | diff --git a/pyproject.toml b/pyproject.toml index a815f18d..0aa99458 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -158,7 +158,7 @@ agent-env = "agent_env.cli:cli" [project.entry-points."agent_env.bundles"] agents = "agent_env" -apps = "agent_env" +browsers = "agent_env" computers = "agent_env" drivers = "agent_env" desktop = "agent_env.examples" diff --git a/src/agent_env/apps/aliases.toml b/src/agent_env/apps/aliases.toml deleted file mode 100644 index ee9927a0..00000000 --- a/src/agent_env/apps/aliases.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Ids a computer env's apps, or a task, can name these apps by; a store's env of the id wins over an alias. -[envs] -chromium = ["chromium"] diff --git a/src/agent_env/apps/README.md b/src/agent_env/browsers/README.md similarity index 89% rename from src/agent_env/apps/README.md rename to src/agent_env/browsers/README.md index 736efd45..cb26f020 100644 --- a/src/agent_env/apps/README.md +++ b/src/agent_env/browsers/README.md @@ -1,4 +1,5 @@ -Computer apps that come with agent-env, each a `computer_app` env a computer env lists in its `apps`. +The website browsers that come with agent-env, today `chromium`, a `computer_app` env a computer env lists in its +`apps`. `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 @@ -9,7 +10,7 @@ prompts, and no warning bar. It runs without Chromium's sandbox and `/dev/shm`, 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`, -the alias `aliases.toml` gives it, or by its id, `@local/agentenv-framework/apps/chromium`. +the alias `aliases.toml` gives it, or by its id, `@local/agentenv-framework/browsers/chromium`. An app that starts Chromium itself, with its own profile and flags, shares this install through `agentenv_protocol.website_browser`: `CHROMIUM_DOWNLOADS` and `CHROMIUM_PACKAGES` in its `@linux(...)`, diff --git a/src/agent_env/browsers/aliases.toml b/src/agent_env/browsers/aliases.toml new file mode 100644 index 00000000..36afb975 --- /dev/null +++ b/src/agent_env/browsers/aliases.toml @@ -0,0 +1,3 @@ +# Ids a computer env's apps, or a task, can name these browsers by; a store's env of the id wins over an alias. +[envs] +chromium = ["chromium"] diff --git a/src/agent_env/apps/envs/chromium/chromium.py b/src/agent_env/browsers/envs/chromium/chromium.py similarity index 100% rename from src/agent_env/apps/envs/chromium/chromium.py rename to src/agent_env/browsers/envs/chromium/chromium.py diff --git a/src/agent_env/apps/envs/chromium/env.toml b/src/agent_env/browsers/envs/chromium/env.toml similarity index 100% rename from src/agent_env/apps/envs/chromium/env.toml rename to src/agent_env/browsers/envs/chromium/env.toml diff --git a/tst/unit/bundle/installed_test.py b/tst/unit/bundle/installed_test.py index ecf09a41..035defa3 100644 --- a/tst/unit/bundle/installed_test.py +++ b/tst/unit/bundle/installed_test.py @@ -127,7 +127,7 @@ def test_the_plugin_report_counts_the_bundles_agent_env_ships(monkeypatch, quiet assert result.exit_code == 0, result.output (core,) = json.loads(result.stdout)["plugins"] assert [(c["group"], c["name"], c["status"]) for c in core["contributions"]] == [ - (BUNDLES, "advance-to-next-event", "active"), (BUNDLES, "agents", "active"), (BUNDLES, "apps", "active"), + (BUNDLES, "advance-to-next-event", "active"), (BUNDLES, "agents", "active"), (BUNDLES, "browsers", "active"), (BUNDLES, "computers", "active"), (BUNDLES, "desktop", "active"), (BUNDLES, "drivers", "active"), (BUNDLES, "echo-mcp", "active"), (BUNDLES, "hello", "active"), (BUNDLES, "mail-mcp", "active"), (BUNDLES, "next-env-event", "active")] diff --git a/tst/unit/bundle/shipped_bundles_test.py b/tst/unit/bundle/shipped_bundles_test.py index 761f651b..9f3bd827 100644 --- a/tst/unit/bundle/shipped_bundles_test.py +++ b/tst/unit/bundle/shipped_bundles_test.py @@ -26,7 +26,7 @@ def _shipped() -> dict: def test_agentenv_framework_ships_its_bundles(): - assert SHIPPED == ["advance-to-next-event", "agents", "apps", "computers", "desktop", "drivers", "echo-mcp", "hello", + assert SHIPPED == ["advance-to-next-event", "agents", "browsers", "computers", "desktop", "drivers", "echo-mcp", "hello", "mail-mcp", "next-env-event"] @@ -53,13 +53,13 @@ def test_the_shipped_aliases_are_agent_envs_alone_and_claude_code_names_the_defa -def test_the_shipped_env_aliases_name_the_mail_env_the_validator_deploys_the_drivers_and_the_apps(): +def test_the_shipped_env_aliases_name_the_mail_env_the_validator_deploys_the_drivers_and_the_browsers(): table = installed_aliases(tuple(_shipped().values()), ENV_ALIASES) assert table.refused == {} and table.refused_claims == {} assert {alias: named.target_id for alias, named in table.aliases.items()} == { "mail": "@local/agentenv-framework/mail-mcp/mail", "reference": "@local/agentenv-framework/drivers/reference", - "cua-driver": "@local/agentenv-framework/drivers/cua-driver", "chromium": "@local/agentenv-framework/apps/chromium"} + "cua-driver": "@local/agentenv-framework/drivers/cua-driver", "chromium": "@local/agentenv-framework/browsers/chromium"} assert A2AAgent.VALIDATION_ENV_ID == "mail" def test_a_task_naming_no_agent_plans_with_the_shipped_default(tmp_path, monkeypatch, local_stores, cli_routing): diff --git a/tst/unit/env/envs/test_chromium_app.py b/tst/unit/env/envs/test_chromium_app.py index 202e0c90..f5f168b2 100644 --- a/tst/unit/env/envs/test_chromium_app.py +++ b/tst/unit/env/envs/test_chromium_app.py @@ -1,4 +1,4 @@ -"""agentenv's chromium app as a put reads it: the installed ``apps`` bundle names it ``chromium``, and its class declares +"""agentenv's chromium app as a put reads it: the installed ``browsers`` bundle names it ``chromium``, and its class declares the pinned Chromium archives and packages, its profile as its data, a launch matched by its window class and started on its ``start_urls``, none by default, and Chromium's install step.""" import sys @@ -11,18 +11,18 @@ from agent_env.bundle.installed import find_bundle -def test_the_apps_bundle_names_its_chromium_app_chromium(): - bundle = find_bundle("apps") +def test_the_browsers_bundle_names_its_chromium_app_chromium(): + bundle = find_bundle("browsers") parsed = parse_bundle(bundle.root, id_root=bundle.id_root) - assert [(entry.id, entry.type) for entry in parsed.entries] == [("@local/agentenv-framework/apps/chromium", + assert [(entry.id, entry.type) for entry in parsed.entries] == [("@local/agentenv-framework/browsers/chromium", "computer_app")] assert parsed.aliases == {"envs": {"chromium": ("chromium",)}} def test_chromium_declares_its_archives_profile_launch_start_urls_and_install_step(): sys.modules.pop("chromium", None) - chromium = load_class(find_bundle("apps").root / "envs" / "chromium" / "chromium.py", "Chromium", + chromium = load_class(find_bundle("browsers").root / "envs" / "chromium" / "chromium.py", "Chromium", ComputerAppEnvironment) declared = chromium.declaration() diff --git a/tst/unit/test_clean_install_wheel.py b/tst/unit/test_clean_install_wheel.py index 761c9b9c..c1d40778 100644 --- a/tst/unit/test_clean_install_wheel.py +++ b/tst/unit/test_clean_install_wheel.py @@ -63,7 +63,7 @@ def test_the_gate_checks_the_folder_of_every_shipped_bundle(): roots = {name: root.relative_to(SCRIPT.parents[2]).as_posix() for name, root in clean_install.bundle_roots().items()} assert roots == {"advance-to-next-event": "src/agent_env/examples/advance-to-next-event", - "agents": "src/agent_env/agents", "apps": "src/agent_env/apps", + "agents": "src/agent_env/agents", "browsers": "src/agent_env/browsers", "computers": "src/agent_env/computers", "desktop": "src/agent_env/examples/desktop", "drivers": "src/agent_env/drivers", "echo-mcp": "src/agent_env/examples/echo-mcp", "hello": "src/agent_env/examples/hello", From 515baec9c45875c6e220781f6814d5b023e5658a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 1 Nov 2026 17:13:54 -0800 Subject: [PATCH 5/5] refactor(computer): keep chromium's pin in its app, not the protocol package The protocol package is the SDK environments are written with. Its browser piece is a base class for website browsers, still to come, while a particular build, its archives, packages, install step, policies and command, belongs to the app that ships it. So all of it moves into the browsers bundle's chromium.py, and agentenv_protocol.website_browser goes. An app that wants the same Chromium, such as the bench's, pins it itself, which also keeps its browser fixed whatever a framework release ships. With no other app starting Chromium through it, the command no longer takes a window class. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- packages/agentenv-protocol/README.md | 30 ------- .../website_browser/__init__.py | 7 -- .../website_browser/chromium.py | 88 ------------------- .../tests/test_website_browser.py | 59 ------------- src/agent_env/browsers/README.md | 4 - .../browsers/envs/chromium/chromium.py | 86 +++++++++++++++--- tst/integration/computer/chromium_app_test.py | 4 +- tst/unit/env/envs/test_chromium_app.py | 67 +++++++++++--- 9 files changed, 132 insertions(+), 215 deletions(-) delete mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website_browser/__init__.py delete mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website_browser/chromium.py delete mode 100644 packages/agentenv-protocol/tests/test_website_browser.py diff --git a/AGENTS.md b/AGENTS.md index bc48fa7d..6c7dffc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,7 @@ and deserialized through a registry. | `cli/` | Click CLI with the groups `a2a-agent`, `artifact`, `bundle`, `config`, `env`, `eval`, `plugin`, `run`, `task`, `up`. `task run`, `task run-batch` and `eval run` hand their runs to the configured `[runner]` and follow them. | | `agents/` | The `agents` bundle: the agents agent-env ships, one folder each under `agents/agents/`, with a smoke task each under `agents/tasks/`. Its Claude Code, `@local/agentenv-framework/agents/claude-code`, is the default agent. An agent's Python packages are its folder's `requirements.txt`, installed in its image, never in agent-env's own environment. A run that pulls one of its agents reads the whole bundle, so `tst/unit/bundle/shipped_bundles_test.py` parses and checks every shipped bundle. | | `computers/` | The `computers` bundle: the base computers agent-env ships as `computer` artifacts, `ubuntu-24.04` (XFCE on Xvfb, supervisord as the session user, settings as `AGENTENV_SETTING_`), aliased through `[artifacts]`. It has no tasks either. | -| `browsers/` | The `browsers` bundle: the website browsers agent-env ships, today `chromium`, a `computer_app` env (Chrome for Testing at the build Playwright pins, for amd64 and arm64, set up for unattended use by managed policies in `/etc/opt/chrome_for_testing/policies/managed`, opened at boot on its `start_urls`, its profile its data). Its install lives in `agentenv_protocol.website_browser` (`CHROMIUM_DOWNLOADS`, `CHROMIUM_PACKAGES`, `ChromiumInstall`, `chromium_command`), so an app that starts Chromium itself shares it; `tst/integration/computer/chromium_app_test.py` runs it. Like `drivers`, it has no tasks. | +| `browsers/` | The `browsers` bundle: the website browsers agent-env ships, today `chromium`, a `computer_app` env (Chrome for Testing at the build Playwright pins, for amd64 and arm64, set up for unattended use by managed policies in `/etc/opt/chrome_for_testing/policies/managed`, opened at boot on its `start_urls`, its profile its data). All of it, its pin, install step, policies and command, is in `envs/chromium/chromium.py`; `tst/integration/computer/chromium_app_test.py` runs it. Like `drivers`, it has no tasks. | | `drivers/` | The `drivers` bundle: the computer drivers agent-env ships as `computer_driver` envs, the reference driver (X11 on Linux, recording through `x11grab`) aliased `reference`, and trycua's cua-driver aliased `cua-driver` (its daemon from the pinned PyPI wheel, its own instructions, tools and skill resources served to agents through `@server_instructions`, `forwarded_tools` and `@resource`, its tools beside `computer_*`, `computer/v1` on its desktop-scope calls, its skill pack leading the computer skill; `tst/integration/computer/cua_driver_test.py` runs it). It has no tasks: a bundle with neither tasks nor evals is refused unless its `aliases.toml` names something for other bundles to use. The shipped `desktop` example composes it with the `ubuntu-24.04` base into a computer env, which `tst/integration/computer/` deploys, probes, records and has an agent use. | | `examples/` | The example bundles agent-env ships (`hello`, and `echo-mcp`, which needs Docker, and `desktop`, a computer env of `ubuntu-24.04` and `reference` with a probe task and an agent task). Tests run them, but they are examples for users, not fixtures. Every shipped bundle is registered under `agent_env.bundles` in `pyproject.toml`, ` = ""` shipping `src///`. | diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index 824d667f..5462c89e 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -739,36 +739,6 @@ requests to the app's server. env stores, and `python -m agentenv_protocol.computer._declare ` prints it as JSON, or a driver's `DriverDeclaration`, without the class's own dependencies installed. -### Chromium - -`agentenv_protocol.website_browser` holds Chromium's install for a computer, which agent-env's own `chromium` app and -any app that starts Chromium itself share: Chrome for Testing at the build Playwright pins (`CHROMIUM_VERSION`), for -amd64 and arm64, since Google ships no Linux arm64 Chrome. - -```python -from agentenv_protocol.computer import ComputerAppEnvironment, Launch, Packages, Ready, computer_app, linux -from agentenv_protocol.website_browser import CHROMIUM_DOWNLOADS, CHROMIUM_PACKAGES, ChromiumInstall, chromium_command - -@computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) -@linux(packages=Packages(apt=CHROMIUM_PACKAGES), downloads=CHROMIUM_DOWNLOADS, - launch=Launch(command=["venv/bin/python", "desk.py"], window={"class": "Desk"}, ready=Ready(window=True))) -class Desk(ChromiumInstall, ComputerAppEnvironment): - ... -``` - -- `CHROMIUM_DOWNLOADS` is one `Download` per arch, by sha256; `CHROMIUM_PACKAGES` what it links against that Ubuntu - 24.04 lacks, a font and `unzip`. -- `ChromiumInstall` is a base of the app's class, since an OS decorator replaces a parent's install while install steps - are inherited. Its system install step unpacks the arch's archive with `unzip`, which keeps its execute bits, and - writes the managed policies (`POLICIES`) where Chrome for Testing reads them, `/etc/opt/chrome_for_testing/policies/managed`: - no sign-in, sync, promotional tabs, default-browser prompt, telemetry, password, autofill or translation prompts, - updates, location or notification prompts, and no warning bar for the flags a container needs. -- `chromium_command(folder, profile=..., urls=..., flags=..., window_class=...)` is its 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; the - window class, which the app's launch `window` matches (`desk.py` passes `window_class="Desk"`; the default, - `WINDOW_CLASS`, is agent-env's `chromium` app's, and a launch is ready on any window of its class); the profile; the - caller's own flags; and the URLs in tabs, the first active. It sets no proxy, so sites on loopback stay reachable. - ## A2A agent framework The distribution exposes two unrelated decorators named `extension`: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/__init__.py deleted file mode 100644 index 84fb41cb..00000000 --- a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Website browsers for computers. Today Chromium's install, which agentenv's ``chromium`` app and any other app that -starts Chromium itself share.""" -from .chromium import (CHROMIUM_DOWNLOADS, CHROMIUM_PACKAGES, CHROMIUM_VERSION, POLICIES, POLICY_DIR, WINDOW_CLASS, - ChromiumInstall, chromium_binary, chromium_command, install_chromium) - -__all__ = ["CHROMIUM_DOWNLOADS", "CHROMIUM_PACKAGES", "CHROMIUM_VERSION", "POLICIES", "POLICY_DIR", "WINDOW_CLASS", - "ChromiumInstall", "chromium_binary", "chromium_command", "install_chromium"] diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/chromium.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/chromium.py deleted file mode 100644 index 34fcab2f..00000000 --- a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/chromium.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Chromium for a computer, as agentenv pins it: 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. An app installs it by -putting ``CHROMIUM_DOWNLOADS`` and ``CHROMIUM_PACKAGES`` in its install and ``ChromiumInstall`` among its bases, since an -OS decorator replaces a parent's install while install steps are inherited, and starts it with ``chromium_command``.""" -from __future__ import annotations - -import json -import subprocess -from pathlib import Path -from typing import Iterable - -from ..computer import Download, install_step - -CHROMIUM_VERSION = "156.0.8078.4" -_ARCHIVES = {"amd64": ("linux64/chrome-linux64.zip", - "43fd31766c7533a02dda2ded5ad50c2f861cd5400be7509c0abad71129f79d79"), - "arm64": ("linux-arm64/chrome-linux-arm64.zip", - "910a12fff6170202195f72ef3d353801c7063d301ab80b1f51b77d791fda60ce")} -CHROMIUM_DOWNLOADS = [Download(url=f"https://storage.googleapis.com/chrome-for-testing-public/{CHROMIUM_VERSION}/{path}", - sha256=sha256, to=f"chromium/chrome-{arch}.zip", arch=arch) - for arch, (path, sha256) in _ARCHIVES.items()] -#: What it links against that Ubuntu 24.04 lacks, a font for the pages it shows, and unzip for its install step. -CHROMIUM_PACKAGES = ["fonts-liberation", "libasound2t64", "libatk-bridge2.0-0t64", "libatk1.0-0t64", "libatspi2.0-0t64", - "libcairo2", "libcups2t64", "libdbus-1-3", "libdrm2", "libexpat1", "libgbm1", "libglib2.0-0t64", - "libgtk-3-0t64", "libnspr4", "libnss3", "libpango-1.0-0", "libvulkan1", "libx11-6", "libxcb1", - "libxcomposite1", "libxdamage1", "libxext6", "libxfixes3", "libxkbcommon0", "libxrandr2", - "libxshmfence1", "unzip"] -#: The window class agentenv's ``chromium`` app opens its windows with, which its launch matches. -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 or autofill prompts, -#: 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, - "AutofillAddressEnabled": False, - "AutofillCreditCardEnabled": False, - "TranslateEnabled": False, - "ComponentUpdatesEnabled": False, - "DefaultGeolocationSetting": 2, - "DefaultNotificationsSetting": 2, - "CommandLineFlagSecurityWarningsEnabled": False, -} -_BINARY = Path("chromium") / "chrome" / "chrome" - - -class ChromiumInstall: - """An app's install step for Chromium, a base of its class beside ``ComputerAppEnvironment``: the arch's archive, - which ``CHROMIUM_DOWNLOADS`` brought, unpacked in the app's folder, and the managed policies written.""" - - @install_step(scope="system", os="linux") - def chromium(self) -> None: - install_chromium(Path.cwd()) - - -def install_chromium(folder: Path, policies: Path = POLICY_DIR) -> None: - """Unpack the archive under ``folder``'s ``chromium/`` to ``chromium/chrome/``, with unzip, which keeps its execute - bits, in its place, and write ``POLICIES`` to ``policies``.""" - [archive] = (folder / "chromium").glob("chrome-*.zip") - unpacked = folder / "chromium" / "unpacked" - subprocess.run(["unzip", "-q", str(archive), "-d", str(unpacked)], check=True) - [top] = unpacked.iterdir() - 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_binary(folder: Path) -> Path: - """The binary ``ChromiumInstall`` unpacked under the app's ``folder``.""" - return folder / _BINARY - - -def chromium_command(folder: Path, *, profile: Path, urls: Iterable[str] = (), flags: Iterable[str] = (), - window_class: str = WINDOW_CLASS) -> 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, the app's own, since a launch is ready on any - window of its class; ``profile`` its profile; ``flags``, the caller's own; 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(chromium_binary(folder)), "--no-sandbox", "--disable-dev-shm-usage", "--no-first-run", - "--no-default-browser-check", f"--class={window_class}", f"--user-data-dir={profile}", *flags, *urls] diff --git a/packages/agentenv-protocol/tests/test_website_browser.py b/packages/agentenv-protocol/tests/test_website_browser.py deleted file mode 100644 index a8dd845d..00000000 --- a/packages/agentenv-protocol/tests/test_website_browser.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Chromium's install, shared by agentenv's chromium app and any app that starts Chromium itself: a pinned archive per -arch, its packages, the command, the install step that unpacks it keeping its execute bits and writes the policies, and -another app that inherits that step.""" -import json -import os -import zipfile - -from agentenv_protocol.computer import ComputerAppEnvironment, Launch, Packages, Ready, computer_app, linux -from agentenv_protocol.website_browser import (CHROMIUM_DOWNLOADS, CHROMIUM_PACKAGES, CHROMIUM_VERSION, POLICIES, - ChromiumInstall, chromium_command, install_chromium) - - -def test_each_arch_gets_its_pinned_archive_of_the_version_in_a_place_of_its_own(): - assert [(d.arch, d.to) for d in CHROMIUM_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 CHROMIUM_DOWNLOADS) - assert {"libnss3", "libasound2t64", "unzip"} <= set(CHROMIUM_PACKAGES) - - -def test_the_command_runs_unsandboxed_without_dev_shm_first_runs_or_proxy_and_opens_the_urls_in_order(tmp_path): - command = chromium_command(tmp_path, profile=tmp_path / "profile", urls=["https://a.test/", "https://b.test/"], - flags=["--disable-default-apps"]) - - 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 / 'profile'}", "--disable-default-apps", "https://a.test/", - "https://b.test/"] - assert not [arg for arg in command if "proxy" in arg] - assert "--class=Desk" in chromium_command(tmp_path, profile=tmp_path / "profile", window_class="Desk") - - -def test_the_install_unpacks_the_archive_with_its_execute_bits_in_its_place_and_writes_the_policies(tmp_path): - (tmp_path / "chromium").mkdir() - with zipfile.ZipFile(tmp_path / "chromium" / "chrome-amd64.zip", "w") as archive: - executable = zipfile.ZipInfo("chrome-linux64/chrome") - executable.external_attr = 0o100755 << 16 - archive.writestr(executable, "#!/bin/sh\n") - archive.writestr("chrome-linux64/resources.pak", "data") - - install_chromium(tmp_path, tmp_path / "policies") - - 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"] - assert os.access(tmp_path / "chromium/chrome/chrome", os.X_OK) - policies = json.loads((tmp_path / "policies" / "agentenv.json").read_text()) - assert policies == POLICIES and policies["BrowserSignin"] == 0 and not policies["CommandLineFlagSecurityWarningsEnabled"] - - -def test_another_app_that_starts_chromium_itself_inherits_its_install_step(): - @computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}]) - @linux(packages=Packages(apt=[*CHROMIUM_PACKAGES, "xdotool"]), downloads=CHROMIUM_DOWNLOADS, - launch=Launch(command=["venv/bin/python", "desk.py"], window={"class": "Desk"}, ready=Ready(window=True))) - class Desk(ChromiumInstall, ComputerAppEnvironment): - pass - - declared = Desk.declaration() - - assert [(step.name, step.scope, step.os) for step in declared.install_steps] == [("chromium", "system", ["linux"])] - assert [d["to"] for d in declared.os["linux"].install["downloads"]] == [d.to for d in CHROMIUM_DOWNLOADS] diff --git a/src/agent_env/browsers/README.md b/src/agent_env/browsers/README.md index cb26f020..777085b4 100644 --- a/src/agent_env/browsers/README.md +++ b/src/agent_env/browsers/README.md @@ -11,7 +11,3 @@ with no proxy. It opens at boot on its `start_urls` setting, a list, the first t (`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`. - -An app that starts Chromium itself, with its own profile and flags, shares this install through -`agentenv_protocol.website_browser`: `CHROMIUM_DOWNLOADS` and `CHROMIUM_PACKAGES` in its `@linux(...)`, -`ChromiumInstall` among its class's bases, and `chromium_command(...)` for the command. diff --git a/src/agent_env/browsers/envs/chromium/chromium.py b/src/agent_env/browsers/envs/chromium/chromium.py index dfa4b49c..296a628f 100644 --- a/src/agent_env/browsers/envs/chromium/chromium.py +++ b/src/agent_env/browsers/envs/chromium/chromium.py @@ -1,31 +1,95 @@ -"""agentenv's chromium app: Chromium as ``agentenv_protocol.website_browser`` pins and sets it up, 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.""" +"""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 +import subprocess from pathlib import Path +from typing import Iterable from pydantic import BaseModel -from agentenv_protocol.computer import ComputerAppEnvironment, Launch, Packages, Ready, computer_app, linux -from agentenv_protocol.website_browser import (CHROMIUM_DOWNLOADS, CHROMIUM_PACKAGES, WINDOW_CLASS, ChromiumInstall, - chromium_command) +from agentenv_protocol.computer import (ComputerAppEnvironment, Download, Launch, Packages, Ready, computer_app, + install_step, linux) +VERSION = "156.0.8078.4" +_ARCHIVES = {"amd64": ("linux64/chrome-linux64.zip", + "43fd31766c7533a02dda2ded5ad50c2f861cd5400be7509c0abad71129f79d79"), + "arm64": ("linux-arm64/chrome-linux-arm64.zip", + "910a12fff6170202195f72ef3d353801c7063d301ab80b1f51b77d791fda60ce")} +DOWNLOADS = [Download(url=f"https://storage.googleapis.com/chrome-for-testing-public/{VERSION}/{path}", sha256=sha256, + to=f"chromium/chrome-{arch}.zip", arch=arch) + for arch, (path, sha256) in _ARCHIVES.items()] +#: What it links against that Ubuntu 24.04 lacks, a font for the pages it shows, and unzip for its install step. +PACKAGES = ["fonts-liberation", "libasound2t64", "libatk-bridge2.0-0t64", "libatk1.0-0t64", "libatspi2.0-0t64", + "libcairo2", "libcups2t64", "libdbus-1-3", "libdrm2", "libexpat1", "libgbm1", "libglib2.0-0t64", + "libgtk-3-0t64", "libnspr4", "libnss3", "libpango-1.0-0", "libvulkan1", "libx11-6", "libxcb1", + "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 or autofill prompts, +#: 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, + "AutofillAddressEnabled": False, + "AutofillCreditCardEnabled": False, + "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=CHROMIUM_PACKAGES), downloads=CHROMIUM_DOWNLOADS, paths=[PROFILE], +@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(ChromiumInstall, ComputerAppEnvironment): +class Chromium(ComputerAppEnvironment): class Settings(BaseModel): #: Opened in tabs at launch, the first active; none opens a new tab. start_urls: list[str] = [] + @install_step(scope="system", os="linux") + def chromium(self) -> None: + install_chromium(Path.cwd()) + + +def install_chromium(folder: Path, policies: Path = POLICY_DIR) -> 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``.""" + [archive] = (folder / "chromium").glob("chrome-*.zip") + unpacked = folder / "chromium" / "unpacked" + subprocess.run(["unzip", "-q", str(archive), "-d", str(unpacked)], check=True) + [top] = unpacked.iterdir() + 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, profile=Path.home() / PROFILE, - urls=Chromium().settings.start_urls, - flags=[f"--disk-cache-dir={Path.home() / CACHE}"]) + command = chromium_command(Path(__file__).resolve().parent, home=Path.home(), urls=Chromium().settings.start_urls) os.execv(command[0], command) diff --git a/tst/integration/computer/chromium_app_test.py b/tst/integration/computer/chromium_app_test.py index ec0ef541..59bee68d 100644 --- a/tst/integration/computer/chromium_app_test.py +++ b/tst/integration/computer/chromium_app_test.py @@ -14,7 +14,6 @@ from mcp.client.streamable_http import streamablehttp_client from agentenv_protocol import client as protocol_v1 -from agentenv_protocol.website_browser import POLICIES from agent_env.env import Env from agent_env.env.envs.computer.utils import inside @@ -63,7 +62,8 @@ async def test_a_deploy_opens_its_start_urls_unprompted_and_a_reset_clears_its_p assert opened == ["Alpha page - Google Chrome for Testing"] assert not keyed.isError and tabbed == ["Bravo page - Google Chrome for Testing"] - assert json.loads(policies["stdout"]) == POLICIES + assert {"BrowserSignin": 0, "CommandLineFlagSecurityWarningsEnabled": False}.items() <= json.loads( + policies["stdout"]).items() assert marker["exit_code"] != 0 and reopened == ["Alpha page - Google Chrome for Testing"] assert not report.failed diff --git a/tst/unit/env/envs/test_chromium_app.py b/tst/unit/env/envs/test_chromium_app.py index f5f168b2..99cdbb5a 100644 --- a/tst/unit/env/envs/test_chromium_app.py +++ b/tst/unit/env/envs/test_chromium_app.py @@ -1,16 +1,30 @@ -"""agentenv's chromium app as a put reads it: the installed ``browsers`` bundle names it ``chromium``, and its class declares -the pinned Chromium archives and packages, its profile as its data, a launch matched by its window class and started on -its ``start_urls``, none by default, and Chromium's install step.""" +"""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 +import os import sys +import zipfile + +import pytest from agentenv_protocol.computer._load import load_class from agentenv_protocol.computer.app import ComputerAppEnvironment -from agentenv_protocol.website_browser import CHROMIUM_DOWNLOADS, CHROMIUM_PACKAGES from agent_env.bundle import parse_bundle from agent_env.bundle.installed import find_bundle +@pytest.fixture +def chromium(): + sys.modules.pop("chromium", None) + cls = load_class(find_bundle("browsers").root / "envs" / "chromium" / "chromium.py", "Chromium", + ComputerAppEnvironment) + yield sys.modules[cls.__module__] + sys.modules.pop("chromium", None) + + def test_the_browsers_bundle_names_its_chromium_app_chromium(): bundle = find_bundle("browsers") parsed = parse_bundle(bundle.root, id_root=bundle.id_root) @@ -20,19 +34,46 @@ 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(): - sys.modules.pop("chromium", None) - chromium = load_class(find_bundle("browsers").root / "envs" / "chromium" / "chromium.py", "Chromium", - ComputerAppEnvironment) - - declared = chromium.declaration() +def test_chromium_declares_its_archives_profile_launch_start_urls_and_install_step(chromium): + declared = chromium.Chromium.declaration() linux = declared.os["linux"] - assert linux.install["downloads"] == [d.model_dump(mode="json", exclude_none=True) for d in CHROMIUM_DOWNLOADS] - assert linux.install["packages"]["apt"] == CHROMIUM_PACKAGES + 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) for step in declared.install_steps] == [("chromium", "system")] + assert [(step.name, step.scope, step.os) for step in declared.install_steps] == [("chromium", "system", ["linux"])] assert declared.settings["properties"]["start_urls"]["default"] == [] 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): + (tmp_path / "chromium").mkdir() + with zipfile.ZipFile(tmp_path / "chromium" / "chrome-amd64.zip", "w") as archive: + executable = zipfile.ZipInfo("chrome-linux64/chrome") + executable.external_attr = 0o100755 << 16 + archive.writestr(executable, "#!/bin/sh\n") + archive.writestr("chrome-linux64/resources.pak", "data") + + chromium.install_chromium(tmp_path, tmp_path / "policies") + + 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"] + 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]