diff --git a/.agentenv/config.example.toml b/.agentenv/config.example.toml index ab60d7968..c2e04609b 100644 --- a/.agentenv/config.example.toml +++ b/.agentenv/config.example.toml @@ -43,6 +43,11 @@ agent_default = "local" # agents too # default_a2a_agent_id = "my-agent" # what a deploy_agent step that names no agent deploys; by default the # # agents bundle's Claude Code, @local/agentenv-framework/agents/claude-code +# [envs] +# default_gateway_env_id = "default" # the gateway every gateway deploy runs +# default_service_db_env_id = "default-db" # the service-db local Postgres state runs from +# default_website_browser_env_id = "playwright-mcp" # the website browser a deploy with websites adds + # [model] # base_url = "http://localhost:4000" # a model proxy on this machine; local containers reach it as host.docker.internal # # on Linux, bind it to the Docker bridge address and use that: http://172.17.0.1:4000 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d3aac7c20..5d157bbd4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -39,7 +39,7 @@ updates: directories: - "/src/agent_env/env/gateway" - "/src/agent_env/env/envs/service_db" - - "/src/agent_env/env/envs/website_browser" + - "/src/agent_env/env/envs/playwright_mcp" - "/src/agent_env/agents/agents/claude-code" - "/src/agent_env/agents/agents/codex" - "/src/agent_env/agents/agents/gemini" diff --git a/.github/scripts/infra_build.py b/.github/scripts/infra_build.py index 92bc20aff..c4ba84fa8 100644 --- a/.github/scripts/infra_build.py +++ b/.github/scripts/infra_build.py @@ -30,7 +30,7 @@ "agentenv-service-db": "src/agent_env/env/envs/service_db/Dockerfile", "agentenv-db-web": "src/agent_env/env/envs/service_db/Dockerfile.db-web", "agentenv-db-mcp": "src/agent_env/env/envs/service_db/Dockerfile.db-mcp", - "agentenv-website-browser": "src/agent_env/env/envs/website_browser/Dockerfile", + "agentenv-playwright-mcp": "src/agent_env/env/envs/playwright_mcp/Dockerfile", "agentenv-claude-code": "src/agent_env/agents/agents/claude-code/Dockerfile", "agentenv-codex": "src/agent_env/agents/agents/codex/Dockerfile", "agentenv-gemini": "src/agent_env/agents/agents/gemini/Dockerfile", @@ -68,9 +68,9 @@ "agentenv-gateway": ["docker", "run", "--rm", "--entrypoint", "python", "{image}", "-c", "import gateway.entrypoint"], "agentenv-db-web": ["docker", "run", "--rm", "--entrypoint", "/usr/bin/pgweb", "{image}", "--version"], "agentenv-db-mcp": ["docker", "run", "--rm", "{image}", "--help"], - "agentenv-website-browser": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", - 'npx --no-install @playwright/mcp@"$PLAYWRIGHT_MCP_VERSION" --help >/dev/null' - " && ls -d /root/.cache/ms-playwright/chromium-*"], + "agentenv-playwright-mcp": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", + 'npx --no-install @playwright/mcp@"$PLAYWRIGHT_MCP_VERSION" --help >/dev/null' + " && ls -d /root/.cache/ms-playwright/chromium-*"], "agentenv-claude-code": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", 'claude --version && python -c "import a2a_server"'], "agentenv-codex": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", diff --git a/.github/scripts/infra_images.py b/.github/scripts/infra_images.py index 6d2c52ae6..6c520cade 100644 --- a/.github/scripts/infra_images.py +++ b/.github/scripts/infra_images.py @@ -20,15 +20,16 @@ import sys from pathlib import Path -from agent_env.env.bootstrap import PUBLISHED_IMAGES +from agent_env.env.bootstrap import PLAYWRIGHT_MCP_DOCKERFILE, PUBLISHED_IMAGES from agent_env.env.published_images import BUNDLE_IMAGES, REGISTRY, PublishedImage, write_release_digests from agent_env.store.image_store.oci_registry_credentials import RegistryAuth from agent_env.store.image_store.registry_api import NoSuchImageError, RegistryRefusedError, pin_digest REPO = Path(__file__).resolve().parents[2] # "agents" is the images that install the protocol a release publishes: the shipped bundles', the agents' and an -# example env's, and the website browser's, whose recorder installs it. -_AFTER_PROTOCOL = BUNDLE_IMAGES + tuple(image for image in PUBLISHED_IMAGES if image.name == "agentenv-website-browser") +# example env's, and the Playwright MCP browser's, whose recorder installs it, matched by its Dockerfile. +_AFTER_PROTOCOL = BUNDLE_IMAGES + tuple(image for image in PUBLISHED_IMAGES + if image.dockerfile == PLAYWRIGHT_MCP_DOCKERFILE) _GROUPS = {"infra": tuple(image for image in PUBLISHED_IMAGES if image not in _AFTER_PROTOCOL), "agents": _AFTER_PROTOCOL} diff --git a/AGENTS.md b/AGENTS.md index 855895d24..c8ecec381 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,11 @@ or one of them only where it is used. endpoint serving Anthropic's Messages API, such as a LiteLLM gateway. It declares `install/v1` too: its folder's `install.sh` installs it into a task container or onto a VM sandbox's host, with its own Claude Code, uv and Python, adding bash only to a target with neither bash nor zsh. +- `[envs] default_gateway_env_id`, `default_service_db_env_id`, `default_website_browser_env_id`: the infra envs a + deploy runs on; built-in `default`, `default-db` and `playwright-mcp`, the Playwright MCP website browser a deploy + with websites adds. An id set in code, by `configure(...)` or the config attribute (as `agent-env env deploy + --gateway` does), beats the file. Within a run, its `user_overrides` (`gateway_env_id`, `service_db_env_id`, + `website_browser_env_id`) beat both. - Secrets never live in the file: use `secret:KEY` or `env:NAME` references, resolved through `[stores.secret]`. - agent-env has no stage concept: "dev" versus "prod" is which file `AGENT_ENV_CONFIG` names. A diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index 10dc4d11a..beb026b48 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -46,7 +46,8 @@ env_images, infra_to_set_up, needs_docker, - put_command, + put_hint, + ships, stores_are_local, ) from agent_env.env.env import Env @@ -429,16 +430,21 @@ def _refusal(self, link: SandboxProvider, form: Accepts, image: _Image, *, vms_l def _remote_infra(self, link: SandboxProvider, kinds: set[str]) -> list[str]: """Why ``link``, a provider not on this machine, can't run the infra envs of ``kinds``. One the run sets up, in local stores, isn't checked: it's written anew, pulled from its registry or built, with its build context.""" - set_up = {setup.kind for setup in infra_to_set_up(kinds)} if stores_are_local() else set() + local = stores_are_local() + # A website browser agent-env doesn't ship is never set up; missing from local stores, finish() reports it. + shipped = {kind for kind in kinds if ships(kind, default_env_id(kind))} + set_up = {setup.kind for setup in infra_to_set_up(shipped)} if local else set() found = [] for kind in sorted(kinds - set_up): env_id = default_env_id(kind) try: env = Env.get(env_id) except NotFoundError: + if local and kind not in shipped: + continue found.append(f"deploys on the {_shown(link)} sandbox provider, which needs the {kind} env {env_id!r}, " f"and the store doesn't hold it; agent-env builds infra envs only into local stores, so put " - f"it with `{put_command(kind)}`") + f"it with {put_hint(kind)}") continue accepts = EnvironmentGatewayProvider.accepts(link) found += [refusal for image in env_images(env) diff --git a/src/agent_env/cli/env/__init__.py b/src/agent_env/cli/env/__init__.py index 71f3f826d..8fa597014 100644 --- a/src/agent_env/cli/env/__init__.py +++ b/src/agent_env/cli/env/__init__.py @@ -10,7 +10,6 @@ from .snapshot import snapshot from .state import init_env_state, teardown_env_state from .website import website -from .website_browser import website_browser @click.group() @@ -30,4 +29,3 @@ def env(): env.add_command(snapshot) env.add_command(teardown_env_state) env.add_command(website) -env.add_command(website_browser) diff --git a/src/agent_env/cli/env/deploy.py b/src/agent_env/cli/env/deploy.py index 7d5fc4bec..ee4f75bc5 100644 --- a/src/agent_env/cli/env/deploy.py +++ b/src/agent_env/cli/env/deploy.py @@ -26,6 +26,8 @@ help="Override default_service_db_env_id (on Modal its images must be in the configured image store)") @click.option("--gateway", "gateway_env_id", default=None, help="Override default_gateway_env_id") +@click.option("--website-browser", "website_browser_env_id", default=None, + help="Override default_website_browser_env_id, the browser a deploy with websites adds") @click.option("--disk-size-gb", type=float, default=10.0, show_default=True, help="Sandbox VM disk size in GB. Large universes (e.g. github/gdrive " "root file trees) need more than the 10GB default.") @@ -46,7 +48,7 @@ @click.option("--env-state-instance-id", default=None, help="Attach against an EXISTING env state instance instead of creating a fresh store " "(its own type selects the provider).") -def deploy(env_id: str, env_version: int | None, ttl_seconds: int, gateway_mode: str, sandbox: str, service_db_env_id: str | None, gateway_env_id: str | None, disk_size_gb: float, cpu: float | None, memory_mb: int | None, env_state_type: str | None, env_state_instance_id: str | None): +def deploy(env_id: str, env_version: int | None, ttl_seconds: int, gateway_mode: str, sandbox: str, service_db_env_id: str | None, gateway_env_id: str | None, website_browser_env_id: str | None, disk_size_gb: float, cpu: float | None, memory_mb: int | None, env_state_type: str | None, env_state_instance_id: str | None): """Deploy an MCP server environment.""" from agent_env.providers import build_sandbox_provider from agent_env.config import get_config @@ -64,6 +66,9 @@ def deploy(env_id: str, env_version: int | None, ttl_seconds: int, gateway_mode: if gateway_env_id: get_config().default_gateway_env_id = gateway_env_id click.echo(f"Override default_gateway_env_id: {gateway_env_id}") + if website_browser_env_id: + get_config().default_website_browser_env_id = website_browser_env_id + click.echo(f"Override default_website_browser_env_id: {website_browser_env_id}") click.echo(f"Fetching env: id={env_id} version={env_version or 'latest'}...") env = Env.get(env_id, version=env_version) diff --git a/src/agent_env/cli/env/website_browser.py b/src/agent_env/cli/env/website_browser.py deleted file mode 100644 index 15c8d315f..000000000 --- a/src/agent_env/cli/env/website_browser.py +++ /dev/null @@ -1,31 +0,0 @@ -import sys - -import click - -from agent_env.cli.utils import build_platform_option -from agent_env.env.bootstrap import WEBSITE_BROWSER_CONTEXT, WEBSITE_BROWSER_DOCKERFILE, put_website_browser_env # noqa: F401 - - -@click.group(name="website-browser") -def website_browser(): - """Website browser environment commands.""" - pass - - -@website_browser.command() -@click.option("--id", "env_id", default=None, help="Env id (default: config default_website_browser_env_id)") -@click.option("--metadata", "metadata_pairs", multiple=True, help="Metadata key=value pair (repeatable)") -@build_platform_option -def put(env_id: str, metadata_pairs: tuple[str, ...], build_platform: str): - """Build and upload a website browser environment.""" - from agent_env.config import get_config - - metadata = {} - for pair in metadata_pairs: - if "=" not in pair: - click.echo(f"Invalid metadata format '{pair}', expected key=value", err=True) - sys.exit(1) - key, value = pair.split("=", 1) - metadata[key] = value - put_website_browser_env(env_id or get_config().default_website_browser_env_id, platform=build_platform, - metadata=metadata, say=click.echo) diff --git a/src/agent_env/cli/task/_checks.py b/src/agent_env/cli/task/_checks.py index 0782db276..0e51fa96a 100644 --- a/src/agent_env/cli/task/_checks.py +++ b/src/agent_env/cli/task/_checks.py @@ -13,8 +13,8 @@ from agent_env.bundle.preflight import stored_infra from agent_env.bundle.requires import UnmetRequirements from agent_env.entity.refs import EntityKind, RefRole, ref_sites -from agent_env.config.runtime import GATEWAY_OVERRIDE, SERVICE_DB_OVERRIDE -from agent_env.env.bootstrap import GATEWAY, SERVICE_DB, ensure_default_envs, stores_are_local +from agent_env.config.runtime import GATEWAY_OVERRIDE, SERVICE_DB_OVERRIDE, WEBSITE_BROWSER_OVERRIDE +from agent_env.env.bootstrap import GATEWAY, SERVICE_DB, WEBSITE_BROWSER, ensure_default_envs, stores_are_local from agent_env.env.env import Env from agent_env.plugins._requirements import unmet_problems from agent_env.store.base import NotFoundError @@ -32,7 +32,7 @@ def runnable_task(task_id: str, version: int | None, metadata: Mapping[str, Any] """The stored task ``task_id`` at ``version``, or its latest, once what it requires is installed and every env and artifact type the steps a run with ``metadata`` runs read loads here. With local stores, the infra envs those steps deploy on that the store doesn't hold are set up, but for one the run is given its own of (``gateway_env_id``, - ``service_db_env_id``); one it holds is left as it is, for ``agent-env up`` or ``run`` to rebuild. Raises + ``service_db_env_id``, ``website_browser_env_id``); one it holds is left as it is, for ``agent-env up`` or ``run`` to rebuild. Raises UnmetRequirements naming the command that installs what it requires, or a ClickException for a type nothing here registers.""" try: @@ -51,7 +51,8 @@ def runnable_task(task_id: str, version: int | None, metadata: Mapping[str, Any] raise UnmetRequirements(problems, unmet) if problems: raise click.ClickException("\n".join(problems)) - given = {kind for kind, key in ((GATEWAY, GATEWAY_OVERRIDE), (SERVICE_DB, SERVICE_DB_OVERRIDE)) if overrides.get(key)} + given = {kind for kind, key in ((GATEWAY, GATEWAY_OVERRIDE), (SERVICE_DB, SERVICE_DB_OVERRIDE), + (WEBSITE_BROWSER, WEBSITE_BROWSER_OVERRIDE)) if overrides.get(key)} if stores_are_local() and (kinds := stored_infra(steps, overrides) - given): ensure_default_envs(kinds, say=say, missing_only=True) return task diff --git a/src/agent_env/cli/task/run.py b/src/agent_env/cli/task/run.py index 7473da0a2..72c6ca818 100644 --- a/src/agent_env/cli/task/run.py +++ b/src/agent_env/cli/task/run.py @@ -15,7 +15,7 @@ from agent_env.cli._install_prompt import offer_to_install from agent_env.cli.banner import print_banner from agent_env.cli.task._checks import runnable_task -from agent_env.config.runtime import GATEWAY_OVERRIDE, SERVICE_DB_OVERRIDE +from agent_env.config.runtime import GATEWAY_OVERRIDE, SERVICE_DB_OVERRIDE, WEBSITE_BROWSER_OVERRIDE from agent_env.cli import submitted_runs from agent_env.cli.identity import get_agent_env_client_id from agent_env.cli.teardown_output import echo_teardown @@ -364,6 +364,8 @@ def _stored(task, context: TaskStepContext) -> str: help="Override default_gateway_env_id (required when --env-sandbox=modal until the default gateway image is rebuilt)") @click.option("--service-db-env-id", default=None, type=str, help="Override default_service_db_env_id (on Modal its images must be in the configured image store)") +@click.option("--website-browser-env-id", default=None, type=str, + help="Override default_website_browser_env_id, the browser a deploy with websites adds") @click.option("--env-state-type", default=None, type=str, help="Override the env state type for deploy_env steps") @click.option("--env-state-instance-id", default=None, type=str, @@ -372,7 +374,7 @@ def _stored(task, context: TaskStepContext) -> str: help="Keep what a run deployed up when it ends, print it, and tear it down on Ctrl-C; while it holds, " "another terminal can resume the run with --start-step and --context-json. Without it, each run " "is torn down as it ends, as Ctrl-C mid-run always does.") -def run(task_id: str, task_version: int | None, output_dir: str | None, k: int, agent_model: str | None, user_model: str | None, judge_model: str | None, agent_artifact_id: str | None, start_step: int, context_json: str | None, litellm_api_key: str | None, judge_litellm_api_key: str | None, apply_trajectory_filter: bool | None, a2a_agent_id: str | None, agent_sandbox: str | None, env_sandbox: str | None, gateway_env_id: str | None, service_db_env_id: str | None, env_state_type: str | None, env_state_instance_id: str | None, keep: bool): +def run(task_id: str, task_version: int | None, output_dir: str | None, k: int, agent_model: str | None, user_model: str | None, judge_model: str | None, agent_artifact_id: str | None, start_step: int, context_json: str | None, litellm_api_key: str | None, judge_litellm_api_key: str | None, apply_trajectory_filter: bool | None, a2a_agent_id: str | None, agent_sandbox: str | None, env_sandbox: str | None, gateway_env_id: str | None, service_db_env_id: str | None, website_browser_env_id: str | None, env_state_type: str | None, env_state_instance_id: str | None, keep: bool): """Run a task through the configured [runner] and follow it. Each run's sandboxes are torn down as it ends, passed or failed; its instance and context JSON stay. @@ -416,6 +418,8 @@ def run(task_id: str, task_version: int | None, output_dir: str | None, k: int, initial_context.metadata.setdefault("user_overrides", {})[GATEWAY_OVERRIDE] = gateway_env_id if service_db_env_id: initial_context.metadata.setdefault("user_overrides", {})[SERVICE_DB_OVERRIDE] = service_db_env_id + if website_browser_env_id: + initial_context.metadata.setdefault("user_overrides", {})[WEBSITE_BROWSER_OVERRIDE] = website_browser_env_id initial_context.metadata["start_step"] = start_step initial_context.metadata[KEEP_KEY] = keep initial_context.metadata["task_id"] = task_id diff --git a/src/agent_env/config/__init__.py b/src/agent_env/config/__init__.py index a34446a61..d4e6e8bad 100644 --- a/src/agent_env/config/__init__.py +++ b/src/agent_env/config/__init__.py @@ -29,6 +29,7 @@ set_object_store, set_runner, set_secret_store, + website_browser_env_id, ) __all__ = [ @@ -53,4 +54,5 @@ "set_object_store", "set_runner", "set_secret_store", + "website_browser_env_id", ] diff --git a/src/agent_env/config/describe.py b/src/agent_env/config/describe.py index 1445a0b3c..e8e291d7f 100644 --- a/src/agent_env/config/describe.py +++ b/src/agent_env/config/describe.py @@ -280,7 +280,7 @@ class FileSection: FileSection("sandbox", ("sandbox",), "providers.sandbox_providers.sandbox_provider", {"default": None, "agent_default": None, "attribution": None, "providers": Named(_SEAM)}), FileSection("state", ("state",), "providers.env_state.env_state_provider", {"providers": Named(_SEAM)}), - FileSection("envs", ("envs",), "env.registry", {"impls": None}), + FileSection("envs", ("envs",), "env.registry", {"impls": None, **_resolved_keys("envs")}), FileSection("artifacts", ("artifacts",), "artifact.registry", {"impls": None, "type_aliases": None}), FileSection("task_steps", ("task_steps",), "task_step.registry", {"impls": None}), FileSection("explorer", ("explorer",), "explorer.app", diff --git a/src/agent_env/config/runtime.py b/src/agent_env/config/runtime.py index 056aead3f..5a037771a 100644 --- a/src/agent_env/config/runtime.py +++ b/src/agent_env/config/runtime.py @@ -79,6 +79,10 @@ def _default_sandbox_mode() -> str: _AGENTS_CONFIG_SECTION = "agents" _AGENTS_CONFIG_KEY = "default_a2a_agent_id" _DEFAULT_A2A_AGENT_ID = "@local/agentenv-framework/agents/claude-code" # the agents bundle's Claude Code +_ENVS_CONFIG_SECTION = "envs" +# The infra envs a deploy runs on, which [envs] can name, each by its built-in id. +_DEFAULT_ENV_IDS = {"default_gateway_env_id": "default", "default_service_db_env_id": "default-db", + "default_website_browser_env_id": "playwright-mcp"} def _validated_human_a2a_url(value: object, source: str) -> str: @@ -94,6 +98,12 @@ def _validated_a2a_agent_id(value: object, source: str) -> str: return value +def _validated_env_id(value: object, source: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"{source} must be a non-empty string env id, got {value!r}") + return value + + def validated_agents_section(section: Mapping[str, Any]) -> Mapping[str, Any]: """The ``[agents]`` table, checked as the reader checks it; ``config show`` runs this too.""" unknown = set(section) - {_AGENTS_CONFIG_KEY} @@ -145,6 +155,9 @@ def file_label(self) -> str: env_var="AGENT_ENV_HUMAN_A2A_URL", field="default_human_a2a_url"), ResolvedKey(("agents", "default_a2a_agent_id"), field="default_a2a_agent_id", default=_DEFAULT_A2A_AGENT_ID, default_label="built-in default in config.runtime"), + *(ResolvedKey((_ENVS_CONFIG_SECTION, name), field=name, default=env_id, + default_label="built-in default in config.runtime") + for name, env_id in _DEFAULT_ENV_IDS.items()), ) _KEY_BY_NAME = {key.name: key for key in RESOLVED_KEYS} @@ -186,6 +199,24 @@ def snapshot(self) -> dict[str, dict[str, str]]: return {group: dict(failures) for group, failures in groups.items() if failures} +class _DefaultEnvId: + """One of Config's default infra env ids, a dataclass field: an id set in code, by ``Config(...)``, + ``configure()`` or assignment, beats ``[envs]`` in config.toml, which beats the built-in id. None unsets it.""" + + def __set_name__(self, owner: type, name: str) -> None: + self.name = name + + def __get__(self, config: Optional["Config"], owner: Optional[type] = None) -> Any: + # On the class, the field's default: nothing set in code. + return None if config is None else config.get_default_env_id(self.name) + + def __set__(self, config: "Config", value: Optional[str]) -> None: + if value is None: + config._env_ids_in_code.pop(self.name, None) + else: + config._env_ids_in_code[self.name] = value + + # Identity, not field equality: a Config owns a document, so two are interchangeable only if # they are the same object. Field equality called two on different config files equal. @dataclass(eq=False) @@ -198,9 +229,11 @@ class Config: agent_sandbox_mode: str = field( default_factory=lambda: os.getenv("AGENT_SANDBOX_MODE") or _default_sandbox_mode() ) - default_gateway_env_id: str = "default" - default_service_db_env_id: str = "default-db" - default_website_browser_env_id: str = "website-browser" + # The default infra env ids set in code, which the three fields below write and read through. + _env_ids_in_code: dict[str, str] = field(default_factory=dict, init=False, repr=False, compare=False) + default_gateway_env_id: Optional[str] = _DefaultEnvId() # type: ignore[assignment] + default_service_db_env_id: Optional[str] = _DefaultEnvId() # type: ignore[assignment] + default_website_browser_env_id: Optional[str] = _DefaultEnvId() # type: ignore[assignment] default_a2a_agent_id: Optional[str] = None modal_default_region: str = field( default_factory=lambda: os.getenv("AGENT_ENV_MODAL_REGION", "us-east-1") @@ -208,6 +241,7 @@ class Config: default_human_a2a_url: Optional[str] = None _secret: Optional[Mapping[str, Any]] = field(default=None, repr=False, compare=False) + _snapshot: Optional[config_snapshot.Snapshot] = field(default=None, repr=False, compare=False) _snapshot_lock: Any = field(default_factory=threading.Lock, repr=False, compare=False) _document_store: Optional["DocumentStore"] = field(default=None, repr=False, compare=False) @@ -384,6 +418,21 @@ def get_default_human_a2a_url(self) -> str: return _validated_human_a2a_url(resolved, key.file_label) raise ConfigError(f"No human-A2A URL configured: set [{_HUMAN_A2A_CONFIG_SECTION}] {_HUMAN_A2A_CONFIG_KEY} or AGENT_ENV_HUMAN_A2A_URL") + def get_default_env_id(self, name: str) -> str: + """The default infra env id ``name`` (``default_gateway_env_id``, ``default_service_db_env_id`` or + ``default_website_browser_env_id``): one set in code > ``[envs] `` in config.toml > the built-in id.""" + key = _KEY_BY_NAME[f"{_ENVS_CONFIG_SECTION}.{name}"] + for layer in self.key_candidates(key): + if layer.kind == KIND_INSTALLED: + return _validated_env_id(layer.raw, key.field) + if layer.kind == KIND_DEFAULT: + return layer.raw + # Only here, where the file is what we are about to take, does a broken file matter. + self.section(_ENVS_CONFIG_SECTION) + resolved = config_loader.interpolate(layer.raw, secret_resolver=self._resolve_secret) + return _validated_env_id(resolved, key.file_label) + return _DEFAULT_ENV_IDS[name] + def get_default_a2a_agent_id(self) -> str: """The agent a ``deploy_agent`` step without an id deploys: ``configure(default_a2a_agent_id=...)`` > ``[agents] default_a2a_agent_id`` > the agents bundle's Claude Code.""" @@ -508,7 +557,8 @@ def key_candidates(self, key: "ResolvedKey") -> list[Layer]: if override is not None: candidates.append(Layer(KIND_ENV, f"${key.env_var}", override)) if key.field is not None: - installed = getattr(self, key.field, None) + installed = (self._env_ids_in_code.get(key.field) if key.field in _DEFAULT_ENV_IDS + else getattr(self, key.field, None)) if installed is not None: candidates.append(Layer(KIND_INSTALLED, f"configure({key.field}=...)", installed)) from_file = self.file_candidate(key.toml_path) @@ -967,6 +1017,9 @@ def configure( *, default_human_a2a_url: Optional[str] = None, default_a2a_agent_id: Optional[str] = None, + default_gateway_env_id: Optional[str] = None, + default_service_db_env_id: Optional[str] = None, + default_website_browser_env_id: Optional[str] = None, document_store: Optional[DocumentStore] = None, image_store: Optional[ImageStore] = None, object_store: Optional[ObjectStore] = None, @@ -977,7 +1030,9 @@ def configure( Pass ``document_store`` / ``image_store`` / ``object_store`` / ``secret_store`` to install explicit backends on the new config. ``default_human_a2a_url`` overrides config.toml ``[conversations]`` but not - ``AGENT_ENV_HUMAN_A2A_URL``. ``default_a2a_agent_id`` overrides config.toml ``[agents]``. + ``AGENT_ENV_HUMAN_A2A_URL``. ``default_a2a_agent_id`` overrides config.toml ``[agents]``, and + ``default_gateway_env_id``, ``default_service_db_env_id`` and ``default_website_browser_env_id`` override + ``[envs]``. Select the stage *before* calling this, by pointing ``AGENT_ENV_CONFIG`` at a per-stage config file (a plugin can do this for you, for instance from a CLI root option it registers). @@ -989,7 +1044,9 @@ def configure( if default_a2a_agent_id is not None: overrides["default_a2a_agent_id"] = default_a2a_agent_id config_path = config_loader.discover_config_path() - _config = Config(**overrides) + _config = Config(**overrides, default_gateway_env_id=default_gateway_env_id, + default_service_db_env_id=default_service_db_env_id, + default_website_browser_env_id=default_website_browser_env_id) if document_store is not None: _config.set_document_store(document_store) if image_store is not None: @@ -1032,16 +1089,18 @@ def get_runner(): _RUN_ENV_IDS: ContextVar[Mapping[str, str]] = ContextVar("agent_env_run_env_ids", default={}) -# The user_overrides keys naming the gateway and service-db envs a run deploys on, over the configured defaults. +# The user_overrides keys naming the gateway, service-db and website browser envs a run deploys on, over the +# configured defaults. GATEWAY_OVERRIDE, SERVICE_DB_OVERRIDE = "gateway_env_id", "service_db_env_id" +WEBSITE_BROWSER_OVERRIDE = "website_browser_env_id" +_RUN_ENV_ID_KEYS = (GATEWAY_OVERRIDE, SERVICE_DB_OVERRIDE, WEBSITE_BROWSER_OVERRIDE) @contextlib.contextmanager def run_env_ids(user_overrides: Mapping[str, Any]) -> Iterator[None]: - """Within a run, the ``gateway_env_id`` and ``service_db_env_id`` its ``user_overrides`` name win over the - configured defaults, in whichever process runs it.""" - token = _RUN_ENV_IDS.set( - {key: user_overrides[key] for key in (GATEWAY_OVERRIDE, SERVICE_DB_OVERRIDE) if user_overrides.get(key)}) + """Within a run, the ``gateway_env_id``, ``service_db_env_id`` and ``website_browser_env_id`` its + ``user_overrides`` name win over the configured defaults, in whichever process runs it.""" + token = _RUN_ENV_IDS.set({key: user_overrides[key] for key in _RUN_ENV_ID_KEYS if user_overrides.get(key)}) try: yield finally: @@ -1058,6 +1117,12 @@ def service_db_env_id(config: Config) -> str: return _RUN_ENV_IDS.get().get(SERVICE_DB_OVERRIDE) or config.default_service_db_env_id +def website_browser_env_id(config: Config) -> str: + """The website browser env a deploy with websites adds: the run's override, else + ``config.default_website_browser_env_id``.""" + return _RUN_ENV_IDS.get().get(WEBSITE_BROWSER_OVERRIDE) or config.default_website_browser_env_id + + def get_config() -> Config: """The process-wide Config, built on first use. diff --git a/src/agent_env/env/bootstrap.py b/src/agent_env/env/bootstrap.py index b08ceac47..1e34ba927 100644 --- a/src/agent_env/env/bootstrap.py +++ b/src/agent_env/env/bootstrap.py @@ -22,7 +22,7 @@ from agent_env.env.env import Env from agent_env.env.envs import GatewayEnv, MCPServerEnv from agent_env.env.envs.service_db import ServiceDBEnv -from agent_env.env.envs.website_browser import WEBSITE_BROWSER_ENVIRONMENT_NAME, WEBSITE_BROWSER_IMAGE_TAG +from agent_env.env.envs.playwright_mcp import PLAYWRIGHT_MCP_ENVIRONMENT_NAME, PLAYWRIGHT_MCP_IMAGE_TAG from agent_env.env.published_images import BUNDLE_IMAGES, PublishedImage, published_digest from agent_env.store.base import NotFoundError from agent_env.store.document_store import LocalSqliteDocumentStore @@ -45,10 +45,10 @@ DB_WEB_IMAGE_NAME = "agent-env-db-web" DB_MCP_DOCKERFILE = _ENV_PACKAGE / "envs" / "service_db" / "Dockerfile.db-mcp" DB_MCP_IMAGE_NAME = "agent-env-db-mcp" -WEBSITE_BROWSER_DOCKERFILE = _ENV_PACKAGE / "envs" / "website_browser" / "Dockerfile" -WEBSITE_BROWSER_CONTEXT = WEBSITE_BROWSER_DOCKERFILE.parent +PLAYWRIGHT_MCP_DOCKERFILE = _ENV_PACKAGE / "envs" / "playwright_mcp" / "Dockerfile" +PLAYWRIGHT_MCP_CONTEXT = PLAYWRIGHT_MCP_DOCKERFILE.parent -GATEWAY, SERVICE_DB, WEBSITE_BROWSER = "gateway", "service-db", "website-browser" # the kinds, named as their commands +GATEWAY, SERVICE_DB, WEBSITE_BROWSER = "gateway", "service-db", "website-browser" # the kinds MISSING = "missing" # the reason an infra env the store doesn't hold is set up @@ -93,18 +93,18 @@ def put_service_db_env(env_id: str, *, platform: str | None, metadata: Mapping[s return env -def put_website_browser_env(env_id: str, *, platform: str | None, metadata: Mapping[str, Any] | None = None, - say: Callable[[str], None] = _quiet, published: Mapping[Path, str] | None = None, - version: int | None = None) -> MCPServerEnv: - """Write the website browser as the MCP server env ``env_id``, its image registered from ``published``, each - Dockerfile's published digest, when given, else built for ``platform`` (this host's when None). It's stored as - ``version`` when given, which must be the env's next: ConcurrentModificationError otherwise.""" +def put_playwright_mcp_env(env_id: str, *, platform: str | None, metadata: Mapping[str, Any] | None = None, + say: Callable[[str], None] = _quiet, published: Mapping[Path, str] | None = None, + version: int | None = None) -> MCPServerEnv: + """Write the Playwright MCP website browser as the MCP server env ``env_id``, its image registered from + ``published``, each Dockerfile's published digest, when given, else built for ``platform`` (this host's when None). + It's stored as ``version`` when given, which must be the env's next: ConcurrentModificationError otherwise.""" _check_version(version) - artifact = _image("website browser", derive_id(env_id, "env_image"), "Website browser MCP server", - WEBSITE_BROWSER_DOCKERFILE, WEBSITE_BROWSER_IMAGE_TAG, platform=platform, say=say, + artifact = _image("Playwright MCP", derive_id(env_id, "env_image"), "Playwright MCP website browser", + PLAYWRIGHT_MCP_DOCKERFILE, PLAYWRIGHT_MCP_IMAGE_TAG, platform=platform, say=say, published=published) say("Creating MCPServerEnv...") - env = MCPServerEnv.put(id=env_id, docker_image_artifact=artifact, environment_name=WEBSITE_BROWSER_ENVIRONMENT_NAME, + env = MCPServerEnv.put(id=env_id, docker_image_artifact=artifact, environment_name=PLAYWRIGHT_MCP_ENVIRONMENT_NAME, metadata=_metadata(WEBSITE_BROWSER, metadata), version=version) say(f"Created MCPServerEnv: id={env.id} version={env.version} environment_name={env.environment_name}") return env @@ -153,7 +153,7 @@ def _metadata(kind: str, extra: Mapping[str, Any] | None) -> dict[str, Any]: _DOCKERFILES = { GATEWAY: [GATEWAY_DOCKERFILE], SERVICE_DB: [SERVICE_DB_DOCKERFILE, DB_WEB_DOCKERFILE, DB_MCP_DOCKERFILE], - WEBSITE_BROWSER: [WEBSITE_BROWSER_DOCKERFILE], + WEBSITE_BROWSER: [PLAYWRIGHT_MCP_DOCKERFILE], } # The image each shipped Dockerfile is published as: the infra envs', the shipped agents' and an example env's. PUBLISHED_IMAGES = ( @@ -161,7 +161,7 @@ def _metadata(kind: str, extra: Mapping[str, Any] | None) -> dict[str, Any]: PublishedImage("agentenv-service-db", SERVICE_DB_DOCKERFILE), PublishedImage("agentenv-db-web", DB_WEB_DOCKERFILE), PublishedImage("agentenv-db-mcp", DB_MCP_DOCKERFILE), - PublishedImage("agentenv-website-browser", WEBSITE_BROWSER_DOCKERFILE), + PublishedImage("agentenv-playwright-mcp", PLAYWRIGHT_MCP_DOCKERFILE), *BUNDLE_IMAGES, ) _PUBLISHED = {image.dockerfile: image for image in PUBLISHED_IMAGES} @@ -193,7 +193,7 @@ def build_inputs_digest(kind: str) -> str: return digest.hexdigest() -_PUTS = {GATEWAY: put_gateway_env, SERVICE_DB: put_service_db_env, WEBSITE_BROWSER: put_website_browser_env} +_PUTS = {GATEWAY: put_gateway_env, SERVICE_DB: put_service_db_env, WEBSITE_BROWSER: put_playwright_mcp_env} class InfraError(ValueError): @@ -244,14 +244,36 @@ def _images(self) -> str: return "images" if len(_DOCKERFILES[self.kind]) > 1 else "image" +# The config key naming each kind's env, under [envs] in config.toml. +_DEFAULT_ENV_ID_KEYS = {GATEWAY: "default_gateway_env_id", SERVICE_DB: "default_service_db_env_id", + WEBSITE_BROWSER: "default_website_browser_env_id"} + + def default_env_id(kind: str) -> str: - config = get_config() - return {GATEWAY: config.default_gateway_env_id, SERVICE_DB: config.default_service_db_env_id, - WEBSITE_BROWSER: config.default_website_browser_env_id}[kind] + return get_config().get_default_env_id(_DEFAULT_ENV_ID_KEYS[kind]) + + +def ships(kind: str, env_id: str) -> bool: + """Whether agent-env sets up ``kind``'s env as ``env_id``: the gateway and service-db under any id, as it ships one + of each, and the website browser only under the id of the one it ships, since the slot may name any browser.""" + return kind != WEBSITE_BROWSER or env_id == PLAYWRIGHT_MCP_ENVIRONMENT_NAME -def put_command(kind: str) -> str: - return f"agent-env env {kind} put --id {default_env_id(kind)}" +def put_hint(kind: str) -> str: + """How to put ``kind``'s env under the id config names, as a message says it. The website browser is an MCP server + env, which ``mcp-server put`` writes: the one agent-env ships over the image this install's release publishes when + there is one, else built from its Dockerfile; any other browser over its own image.""" + env_id = default_env_id(kind) + if kind != WEBSITE_BROWSER: + return f"`agent-env env {kind} put --id {env_id}`" + image = _PUBLISHED[PLAYWRIGHT_MCP_DOCKERFILE] + shipped = (f"--environment-name {PLAYWRIGHT_MCP_ENVIRONMENT_NAME} " + + (f"--image-ref {image.ref()}" if published_digest(image) is not None + else f"--dockerfile {PLAYWRIGHT_MCP_DOCKERFILE}")) + if ships(kind, env_id): + return f"`agent-env env mcp-server put --id {env_id} {shipped}`" + return (f"`agent-env env mcp-server put --id {env_id} --environment-name --image-ref `, " + f"as for the browser agent-env ships: `{shipped}`") def infra_to_set_up(kinds: Iterable[str], *, missing_only: bool = False) -> list[InfraSetup]: @@ -269,11 +291,14 @@ def infra_to_set_up(kinds: Iterable[str], *, missing_only: bool = False) -> list try: env = Env.get(env_id) except NotFoundError: - if local: + if not ships(kind, env_id): + problems.append(f"the {kind} env {env_id!r} isn't in the store, and agent-env sets up only the website " + f"browser it ships, {PLAYWRIGHT_MCP_ENVIRONMENT_NAME!r}; put it with {put_hint(kind)}") + elif local: setups.append(_pulled_or_built(kind, env_id, MISSING)) else: problems.append(f"the {kind} env {env_id!r} isn't in the store, and agent-env builds infra envs only into " - f"local stores; put it with `{put_command(kind)}`") + f"local stores; put it with {put_hint(kind)}") continue if missing_only or not (local and _built_from_stock(env, kind)): continue diff --git a/src/agent_env/env/envs/multi_env.py b/src/agent_env/env/envs/multi_env.py index 0e2e3758e..e1e055263 100644 --- a/src/agent_env/env/envs/multi_env.py +++ b/src/agent_env/env/envs/multi_env.py @@ -40,7 +40,7 @@ def _snapshot_after_load_default() -> bool: ) from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.website import WebsiteEnv -from agent_env.env.envs.website_browser import WEBSITE_BROWSER_RECORDER_PORT +from agent_env.env.envs.playwright_mcp import PLAYWRIGHT_MCP_RECORDER_PORT from agent_env.store.ids import derive_id from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_CONTAINER from agent_env.attribution import Attribution @@ -587,7 +587,7 @@ async def _load_from_snapshot(self, snapshot) -> None: from agent_env.env.envs.service_db import DATABASE_SERVICE_NAME, DB_MCP_SERVICE_NAME, PGWEB_SERVICE_NAME from agent_env.providers.env_providers.constants import DOCKER_COMPOSE_PATH, GATEWAY_APP_DIR, GATEWAY_SERVICE_NAME from agent_env.providers.env_state import LocalPostgresStateProvider - from agent_env.config import gateway_env_id, get_config, service_db_env_id + from agent_env.config import gateway_env_id, get_config, service_db_env_id, website_browser_env_id if self._sandbox is None: raise RuntimeError("Environment not deployed - call deploy() first") @@ -617,9 +617,9 @@ async def _load_from_snapshot(self, snapshot) -> None: for env in self.website_envs ] if website_configs: - website_browser_env = Env.get(config.default_website_browser_env_id) + website_browser_env = Env.get(website_browser_env_id(config)) mcp_servers = list(mcp_servers) + [MCPServerConfig(image=website_browser_env.docker_image_artifact.image_name, environment_name=website_browser_env.environment_name, - card_port=WEBSITE_BROWSER_RECORDER_PORT)] + card_port=PLAYWRIGHT_MCP_RECORDER_PORT)] compose_content = gw.create_docker_compose( mcp_servers=mcp_servers, diff --git a/src/agent_env/env/envs/website_browser/Dockerfile b/src/agent_env/env/envs/playwright_mcp/Dockerfile similarity index 100% rename from src/agent_env/env/envs/website_browser/Dockerfile rename to src/agent_env/env/envs/playwright_mcp/Dockerfile diff --git a/src/agent_env/env/envs/playwright_mcp/__init__.py b/src/agent_env/env/envs/playwright_mcp/__init__.py new file mode 100644 index 000000000..f3204f6a9 --- /dev/null +++ b/src/agent_env/env/envs/playwright_mcp/__init__.py @@ -0,0 +1,16 @@ +"""Constants for the Playwright MCP website browser, Microsoft's Playwright MCP server (``@playwright/mcp``).""" + +# Docker image tag for the browser's image when built locally. +PLAYWRIGHT_MCP_IMAGE_TAG = "mcp-playwright-mcp" + +# The env id and environment_name its put writes. +PLAYWRIGHT_MCP_ENVIRONMENT_NAME = "playwright-mcp" + +# The port the image's recorder serves the browser's env card on (recorder.py, entrypoint.sh's RECORDER_PORT). +PLAYWRIGHT_MCP_RECORDER_PORT = 18766 + +__all__ = [ + "PLAYWRIGHT_MCP_IMAGE_TAG", + "PLAYWRIGHT_MCP_ENVIRONMENT_NAME", + "PLAYWRIGHT_MCP_RECORDER_PORT", +] diff --git a/src/agent_env/env/envs/website_browser/entrypoint.sh b/src/agent_env/env/envs/playwright_mcp/entrypoint.sh similarity index 100% rename from src/agent_env/env/envs/website_browser/entrypoint.sh rename to src/agent_env/env/envs/playwright_mcp/entrypoint.sh diff --git a/src/agent_env/env/envs/website_browser/recorder-requirements.txt b/src/agent_env/env/envs/playwright_mcp/recorder-requirements.txt similarity index 100% rename from src/agent_env/env/envs/website_browser/recorder-requirements.txt rename to src/agent_env/env/envs/playwright_mcp/recorder-requirements.txt diff --git a/src/agent_env/env/envs/website_browser/recorder.py b/src/agent_env/env/envs/playwright_mcp/recorder.py similarity index 96% rename from src/agent_env/env/envs/website_browser/recorder.py rename to src/agent_env/env/envs/playwright_mcp/recorder.py index a849a10fa..c82dbedf2 100644 --- a/src/agent_env/env/envs/website_browser/recorder.py +++ b/src/agent_env/env/envs/playwright_mcp/recorder.py @@ -1,7 +1,7 @@ -"""The website browser's recorder: computer-recording/v1 of the page Playwright MCP drives, from Chrome DevTools +"""The Playwright MCP browser's recorder: computer-recording/v1 of the page Playwright MCP drives, from Chrome DevTools screencast frames, on a card and port of its own beside the MCP server. It is idle until a recording starts, which finds the browser through the remote debugging port Playwright opens for an ``--isolated`` Chromium and follows the -page the agent is on. It runs standalone in the website browser's image, so it imports only +page the agent is on. It runs standalone in the browser's image, so it imports only ``agentenv-framework-protocol[computer]`` and, to talk to the browser, ``websockets``.""" from __future__ import annotations @@ -31,10 +31,10 @@ from agentenv_protocol.computer.card import COMPUTER_RECORDING from agentenv_protocol.computer.recording import RecordedFrame, RecordHints, Recording, Recordings, recording_params -logger = logging.getLogger("website-browser-recorder") +logger = logging.getLogger("playwright-mcp-recorder") #: The port the recorder serves its card on, unless RECORDER_PORT says another; the gateway reads the browser's card -#: there (agent_env.env.envs.website_browser.WEBSITE_BROWSER_RECORDER_PORT). +#: there (agent_env.env.envs.playwright_mcp.PLAYWRIGHT_MCP_RECORDER_PORT). DEFAULT_PORT = 18766 #: How often the browser is looked for while a recording has none. FIND_S = 0.5 @@ -222,9 +222,9 @@ async def _show(self, cdp: Cdp, target: Optional[str]) -> None: self._session = session -@environment_card(name="website-browser") +@environment_card(name="playwright-mcp") class BrowserRecorder(AgentEnvEnvironment): - """computer-recording/v1 of the website browser's page; the browser's tools stay Playwright MCP's.""" + """computer-recording/v1 of the Playwright MCP browser's page; the browser's tools stay Playwright MCP's.""" recording = Recording() diff --git a/src/agent_env/env/envs/website_browser/__init__.py b/src/agent_env/env/envs/website_browser/__init__.py deleted file mode 100644 index f355e25da..000000000 --- a/src/agent_env/env/envs/website_browser/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Website browser (Playwright) MCP server constants.""" - -# Docker image tag for the website browser server image. -WEBSITE_BROWSER_IMAGE_TAG = "mcp-website-browser" - -# The environment_name used when creating MCPServerEnv. -WEBSITE_BROWSER_ENVIRONMENT_NAME = "website-browser" - -# The port the image's recorder serves the browser's env card on (recorder.py, entrypoint.sh's RECORDER_PORT). -WEBSITE_BROWSER_RECORDER_PORT = 18766 - -__all__ = [ - "WEBSITE_BROWSER_IMAGE_TAG", - "WEBSITE_BROWSER_ENVIRONMENT_NAME", - "WEBSITE_BROWSER_RECORDER_PORT", -] diff --git a/src/agent_env/providers/env_providers/constants.py b/src/agent_env/providers/env_providers/constants.py index 7676ac52b..110ef4446 100644 --- a/src/agent_env/providers/env_providers/constants.py +++ b/src/agent_env/providers/env_providers/constants.py @@ -5,10 +5,10 @@ """ GATEWAY_SERVICE_NAME = "gateway" -# The compose services a gateway deploy runs beside its envs' MCP servers, so no env can be named one of them: the -# gateway, the local Postgres state's database and its two browse UIs, and the website browser (named in -# agent_env.env.envs.website_browser). -GATEWAY_SERVICE_NAMES = frozenset({GATEWAY_SERVICE_NAME, "servicedb", "pgweb", "db-mcp", "website-browser"}) +# The compose services every gateway deploy runs beside its envs' MCP servers, so no env can be named one of them: +# the gateway, and the local Postgres state's database and its two browse UIs. The website browser a deploy with +# websites adds is checked against that deploy's own services, whatever it's named, when its compose is rendered. +GATEWAY_SERVICE_NAMES = frozenset({GATEWAY_SERVICE_NAME, "servicedb", "pgweb", "db-mcp"}) GATEWAY_APP_DIR = "/app" AGENT_ENV_WEBSITE_BACKEND_PORT = 8000 diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index 90b1fbe1e..e9c92fb03 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -9,6 +9,7 @@ import re import shlex import time +from collections import Counter from dataclasses import dataclass, field from datetime import datetime, timezone from typing import TYPE_CHECKING, Optional @@ -34,7 +35,7 @@ DB_WEB_PORT, PGWEB_SERVICE_NAME, ) -from agent_env.env.envs.website_browser import WEBSITE_BROWSER_RECORDER_PORT +from agent_env.env.envs.playwright_mcp import PLAYWRIGHT_MCP_RECORDER_PORT from agent_env.providers.env_providers.constants import ( AGENT_ENV_WEBSITE_BACKEND_PORT, AGENT_ENV_WEBSITE_BACKEND_SUFFIX, @@ -42,6 +43,7 @@ DOCKER_COMPOSE_PATH, GATEWAY_APP_DIR, GATEWAY_SERVICE_NAME, + GATEWAY_SERVICE_NAMES, ) from agent_env.providers.env_providers.env_provider import ( DeployRefused, @@ -723,7 +725,7 @@ async def _deploy_via_vm( mcp_server_name: str | None = None, ) -> DeployedGateway: from agent_env.env.env import Env - from agent_env.config import gateway_env_id, get_config + from agent_env.config import gateway_env_id, get_config, website_browser_env_id website_configs = website_configs or [] website_images = website_images or [] @@ -732,13 +734,13 @@ async def _deploy_via_vm( config = get_config() gateway_env = Env.get(gateway_env_id(config)) - # Auto-include website_browser MCP server when websites are present + # Auto-include the website browser's MCP server when websites are present if website_configs: - website_browser_env = Env.get(config.default_website_browser_env_id) + website_browser_env = Env.get(website_browser_env_id(config)) browser_server = MCPServerConfig( image=website_browser_env.docker_image_artifact.image_name, environment_name=website_browser_env.environment_name, - card_port=WEBSITE_BROWSER_RECORDER_PORT, + card_port=PLAYWRIGHT_MCP_RECORDER_PORT, ) mcp_servers = list(mcp_servers) + [browser_server] mcp_server_images = list(mcp_server_images) + [website_browser_env.docker_image_artifact] @@ -1522,11 +1524,21 @@ def environment_name_problem(name: str) -> str | None: def _compose_name_problems(mcp_servers: list[MCPServerConfig], website_configs: list[WebsiteConfig] | None, sidecars: list[SidecarConfig] | None = None, *, images: tuple[str, ...] = ()) -> list[str]: - """Why the environment names and images of a gateway deploy can't go into its compose file and shell lines.""" + """Why the environment names and images of a gateway deploy can't go into its compose file and shell lines: one + can't be a service name, two of its services would share one (a website's are suffixed, so a website may share an + MCP server's name), or one is a service every gateway deploy runs.""" names = [c.environment_name for c in [*mcp_servers, *(website_configs or []), *(sidecars or [])]] refs = [*images, *(s.image for s in mcp_servers), *(s.image for s in sidecars or []), *(ref for w in website_configs or [] for ref in (w.backend_image, w.frontend_image))] - return [problem for problem in [*map(environment_name_problem, names), *map(image_reference_problem, refs)] if problem] + problems = [problem for problem in [*map(environment_name_problem, names), *map(image_reference_problem, refs)] + if problem] + services = Counter([*(c.environment_name for c in [*mcp_servers, *(sidecars or [])]), + *(f"{w.environment_name}-{suffix}" for w in website_configs or [] + for suffix in (AGENT_ENV_WEBSITE_BACKEND_SUFFIX, AGENT_ENV_WEBSITE_FRONTEND_SUFFIX))]) + problems += [f"two of its services would both be named {name!r}" for name, count in services.items() if count > 1] + problems += [f"environment name {name!r} is one a gateway deploy names its own containers " + f"({', '.join(sorted(GATEWAY_SERVICE_NAMES))})" for name in services if name in GATEWAY_SERVICE_NAMES] + return problems # Compose `environment:` keys whose values are secrets and must be masked before the diff --git a/tst/integration/env/gateway/gateway_test.py b/tst/integration/env/gateway/gateway_test.py index 91aa661bd..5d0cbf6b6 100644 --- a/tst/integration/env/gateway/gateway_test.py +++ b/tst/integration/env/gateway/gateway_test.py @@ -33,9 +33,9 @@ _SCHEMA as GET_TIME_SCHEMA, ) from agent_env.env.envs.service_db import ServiceDBEnv -from agent_env.env.envs.website_browser import ( - WEBSITE_BROWSER_IMAGE_TAG, - WEBSITE_BROWSER_ENVIRONMENT_NAME, +from agent_env.env.envs.playwright_mcp import ( + PLAYWRIGHT_MCP_IMAGE_TAG, + PLAYWRIGHT_MCP_ENVIRONMENT_NAME, ) from agent_env.env.snapshot_store import compute_env_fingerprint from agent_env.config import get_config @@ -118,12 +118,12 @@ class DockerImageConfig: ), ] -WEBSITE_BROWSER_IMAGE = DockerImageConfig( - name="website-browser", - dockerfile=ENVS_DIR / "website_browser" / "Dockerfile", - tag=WEBSITE_BROWSER_IMAGE_TAG, - context=ENVS_DIR / "website_browser", - service_name=WEBSITE_BROWSER_ENVIRONMENT_NAME, +PLAYWRIGHT_MCP_IMAGE = DockerImageConfig( + name="playwright-mcp", + dockerfile=ENVS_DIR / "playwright_mcp" / "Dockerfile", + tag=PLAYWRIGHT_MCP_IMAGE_TAG, + context=ENVS_DIR / "playwright_mcp", + service_name=PLAYWRIGHT_MCP_ENVIRONMENT_NAME, ) SLACK_WEBSITE_BACKEND_IMAGE = DockerImageConfig( @@ -233,8 +233,8 @@ def service_db_env() -> ServiceDBEnv: @pytest.fixture(scope="module") def website_browser_env() -> MCPServerEnv: - """Build website-browser image, create artifact, and create MCPServerEnv.""" - img = WEBSITE_BROWSER_IMAGE + """Build the Playwright MCP browser's image, create artifact, and create MCPServerEnv.""" + img = PLAYWRIGHT_MCP_IMAGE logger.info(f"Building {img.name} Docker image...") result = subprocess.run( ["docker", "build", "--platform", "linux/amd64", @@ -246,16 +246,16 @@ def website_browser_env() -> MCPServerEnv: raise RuntimeError(f"{img.name} build failed: {result.stderr}") artifact = DockerImageArtifact.put( - id=f"website-browser-{img.name}", - description="Website browser MCP server image", + id=f"playwright-mcp-{img.name}", + description="Playwright MCP website browser image", image_name=img.tag, ) env = MCPServerEnv.put( - id="website-browser", + id="playwright-mcp", docker_image_artifact=artifact, - environment_name=WEBSITE_BROWSER_ENVIRONMENT_NAME, + environment_name=PLAYWRIGHT_MCP_ENVIRONMENT_NAME, ) - logger.info(f"Created website-browser MCPServerEnv: {env.id} version={env.version}") + logger.info(f"Created playwright-mcp MCPServerEnv: {env.id} version={env.version}") return env diff --git a/tst/unit/bundle/env_toml_test.py b/tst/unit/bundle/env_toml_test.py index 9ba1468c4..25c27f82f 100644 --- a/tst/unit/bundle/env_toml_test.py +++ b/tst/unit/bundle/env_toml_test.py @@ -195,7 +195,7 @@ def _website(name, toml=""): "'_', '.' and '-'"), ({**_mcp("x"), "envs/x/env.toml": 'environment_name = "gateway"\n'}, "envs/x: env.toml: environment_name 'gateway' is one a gateway deploy names its own containers (db-mcp, gateway, " - "pgweb, servicedb, website-browser); choose another"), + "pgweb, servicedb); choose another"), (_website("x", 'env_provider_type = "server"\n'), "envs/x: env.toml: env_provider_type 'server' deploys one MCP server, not a website env"), ({**_mcp("x"), "envs/x/env.toml": 'image = { dockerfile = "Dockerfile", build_args = { A = "1" } }\n'}, diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 1248b24f2..4c92736be 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -797,6 +797,19 @@ def test_missing_infra_in_stores_that_arent_local_is_refused_naming_its_put_comm ] +def test_a_missing_browser_agent_env_doesnt_ship_is_reported_once_for_a_deploy_on_another_provider(bundle_dir, + monkeypatch): + with namespace_routing(): + WebsiteEnv.put(id="shop", backend_docker_image_artifact=_image("shop-back", REMOTE), + frontend_docker_image_artifact=_image("shop-front", REMOTE), environment_name="shop") + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "shop"}]) + monkeypatch.setattr(get_config(), "default_website_browser_env_id", "browseruse-mcp") + + problems = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) + assert [problem for problem in problems if "browseruse-mcp" in problem] == [next( + problem for problem in problems if problem.startswith("the website-browser env 'browseruse-mcp' isn't in the store"))] + + def test_a_gateway_deploy_on_another_provider_has_its_missing_infra_built_in_local_stores(bundle_dir, monkeypatch): """Built for this machine, with the build context a VM of another platform builds it from.""" _env("crm", REMOTE) diff --git a/tst/unit/cli/build_platform_test.py b/tst/unit/cli/build_platform_test.py index f4a217d61..9a913d5d8 100644 --- a/tst/unit/cli/build_platform_test.py +++ b/tst/unit/cli/build_platform_test.py @@ -8,12 +8,10 @@ from click.testing import CliRunner from agent_env.cli import cli -from agent_env.env.envs.website_browser import WEBSITE_BROWSER_IMAGE_TAG from agent_env.utils.docker.build import DockerBuildError gateway = importlib.import_module("agent_env.cli.env.gateway") # the packages export the click commands service_db = importlib.import_module("agent_env.cli.env.service_db") -website_browser = importlib.import_module("agent_env.cli.env.website_browser") def test_platform_option_wired_on_mcp_server_put(): @@ -48,13 +46,10 @@ def _put_commands(tmp_path): "service-db": ("agent_env.env.bootstrap", ["env", "service-db", "put", "--id", "x"], (service_db.SERVICE_DB_DOCKERFILE, service_db.SERVICE_DB_DOCKERFILE.parent, service_db.SERVICE_DB_IMAGE_NAME), None), - "website-browser": ("agent_env.env.bootstrap", ["env", "website-browser", "put", "--id", "x"], - (website_browser.WEBSITE_BROWSER_DOCKERFILE, website_browser.WEBSITE_BROWSER_CONTEXT, - WEBSITE_BROWSER_IMAGE_TAG), None), } -@pytest.mark.parametrize("name", ["a2a-agent", "mcp-server", "website", "gateway", "service-db", "website-browser"]) +@pytest.mark.parametrize("name", ["a2a-agent", "mcp-server", "website", "gateway", "service-db"]) def test_each_put_builds_through_build_image_and_a_failed_build_is_one_line(tmp_path, name): module, argv, (dockerfile, context, tag), build_args = _put_commands(tmp_path)[name] diff --git a/tst/unit/cli/sandbox_url_relocation_test.py b/tst/unit/cli/sandbox_url_relocation_test.py index 7ab590475..bfe260f52 100644 --- a/tst/unit/cli/sandbox_url_relocation_test.py +++ b/tst/unit/cli/sandbox_url_relocation_test.py @@ -116,25 +116,8 @@ def test_service_db_put_explicit_id_wins_and_skips_the_config_read(): get_config.assert_not_called() -def test_website_browser_put_falls_back_to_the_config_default(): - from agent_env.cli.env.website_browser import website_browser - - cfg = _config(default_website_browser_env_id="wb-from-config") - with patch("agent_env.config.get_config", return_value=cfg), \ - patch("agent_env.env.bootstrap.DockerImageArtifact") as artifact, \ - patch("agent_env.env.bootstrap.build_image"): - artifact.put.side_effect = RuntimeError("stop once the id is recorded") - res = CliRunner().invoke(website_browser, ["put"]) - - assert artifact.put.call_args.kwargs["id"] == "wb-from-config__env_image" - assert res.exit_code != 0 - - def test_help_text_names_the_config_key_for_each_lazy_default(): from agent_env.cli.env.service_db import service_db - from agent_env.cli.env.website_browser import website_browser runner = CliRunner() assert "default_service_db_env_id" in runner.invoke(service_db, ["put", "--help"]).output - assert "default_website_browser_env_id" in runner.invoke( - website_browser, ["put", "--help"]).output diff --git a/tst/unit/cli/submit_runs_test.py b/tst/unit/cli/submit_runs_test.py index da5d83159..42805f6cf 100644 --- a/tst/unit/cli/submit_runs_test.py +++ b/tst/unit/cli/submit_runs_test.py @@ -92,12 +92,13 @@ def test_a_context_json_is_stored_as_an_instance_the_runner_continues(monkeypatc saved.write_text(json.dumps(TaskStepContext(metadata={"seeded": "yes"}).to_dict())) result = _invoke(monkeypatch, task, "task", "run", "--id", "t", "--context-json", str(saved), "--start-step", "1", - "--gateway-env-id", "gw-run", "--output-dir", str(tmp_path)) + "--gateway-env-id", "gw-run", "--website-browser-env-id", "wb-run", "--output-dir", str(tmp_path)) assert result.exit_code == 0, result.output [call], [context] = task.calls, task.contexts assert call["start_step"] == 1 and context.metadata["seeded"] == "yes" assert context.metadata["user_overrides"]["gateway_env_id"] == "gw-run" + assert context.metadata["user_overrides"]["website_browser_env_id"] == "wb-run" assert call["instance_id"] == run_store.list_runs(task_id="t")[0].instance_id diff --git a/tst/unit/cli/task_run_requires_test.py b/tst/unit/cli/task_run_requires_test.py index 6c4fb6221..692ecb729 100644 --- a/tst/unit/cli/task_run_requires_test.py +++ b/tst/unit/cli/task_run_requires_test.py @@ -13,8 +13,9 @@ from agent_env.cli.task import _checks from agent_env.config import get_config from agent_env.env import MCPServerEnv +from agent_env.env.envs.website import WebsiteEnv from agent_env.entity.refs import EntityKind -from agent_env.env.bootstrap import GATEWAY, SERVICE_DB +from agent_env.env.bootstrap import GATEWAY, SERVICE_DB, WEBSITE_BROWSER from agent_env.env.store import ENVS_COLLECTION from agent_env.plugins import _requirements from agent_env.store.document_store import UnknownDocumentTypeError @@ -160,6 +161,28 @@ def test_the_infra_envs_its_deploys_run_on_are_set_up_in_local_stores_but_for_th assert set_up == ([(kinds, True)] if kinds else []) +@pytest.mark.parametrize("overrides, kinds", [ + ({}, {GATEWAY, SERVICE_DB, WEBSITE_BROWSER}), + ({"website_browser_env_id": "mine"}, {GATEWAY, SERVICE_DB}), +], ids=["local", "own-browser"]) +def test_a_website_deploy_sets_up_the_browser_too_unless_the_run_names_its_own(stores, monkeypatch, overrides, kinds): + with namespace_routing(): + backend, frontend = (get_artifact_store().put_document(DockerImageArtifact( + id=f"shop-{part}", description=part, image_name=f"shop-{part}:latest", tar_gz_s3_url=None, platform=None)) + for part in ("backend", "frontend")) + WebsiteEnv.put(id="shop", backend_docker_image_artifact=backend, frontend_docker_image_artifact=frontend, + environment_name="shop") + _stored([{"id": "env", "type": "deploy_env", "env_id": "shop"}]) + set_up = [] + monkeypatch.setattr(_checks, "stores_are_local", lambda: True) + monkeypatch.setattr(_checks, "ensure_default_envs", + lambda kinds, say, missing_only: set_up.append((set(kinds), missing_only))) + + _checks.runnable_task("t", None, {"user_overrides": overrides}, say=print) + + assert set_up == [(kinds, True)] + + def _mcp_env(id): with namespace_routing(): image = get_artifact_store().put_document(DockerImageArtifact( diff --git a/tst/unit/cli/test_removed_cli_aliases.py b/tst/unit/cli/test_removed_cli_aliases.py index 478596e81..f4930b422 100644 --- a/tst/unit/cli/test_removed_cli_aliases.py +++ b/tst/unit/cli/test_removed_cli_aliases.py @@ -58,6 +58,13 @@ def test_removed_flag_is_gone_and_replacement_survives(path, removed, replacemen assert replacement in opts, f"replacement {replacement!r} went missing with it" +def test_the_website_browser_has_no_put_command_of_its_own(): + """It's an MCP server env, which `env mcp-server put` writes, and the remote-store hint names that.""" + env = cli.commands["env"].commands + assert "website-browser" not in env and "playwright-mcp" not in env + assert "mcp-server" in env + + def test_no_deprecated_command_is_registered_anywhere(): """Catches a legacy spelling registered under a name this module does not enumerate.""" diff --git a/tst/unit/config/test_default_env_ids.py b/tst/unit/config/test_default_env_ids.py new file mode 100644 index 000000000..7bec96049 --- /dev/null +++ b/tst/unit/config/test_default_env_ids.py @@ -0,0 +1,58 @@ +"""Default infra env ids: one set in code (configure() or assignment) > [envs] in config.toml > the built-in id.""" + +import pytest + +from agent_env.config.errors import ConfigError +from agent_env.config.runtime import configure, get_config, reset_config +from tst.util.config import config_with_document + +BUILT_IN = {"default_gateway_env_id": "default", "default_service_db_env_id": "default-db", + "default_website_browser_env_id": "playwright-mcp"} + + +def _config(envs=None): + """A Config with its config.toml read stubbed: no file discovery, no AWS.""" + return config_with_document({} if envs is None else {"envs": envs}) + + +@pytest.mark.parametrize("name", BUILT_IN) +def test_the_built_in_id_when_envs_names_none(name): + assert getattr(_config(), name) == BUILT_IN[name] + assert getattr(_config({"impls": []}), name) == BUILT_IN[name] + + +@pytest.mark.parametrize("name", BUILT_IN) +def test_an_envs_key_names_it_beside_the_registry_impls(name): + assert getattr(_config({"impls": [], name: "from-file"}), name) == "from-file" + + +def test_an_envs_key_resolves_env_references(): + assert _config({"default_website_browser_env_id": "env:MISSING?browser-use"}).default_website_browser_env_id \ + == "browser-use" + + +@pytest.mark.parametrize("name", BUILT_IN) +def test_an_id_set_in_code_beats_the_envs_key(name): + config = _config({name: "from-file"}) + setattr(config, name, "from-code") + assert getattr(config, name) == "from-code" + + +@pytest.mark.parametrize("name", BUILT_IN) +def test_a_constructor_argument_beats_the_envs_key_and_none_leaves_it(name): + assert getattr(config_with_document({"envs": {name: "from-file"}}, **{name: "from-code"}), name) == "from-code" + assert getattr(config_with_document({"envs": {name: "from-file"}}, **{name: None}), name) == "from-file" + + +def test_configure_sets_one_on_the_singleton_and_leaves_the_others(): + try: + configure(default_website_browser_env_id="from-code") + assert get_config().default_website_browser_env_id == "from-code" + assert get_config().default_gateway_env_id == "default" + finally: + reset_config() + + +def test_an_empty_envs_key_is_rejected_naming_it(): + with pytest.raises(ConfigError, match=r"\[envs\] default_website_browser_env_id"): + _config({"default_website_browser_env_id": ""}).default_website_browser_env_id diff --git a/tst/unit/config/test_run_env_ids.py b/tst/unit/config/test_run_env_ids.py index ea7b04ef2..03d358cc7 100644 --- a/tst/unit/config/test_run_env_ids.py +++ b/tst/unit/config/test_run_env_ids.py @@ -1,4 +1,5 @@ -"""A run's ``gateway_env_id`` / ``service_db_env_id`` overrides name the infra envs its deploys use, only within it.""" +"""A run's ``gateway_env_id`` / ``service_db_env_id`` / ``website_browser_env_id`` overrides name the infra envs its +deploys use, only within it.""" from __future__ import annotations @@ -6,7 +7,7 @@ import pytest -from agent_env.config import gateway_env_id, get_config, service_db_env_id, set_document_store +from agent_env.config import gateway_env_id, get_config, service_db_env_id, set_document_store, website_browser_env_id from agent_env.store import LocalSqliteDocumentStore from agent_env.task.task import Task from agent_env.task_step.context import TaskStepContext @@ -21,7 +22,7 @@ def __init__(self, both_running: asyncio.Barrier): async def execute(self, context: TaskStepContext) -> TaskStepContext: await self.both_running.wait() - self.seen = (gateway_env_id(get_config()), service_db_env_id(get_config())) + self.seen = (gateway_env_id(get_config()), service_db_env_id(get_config()), website_browser_env_id(get_config())) return context @@ -32,8 +33,11 @@ async def test_concurrent_runs_each_use_their_own_infra_envs_and_only_within_the probes = {name: _Probe(both_running) for name in ("a", "b")} await asyncio.gather(*(Task(id=f"t-{name}", version=1, steps=[probe]).run(context=TaskStepContext(metadata={ - "user_overrides": {"gateway_env_id": f"gw-{name}", "service_db_env_id": f"db-{name}"}})) + "user_overrides": {"gateway_env_id": f"gw-{name}", "service_db_env_id": f"db-{name}", + "website_browser_env_id": f"wb-{name}"}})) for name, probe in probes.items())) - assert {name: probe.seen for name, probe in probes.items()} == {"a": ("gw-a", "db-a"), "b": ("gw-b", "db-b")} + assert {name: probe.seen for name, probe in probes.items()} == {"a": ("gw-a", "db-a", "wb-a"), + "b": ("gw-b", "db-b", "wb-b")} assert gateway_env_id(get_config()) == get_config().default_gateway_env_id + assert website_browser_env_id(get_config()) == get_config().default_website_browser_env_id diff --git a/tst/unit/config/test_unread_keys.py b/tst/unit/config/test_unread_keys.py index 72b83bc65..70fbfcce8 100644 --- a/tst/unit/config/test_unread_keys.py +++ b/tst/unit/config/test_unread_keys.py @@ -106,6 +106,9 @@ def _warnings(tmp_path, monkeypatch, body: str) -> list[str]: [envs] impls = [] +default_gateway_env_id = "a-gateway" +default_service_db_env_id = "a-db" +default_website_browser_env_id = "a-browser" [artifacts] impls = [] @@ -321,7 +324,8 @@ def test_every_key_a_reader_asks_for_is_declared(monkeypatch): readers = [config.env_registry, config.task_step_registry, config.artifact_registry, config.sandbox_registry, config.state_registry, sandbox_provider._default_sandbox_spec, sandbox_provider._agent_sandbox_spec, lambda: sandbox_provider.apply_default_attribution({}), explorer_app.explorer_settings, - lambda: explorer_plugin.load_plugins(source=config)] + lambda: explorer_plugin.load_plugins(source=config), lambda: config.default_gateway_env_id, + lambda: config.default_service_db_env_id, lambda: config.default_website_browser_env_id] for read in readers: try: read() diff --git a/tst/unit/env/bootstrap_test.py b/tst/unit/env/bootstrap_test.py index c583f21c3..7e54a0ff5 100644 --- a/tst/unit/env/bootstrap_test.py +++ b/tst/unit/env/bootstrap_test.py @@ -11,7 +11,8 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.store import get_artifact_store from agent_env.cli.up import _bootstrap_envs, up -from agent_env.env import GatewayEnv +from agent_env.config import get_config +from agent_env.env import GatewayEnv, MCPServerEnv from agent_env.env import bootstrap from agent_env.env.bootstrap import GATEWAY, SERVICE_DB, WEBSITE_BROWSER, InfraError, ensure_default_envs from agent_env.env.env import Env @@ -21,7 +22,7 @@ _DIGEST = "sha256:" + "5" * 64 _INFRA_PUTS = pytest.mark.parametrize("kind, put", [ (GATEWAY, bootstrap.put_gateway_env), (SERVICE_DB, bootstrap.put_service_db_env), - (WEBSITE_BROWSER, bootstrap.put_website_browser_env), + (WEBSITE_BROWSER, bootstrap.put_playwright_mcp_env), ], ids=[GATEWAY, SERVICE_DB, WEBSITE_BROWSER]) @@ -86,10 +87,10 @@ def test_missing_infra_is_built_for_this_host_in_order_and_once(puts): builds = ensure_default_envs([WEBSITE_BROWSER, GATEWAY, SERVICE_DB], say=said.append) - assert puts == [(SERVICE_DB, "default-db", None), (GATEWAY, "default", None), (WEBSITE_BROWSER, "website-browser", None)] + assert puts == [(SERVICE_DB, "default-db", None), (GATEWAY, "default", None), (WEBSITE_BROWSER, "playwright-mcp", None)] assert [str(build) for build in builds] == [ "service-db env 'default-db' (missing)", "gateway env 'default' (missing)", - "website-browser env 'website-browser' (missing)"] + "website-browser env 'playwright-mcp' (missing)"] assert said[0] == "service-db env 'default-db' (missing): building, which can take minutes" assert ensure_default_envs([GATEWAY, SERVICE_DB]) == [] assert len(puts) == 3 @@ -142,8 +143,8 @@ def test_infra_that_records_no_shipped_dockerfile_is_left_as_it_is(puts): def test_an_env_built_from_someone_elses_dockerfile_is_theirs_to_rebuild(puts, published): artifact = get_artifact_store().put_document(DockerImageArtifact( - id="website-browser__env_image", description="b", image_name="b:v1", tar_gz_s3_url="file:///b.tar.gz")) - GatewayEnv.put(id="website-browser", docker_image_artifact=artifact, + id="playwright-mcp__env_image", description="b", image_name="b:v1", tar_gz_s3_url="file:///b.tar.gz")) + GatewayEnv.put(id="playwright-mcp", docker_image_artifact=artifact, metadata={"dockerfile_path": "/home/me/my-browser/Dockerfile", bootstrap.BUILD_INPUTS_KEY: "theirs"}) published.add(WEBSITE_BROWSER) @@ -167,6 +168,39 @@ def test_nothing_is_built_without_docker(puts, monkeypatch): assert puts == [] +@pytest.mark.parametrize("published_digest, source", [ + ("sha256:" + "5" * 64, "--image-ref " + bootstrap._PUBLISHED[bootstrap.PLAYWRIGHT_MCP_DOCKERFILE].ref()), + (None, f"--dockerfile {bootstrap.PLAYWRIGHT_MCP_DOCKERFILE}"), +], ids=["published", "unpublished"]) +def test_the_website_browser_is_put_with_mcp_server_put_over_the_published_image_when_there_is_one( + local_stores, monkeypatch, published_digest, source): + monkeypatch.setattr(bootstrap, "published_digest", lambda image: published_digest) + + assert bootstrap.put_hint(WEBSITE_BROWSER) == ( + f"`agent-env env mcp-server put --id playwright-mcp --environment-name playwright-mcp {source}`") + assert bootstrap.put_hint(GATEWAY) == "`agent-env env gateway put --id default`" + + +def test_a_browser_agent_env_doesnt_ship_is_never_set_up_and_its_hint_is_generic(puts, monkeypatch): + monkeypatch.setattr(bootstrap, "published_digest", lambda image: None) + monkeypatch.setattr(get_config(), "default_website_browser_env_id", "browseruse-mcp") + + with pytest.raises(InfraError) as e: + ensure_default_envs([WEBSITE_BROWSER, GATEWAY]) + assert e.value.problems == [ + "the website-browser env 'browseruse-mcp' isn't in the store, and agent-env sets up only the website browser " + "it ships, 'playwright-mcp'; put it with `agent-env env mcp-server put --id browseruse-mcp --environment-name " + " --image-ref `, as for the browser agent-env ships: `--environment-name playwright-mcp " + f"--dockerfile {bootstrap.PLAYWRIGHT_MCP_DOCKERFILE}`"] + assert puts == [] + + MCPServerEnv.put(id="browseruse-mcp", environment_name="browseruse-mcp", docker_image_artifact=get_artifact_store( + ).put_document(DockerImageArtifact(id="browseruse-mcp__env_image", description="b", image_name="b:v1", + tar_gz_s3_url="file:///b.tar.gz"))) + assert ensure_default_envs([WEBSITE_BROWSER]) == [] + assert puts == [] + + def test_stores_that_arent_local_are_never_built_into(puts, published, monkeypatch): ensure_default_envs([GATEWAY]) monkeypatch.setattr(bootstrap, "stores_are_local", lambda: False) @@ -348,8 +382,8 @@ def test_up_reports_infra_it_cant_build_as_one_line(puts, monkeypatch): def test_the_website_browser_runs_playwrights_chromium_which_is_built_for_amd64_and_arm64(): """Chrome has no Linux arm64 build, so a browser built for an Apple Silicon host couldn't install it.""" - assert "npx playwright install chromium" in bootstrap.WEBSITE_BROWSER_DOCKERFILE.read_text() - assert "--browser chromium" in (bootstrap.WEBSITE_BROWSER_CONTEXT / "entrypoint.sh").read_text() + assert "npx playwright install chromium" in bootstrap.PLAYWRIGHT_MCP_DOCKERFILE.read_text() + assert "--browser chromium" in (bootstrap.PLAYWRIGHT_MCP_CONTEXT / "entrypoint.sh").read_text() def test_a_put_records_what_the_env_was_built_from_whatever_metadata_it_was_given(local_stores, monkeypatch): @@ -357,8 +391,8 @@ def test_a_put_records_what_the_env_was_built_from_whatever_metadata_it_was_give monkeypatch.setattr(bootstrap.DockerImageArtifact, "put", lambda id, **kwargs: get_artifact_store().put_document( DockerImageArtifact(id=id, description="b", image_name=kwargs["image_name"], tar_gz_s3_url="file:///b.tar.gz"))) - env = bootstrap.put_website_browser_env("website-browser", platform=None, - metadata={bootstrap.BUILD_INPUTS_KEY: "mine", "owner": "me"}) + env = bootstrap.put_playwright_mcp_env("playwright-mcp", platform=None, + metadata={bootstrap.BUILD_INPUTS_KEY: "mine", "owner": "me"}) assert env.metadata[bootstrap.BUILD_INPUTS_KEY] == bootstrap.build_inputs_digest(WEBSITE_BROWSER) assert env.metadata["owner"] == "me" diff --git a/tst/unit/env/envs/test_website_browser_recorder.py b/tst/unit/env/envs/test_playwright_mcp_recorder.py similarity index 92% rename from tst/unit/env/envs/test_website_browser_recorder.py rename to tst/unit/env/envs/test_playwright_mcp_recorder.py index 255f45654..207c0d45f 100644 --- a/tst/unit/env/envs/test_website_browser_recorder.py +++ b/tst/unit/env/envs/test_playwright_mcp_recorder.py @@ -1,6 +1,6 @@ -"""The website browser's recorder: it finds the browser Playwright MCP launched, follows the page the agent is on +"""The Playwright MCP browser's recorder: it finds the browser Playwright MCP launched, follows the page the agent is on through DevTools target events, holds a still page's frame so the recording doesn't split, and serves -computer-recording/v1 on a card of its own that the gateway nests as the website-browser child env.""" +computer-recording/v1 on a card of its own that the gateway nests as the playwright-mcp child env.""" from __future__ import annotations @@ -21,8 +21,8 @@ from agentenv_protocol.computer.card import COMPUTER_RECORDING # noqa: E402 from agentenv_protocol.computer.recording import RecordHints # noqa: E402 -from agent_env.env.envs import website_browser # noqa: E402 -from agent_env.env.envs.website_browser import recorder # noqa: E402 +from agent_env.env.envs import playwright_mcp # noqa: E402 +from agent_env.env.envs.playwright_mcp import recorder # noqa: E402 from agent_env.env.gateway.constants import GATEWAY_EXTENSIONS # noqa: E402 from agent_env.env.gateway.rest_proxy import rewrite_child_card # noqa: E402 @@ -195,7 +195,7 @@ async def test_a_recording_through_the_card_is_an_mp4_of_the_page(home): await asyncio.sleep(0.1) manifest = (await http.post(END, json={"recording_id": started["recording_id"]})).json() - assert card["name"] == "website-browser" and card["capabilities"]["operations"] == [] + assert card["name"] == "playwright-mcp" and card["capabilities"]["operations"] == [] assert [e["uri"] for e in card["capabilities"]["extensions"]] == [COMPUTER_RECORDING] params = card["capabilities"]["extensions"][0]["params"] assert params["cursor"] is False and params["source"] == "stream" and set(params["methods"]) == {"start", "end"} @@ -213,16 +213,16 @@ async def test_the_gateway_nests_the_card_as_a_child_env_whose_recording_is_invo async with httpx.AsyncClient(transport=httpx.ASGITransport(app=card), base_url="http://recorder") as http: served = (await http.get("/.well-known/agent-env.json")).json() composed = {"name": "env", "capabilities": {"extensions": GATEWAY_EXTENSIONS}, - "children_environments": [rewrite_child_card("mcp-website-browser", served, GATEWAY_EXTENSIONS)]} - child = protocol_client.find_child(composed, website_browser.WEBSITE_BROWSER_ENVIRONMENT_NAME) + "children_environments": [rewrite_child_card("mcp-playwright-mcp", served, GATEWAY_EXTENSIONS)]} + child = protocol_client.find_child(composed, playwright_mcp.PLAYWRIGHT_MCP_ENVIRONMENT_NAME) methods = protocol_client.find_extension(child, COMPUTER_RECORDING)["params"]["methods"] assert {op: m["endpoint"] for op, m in methods.items()} == { - "start": "/svc/mcp-website-browser/agentenv/ext/computer-recording/start", - "end": "/svc/mcp-website-browser/agentenv/ext/computer-recording/end"} + "start": "/svc/mcp-playwright-mcp/agentenv/ext/computer-recording/start", + "end": "/svc/mcp-playwright-mcp/agentenv/ext/computer-recording/end"} def test_the_image_serves_the_recorder_on_the_port_the_gateway_reads_the_card_on(): - entrypoint = (Path(website_browser.__file__).parent / "entrypoint.sh").read_text() + entrypoint = (Path(playwright_mcp.__file__).parent / "entrypoint.sh").read_text() default = re.search(r'RECORDER_PORT="\$\{RECORDER_PORT:-(\d+)\}"', entrypoint).group(1) - assert int(default) == recorder.DEFAULT_PORT == website_browser.WEBSITE_BROWSER_RECORDER_PORT + assert int(default) == recorder.DEFAULT_PORT == playwright_mcp.PLAYWRIGHT_MCP_RECORDER_PORT assert "--isolated" in entrypoint # the remote debugging port the recorder finds comes with it diff --git a/tst/unit/providers/env_providers/env_gateway_provider_test.py b/tst/unit/providers/env_providers/env_gateway_provider_test.py index 1f882a41c..9941bdb5b 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -1302,10 +1302,15 @@ def test_needs_local_postgres_reflects_provider_type(): @pytest.mark.asyncio -async def test_deploy_via_vm_prepares_schemas_including_website_browser(): - """The gateway auto-adds the browser MCP when websites are present, then drives stage 2 - (prepare) with the full post-append service list — so a remote store provisions the browser's +@pytest.mark.parametrize("overrides, browser_name", [ + ({}, "website-browser"), + ({"website_browser_env_id": "wb-run"}, "wb-run"), +], ids=["configured-default", "run-override"]) +async def test_deploy_via_vm_prepares_schemas_including_website_browser(overrides, browser_name): + """The gateway auto-adds the browser MCP when websites are present, the run's own when it names one, then drives + stage 2 (prepare) with the full post-append service list — so a remote store provisions the browser's schema. This replaces the earlier band-aid that made Env.deploy compute that list.""" + from agent_env.config import run_env_ids from agent_env.env.gateway import GatewayMode from agent_env.providers.env_state import DatabaseStateProvider @@ -1329,7 +1334,7 @@ def _fake_env_get(env_id, *a, **k): env.environment_name = "website-browser" if env_id == "wb-id" else env_id return env - with patch("agent_env.env.env.Env.get", side_effect=_fake_env_get), \ + with run_env_ids(overrides), patch("agent_env.env.env.Env.get", side_effect=_fake_env_get), \ patch("agent_env.config.get_config", return_value=MagicMock( default_gateway_env_id="gw-id", default_website_browser_env_id="wb-id")), \ patch.object(gp, "create_docker_compose", side_effect=_Stop) as compose: @@ -1349,9 +1354,9 @@ def _fake_env_get(env_id, *a, **k): state_provider.prepare.assert_awaited_once() names = state_provider.prepare.call_args.args[0] - assert set(names) == {"slack", "shop", "website-browser"} + assert set(names) == {"slack", "shop", browser_name} assert compose.call_args.kwargs["mcp_server_name"] == "crm" - browser = next(s for s in compose.call_args.kwargs["mcp_servers"] if s.environment_name == "website-browser") + browser = next(s for s in compose.call_args.kwargs["mcp_servers"] if s.environment_name == browser_name) assert browser.card_port == 18766 # its card is the recorder's @@ -1641,6 +1646,33 @@ def test_compose_refuses_a_name_or_image_that_would_change_what_it_says(field, v ) +def _render(mcp_servers, website_configs=None): + from agent_env.env.envs.service_db import ServiceDBConfig + + return EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=mcp_servers, website_configs=website_configs, gateway_image="agent-gateway", gateway_port=18765, + state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance()) + + +@pytest.mark.parametrize("browser", ["playwright-mcp", "browseruse-mcp"]) +def test_compose_takes_a_browser_of_any_name_and_refuses_an_env_named_like_it(browser): + browser_server = MCPServerConfig(image="browser", environment_name=browser, card_port=18766) + shop = WebsiteConfig(backend_image="shop-be", frontend_image="shop-fe", environment_name="slack") + + services = yaml.safe_load(_render([MCPServerConfig(image="mcp-slack", environment_name="slack"), browser_server], + [shop]))["services"] + assert {browser, "slack", "slack-website-backend", "slack-website-frontend"} <= set(services) + with pytest.raises(ValueError, match=f"two of its services would both be named '{browser}'"): + _render([MCPServerConfig(image="mcp-x", environment_name=browser), browser_server], [shop]) + + +@pytest.mark.parametrize("name", ["gateway", "servicedb", "pgweb", "db-mcp"]) +def test_compose_refuses_an_env_named_like_a_service_every_gateway_deploy_runs(name): + with pytest.raises(ValueError, match=f"environment name '{name}' is one a gateway deploy names its own containers"): + _render([MCPServerConfig(image="mcp-x", environment_name=name)]) + + @pytest.mark.parametrize("name, image", [ ("slack", "mcp-slack"), ("Items.v2_x-1", "localhost:5000/local/items-0123abcd:v1"), ("google_calendar", f"ghcr.io/acme/calendar:1.2@{_DIGEST}"), ("crm", f"public.ecr.aws/acme/crm@{_DIGEST}"), diff --git a/tst/unit/test_infra_build_script.py b/tst/unit/test_infra_build_script.py index 160e54464..dfb2c44c6 100644 --- a/tst/unit/test_infra_build_script.py +++ b/tst/unit/test_infra_build_script.py @@ -280,7 +280,7 @@ def test_retry_tries_again_while_pypi_doesnt_list_the_protocol_a_build_installs( def test_retry_tries_again_while_pypi_doesnt_list_the_protocol_floor_a_build_names(script, monkeypatch, capsys): - """The website browser's recorder names a protocol floor, which its release may be the first to publish.""" + """The Playwright MCP browser's recorder names a protocol floor, which its release may be the first to publish.""" missing = "ERROR: No matching distribution found for agentenv-framework-protocol[computer]>=0.1.382\n" runs, waits = _attempts(script, monkeypatch, [(missing, 1), ("#13 DONE 4.1s\n", 0)]) diff --git a/tst/unit/test_infra_images_script.py b/tst/unit/test_infra_images_script.py index 57685695a..5e9921857 100644 --- a/tst/unit/test_infra_images_script.py +++ b/tst/unit/test_infra_images_script.py @@ -52,14 +52,14 @@ def test_plan_lists_every_image_with_its_checkout_paths_and_tag(script, monkeypa def test_plan_only_lists_the_infra_images_or_the_agents_images(script, monkeypatch, capsys): """The release builds the infra images before it publishes the protocol, and the images that install it, the - bundles' and the website browser's, after.""" + bundles' and the Playwright MCP browser's, after.""" planned = {} for only in ("infra", "agents"): assert _run(script, monkeypatch, "plan", "--only", only) == 0 planned[only] = [entry["name"] for entry in json.loads(capsys.readouterr().out)] bundles = [image.name for image in BUNDLE_IMAGES] - after = [*bundles, "agentenv-website-browser"] # the browser's recorder installs the protocol too + after = [*bundles, "agentenv-playwright-mcp"] # the browser's recorder installs the protocol too assert planned == {"infra": [image.name for image in bootstrap.PUBLISHED_IMAGES if image.name not in after], "agents": after} assert "agentenv-gateway" in planned["infra"] and bundles == [