Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<NAME>`), 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). 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`, `<name> = "<package>"` shipping `src/<package as a path>/<name>/`. |

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ agent-env = "agent_env.cli:cli"

[project.entry-points."agent_env.bundles"]
agents = "agent_env"
browsers = "agent_env"
computers = "agent_env"
drivers = "agent_env"
desktop = "agent_env.examples"
Expand Down
13 changes: 13 additions & 0 deletions src/agent_env/browsers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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
`/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/browsers/chromium`.
3 changes: 3 additions & 0 deletions src/agent_env/browsers/aliases.toml
Original file line number Diff line number Diff line change
@@ -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"]
95 changes: 95 additions & 0 deletions src/agent_env/browsers/envs/chromium/chromium.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""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, 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=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] = []

@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, home=Path.home(), urls=Chromium().settings.start_urls)
os.execv(command[0], command)
4 changes: 4 additions & 0 deletions src/agent_env/browsers/envs/chromium/env.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type = "computer_app"
module = "chromium.py:Chromium"
computer = "ubuntu-24.04"
driver = "reference"
2 changes: 1 addition & 1 deletion tst/installer/test_container_journey.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tst/integration/computer/chromium/envs/desktop/env.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type = "computer"
computer = "ubuntu-24.04"
driver = "reference"
apps = ["chromium"]
3 changes: 3 additions & 0 deletions tst/integration/computer/chromium/tasks/desktop.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[
{"id": "env", "type": "deploy_env", "env_id": "desktop"}
]
80 changes: 80 additions & 0 deletions tst/integration/computer/chromium_app_test.py
Original file line number Diff line number Diff line change
@@ -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 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,<title>Alpha page</title><h1>Alpha</h1>"
BRAVO = "data:text/html,<title>Bravo page</title><h1>Bravo</h1>"
# 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 {"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


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}")
11 changes: 10 additions & 1 deletion tst/integration/computer/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions tst/unit/bundle/installed_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "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")]
assert "\n agent-env itself\n" in shown and "requires:" not in shown
Expand Down
8 changes: 4 additions & 4 deletions tst/unit/bundle/shipped_bundles_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "browsers", "computers", "desktop", "drivers", "echo-mcp", "hello",
"mail-mcp", "next-env-event"]


@pytest.mark.parametrize("name", SHIPPED)
Expand All @@ -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_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"}
"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):
Expand Down
Loading
Loading