diff --git a/AGENTS.md b/AGENTS.md index 329c3b8a6..df782b4e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ and deserialized through a registry. | Package | Contents | |---|---| -| `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset`, `close` and `load_file_artifact_universe`; teardown closes each env a run deployed before terminating its sandboxes by id. Built-ins in `env/envs/`: `MCPServerEnv` (`mcp_server`), `MultiEnv` (`multi`, MCP servers plus websites), `ServiceDBEnv` (`service_db`, PostgreSQL), `WebsiteEnv` (`website`, one site: a server image, `backend_image` in its env.toml, serving the pages and API, checked at its `health` path; a separate frontend image is deprecated, warned of at put and optional in a bundle, and a stored `backend_docker_image_artifact` reads as the server image), `GatewayEnv` (`gateway_server`), `ComputerDriverEnv` (`computer_driver`, `env/envs/computer/driver_env.py`: a computer driver's files and what its class declares for each platform it supports, read at put in a process of its own, `agentenv_protocol.computer._declare`, where the driver's own dependencies needn't be installed, with the `port` it serves on and its install by OS, `[install.]`: files, a `python` requirements file for a venv, and the services the base's init runs; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch with this machine's Docker, the other arch through its emulation, tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile and build context so any env with the same inputs reuses it; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 then boots each native build on a configured sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `env/gateway/gateway.py` is the env-side gateway server, container-only code. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | +| `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset`, `close` and `load_file_artifact_universe`; teardown closes each env a run deployed before terminating its sandboxes by id. Built-ins in `env/envs/`: `MCPServerEnv` (`mcp_server`), `MultiEnv` (`multi`, MCP servers plus websites), `ServiceDBEnv` (`service_db`, PostgreSQL), `WebsiteEnv` (`website`, one site: a server image, `backend_image` in its env.toml, serving the pages and API, checked at its `health` path; a separate frontend image is deprecated, warned of at put and optional in a bundle, and a stored `backend_docker_image_artifact` reads as the server image), `GatewayEnv` (`gateway_server`), `ComputerDriverEnv` (`computer_driver`, `env/envs/computer/driver_env.py`: a computer driver's files and what its class declares for each platform it supports, read at put in a process of its own, `agentenv_protocol.computer._declare`, where the driver's own dependencies needn't be installed, with the `port` it serves on and its install by OS, `[install.]`: files, a `python` requirements file for a venv, and the services the base's init runs; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch with this machine's Docker, the other arch through its emulation, tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile and build context so any env with the same inputs reuses it; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 then boots each native build on a configured sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `env/gateway/gateway.py` is the env-side gateway server, container-only code. Its site route, `/website//`, never forwards a site's data plane or card (`/agentenv/…`, `/.well-known/agent-env.json`), which agentenv reaches through `/svc//`; a site with no frontend image is served by its server image there. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | | `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Every artifact but an environment universe, whose `metadata` key holds its legacy metadata file ids, carries a `metadata` dict outside its versioned fields, changed by compare-and-swap (`merge_metadata`). Built-ins: `CliArtifact` (`cli`), `ComputerArtifact` (`computer`, a base computer: its architectures, OS, session user, settings and container form, whose image `put` reads from its registry into `metadata.validated_images`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`), `EnvironmentUniverseArtifact` (`environment_universe`), `SkillArtifact` (`skill`); `[artifacts] type_aliases` maps another stored spelling of a type onto one of them. A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull, or a published image's tag with its `image_digest` and its build context, which sandboxes pull by the digest and build from the context when they can't (the infra images, and a bundle agent built from a shipped agent's sources, `Plan.published`), or, written by `put_context`, only its build context, which a VM sandbox builds before running it (`VmSandbox.load_docker_images`) and Modal builds before creating its container (`ModalSandboxProvider.prepare_image`, outside a fallback chain's deadline); anything else that runs images by name (a container on the local provider, a lone server on any provider but Modal) refuses such an image. A build context (`utils/docker/context.py`) is what `docker build` would send, `.dockerignore` applied, written so equal files give equal bytes and an equal `source_digest`. | | `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, `start_computer_recording` and `end_computer_recording`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each env still up closes (`Env.close`; a computer env ends any recording still open), what stayed open is registered from its namespace, then every sandbox is terminated by id. `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB, DynamoDB, Firestore with MongoDB compatibility), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. `store/routing.py` is the `@local` namespace: while the CLI or a `build_app` server runs, an `@local/…` id and what an `@local` task's run records go to local stores beside the configured ones, and that run's writes to the configured stores are refused. A new backend must pass the conformance kits in `tst/store/`. | diff --git a/src/agent_env/env/envs/multi_env.py b/src/agent_env/env/envs/multi_env.py index e1e055263..e939d99ad 100644 --- a/src/agent_env/env/envs/multi_env.py +++ b/src/agent_env/env/envs/multi_env.py @@ -583,7 +583,8 @@ async def _load_from_snapshot(self, snapshot) -> None: from agent_env.env.env import Env from agent_env.env.envs.service_db import DB_USER from agent_env.env.gateway import AGENT_ENV_GATEWAY_MCP_PORT - from agent_env.providers import EnvironmentGatewayProvider, MCPServerConfig, WebsiteConfig + from agent_env.providers import EnvironmentGatewayProvider, MCPServerConfig + from agent_env.providers.env_providers.env_gateway_provider import website_config 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 @@ -612,10 +613,7 @@ async def _load_from_snapshot(self, snapshot) -> None: gw._state_provider = LocalPostgresStateProvider(service_db_config=service_db_config) gw._state_instance = LocalPostgresStateProvider.default_instance() mcp_servers = [MCPServerConfig(image=env.docker_image_artifact.image_name, environment_name=env.environment_name) for env in self.mcp_server_envs] - website_configs = [ - WebsiteConfig(backend_image=env.backend_docker_image_artifact.image_name, frontend_image=env.frontend_docker_image_artifact.image_name, environment_name=env.environment_name) - for env in self.website_envs - ] + website_configs = [website_config(env) for env in self.website_envs] if website_configs: 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, diff --git a/src/agent_env/env/envs/website.py b/src/agent_env/env/envs/website.py index 4ccca1ca6..b87fc9418 100644 --- a/src/agent_env/env/envs/website.py +++ b/src/agent_env/env/envs/website.py @@ -25,6 +25,7 @@ ) from agent_env.env.gateway import GatewayMode from agent_env.attribution import Attribution +from agent_env.providers.env_providers.constants import AGENT_ENV_WEBSITE_HEALTH_PATH from agent_env.utils.paths import validate_file_name if TYPE_CHECKING: from agent_env.bundle.authoring import AuthoringContext @@ -34,7 +35,7 @@ logger = logging.getLogger(__name__) -DEFAULT_HEALTH_PATH = "/api/health" +DEFAULT_HEALTH_PATH = AGENT_ENV_WEBSITE_HEALTH_PATH _HEALTH_PATH = re.compile(r"/[A-Za-z0-9._~/-]*") @@ -174,9 +175,6 @@ async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gatew def deploy_refusal(self, *, sandbox_type: str | None = None, **options) -> str | None: """Why deploy() would refuse these options before building anything, or None; raises, as deploy() does, for a type this process can't find. ``sandbox_type`` is deploy()'s own, which no provider takes.""" - if self.frontend_docker_image_artifact is None: - return (f"website env '{self.id}' has no frontend image, and its server serving its own pages deploys " - "once the gateway serves a one-image site") return deploy_refusal(self, provider_or_class(self), options) @classmethod diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 58efa78c6..e47dd9a7c 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -7,6 +7,7 @@ import json import logging import os +import posixpath import socket import subprocess from contextlib import asynccontextmanager @@ -565,7 +566,11 @@ async def log_write(status: int) -> None: on_done=log_write) async def _proxy_website_request(self, request: Request) -> Response: - """Proxy a website request; with a website or state trigger registered, feed it to the engine and hold barriers.""" + """Proxy a website request; with a website or state trigger registered, feed it to the engine and hold barriers. + A site's data plane and card are never reached through its route, so an agent browsing the site can't read + what grades it; agentenv reaches them through /svc//.""" + if reserved_site_path("/" + request.path_params.get("path", "")): + return self._json_response({"error": "not found"}, 404) response = await self._proxy_rest_request(request, url_map=self.website_urls) app = request.path_params["service_name"] engine = self._trigger_engine @@ -1389,3 +1394,9 @@ def _call_never_reached_child(exc: BaseException) -> bool: if isinstance(exc, (ClosedResourceError, anyio.BrokenResourceError)): return True return isinstance(exc, McpError) and exc.error.code == 32600 + + +def reserved_site_path(path: str) -> bool: + """Whether ``path``, as a site's server would route it, is its data plane or its env card.""" + normalized = posixpath.normpath("/" + path.lstrip("/")) + return normalized == RPC_PATH or normalized.startswith(RPC_PATH + "/") or normalized == WELL_KNOWN_PATH diff --git a/src/agent_env/providers/env_providers/constants.py b/src/agent_env/providers/env_providers/constants.py index 110ef4446..7a6a00e97 100644 --- a/src/agent_env/providers/env_providers/constants.py +++ b/src/agent_env/providers/env_providers/constants.py @@ -12,6 +12,7 @@ GATEWAY_APP_DIR = "/app" AGENT_ENV_WEBSITE_BACKEND_PORT = 8000 +AGENT_ENV_WEBSITE_HEALTH_PATH = "/api/health" AGENT_ENV_WEBSITE_BACKEND_SUFFIX = "website-backend" AGENT_ENV_WEBSITE_FRONTEND_SUFFIX = "website-frontend" DOCKER_COMPOSE_PATH = f"{GATEWAY_APP_DIR}/docker-compose.yml" 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 e9c92fb03..c8fc4cc07 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -40,6 +40,7 @@ AGENT_ENV_WEBSITE_BACKEND_PORT, AGENT_ENV_WEBSITE_BACKEND_SUFFIX, AGENT_ENV_WEBSITE_FRONTEND_SUFFIX, + AGENT_ENV_WEBSITE_HEALTH_PATH, DOCKER_COMPOSE_PATH, GATEWAY_APP_DIR, GATEWAY_SERVICE_NAME, @@ -66,6 +67,7 @@ from agent_env.artifact import DockerImageArtifact from agent_env.env.env import DeployedEnv, Env + from agent_env.env.envs.website import WebsiteEnv from agent_env.providers.env_state import DatabaseStateProvider, EnvStateInstance, LocalPostgresStoreSpec logger = logging.getLogger(__name__) @@ -115,11 +117,13 @@ class DeployedGateway: @dataclass class WebsiteConfig: - """Configuration for a website (frontend + backend) in a gateway deployment.""" + """Configuration for a website in a gateway deployment: its server image, which serves the site's pages and API and + answers at ``health_path`` once usable, and a deprecated separate frontend image for a site built on one.""" backend_image: str - frontend_image: str + frontend_image: str | None environment_name: str + health_path: str = AGENT_ENV_WEBSITE_HEALTH_PATH @dataclass @@ -467,6 +471,8 @@ def create_docker_compose( if website_configs: website_urls = ",".join( f"{wc.environment_name}=http://{wc.environment_name}-{AGENT_ENV_WEBSITE_FRONTEND_SUFFIX}:80" + if wc.frontend_image else + f"{wc.environment_name}=http://{wc.environment_name}-{AGENT_ENV_WEBSITE_BACKEND_SUFFIX}:{AGENT_ENV_WEBSITE_BACKEND_PORT}" for wc in website_configs ) gateway_env.append(f" - WEBSITE_URLS={website_urls}") @@ -524,7 +530,7 @@ def create_docker_compose( f" - PORT={AGENT_ENV_WEBSITE_BACKEND_PORT}", " - PYTHONUNBUFFERED=1", " healthcheck:", - f' test: ["CMD", "curl", "-f", "http://localhost:{AGENT_ENV_WEBSITE_BACKEND_PORT}/api/health"]', + f' test: ["CMD", "curl", "-f", "http://localhost:{AGENT_ENV_WEBSITE_BACKEND_PORT}{wc.health_path}"]', " interval: 10s", " timeout: 5s", " start_period: 30s", @@ -532,6 +538,10 @@ def create_docker_compose( " networks:", " - env-network", "", + ]) + if not wc.frontend_image: + continue + lines.extend([ f" {frontend_name}:", f" image: {wc.frontend_image}", " depends_on:", @@ -1482,6 +1492,14 @@ class _GatewayTopology: names_gateway_on_vm: bool # a MultiEnv's record names its gateway sandbox even on a VM +def website_config(env: WebsiteEnv) -> WebsiteConfig: + """A website env as the gateway runs it: its server image, any deprecated frontend image, and its health path.""" + frontend = env.frontend_docker_image_artifact + return WebsiteConfig(backend_image=env.server_docker_image_artifact.image_name, + frontend_image=frontend.image_name if frontend else None, + environment_name=env.environment_name, health_path=env.health_path) + + def _gateway_topology(env: Env) -> _GatewayTopology: """What a gateway fronts for env: a MultiEnv's MCP servers and websites (whatever their own env_provider_type) under its name, a WebsiteEnv's website, or else the env's one MCP server.""" @@ -1498,12 +1516,9 @@ def _gateway_topology(env: Env) -> _GatewayTopology: return _GatewayTopology( mcp_servers=[MCPServerConfig(image=s.docker_image_artifact.image_name, environment_name=s.environment_name) for s in servers], mcp_server_images=[s.docker_image_artifact for s in servers], - website_configs=[ - WebsiteConfig(backend_image=w.backend_docker_image_artifact.image_name, frontend_image=w.frontend_docker_image_artifact.image_name, - environment_name=w.environment_name) - for w in websites - ] or None, - website_images=[image for w in websites for image in (w.backend_docker_image_artifact, w.frontend_docker_image_artifact)] or None, + website_configs=[website_config(w) for w in websites] or None, + website_images=[image for w in websites + for image in (w.server_docker_image_artifact, w.frontend_docker_image_artifact) if image] or None, mcp_server_name=name, names_gateway_on_vm=is_multi, ) @@ -1529,7 +1544,7 @@ def _compose_name_problems(mcp_servers: list[MCPServerConfig], website_configs: 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))] + *(ref for w in website_configs or [] for ref in (w.backend_image, w.frontend_image) if ref is not None)] 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 [])]), diff --git a/tst/unit/bundle/env_toml_test.py b/tst/unit/bundle/env_toml_test.py index 25c27f82f..236be7b99 100644 --- a/tst/unit/bundle/env_toml_test.py +++ b/tst/unit/bundle/env_toml_test.py @@ -143,6 +143,18 @@ def test_a_website_builds_dockerfile_backend_and_dockerfile_frontend_and_takes_i assert [call["build"][0] for call in builds] == ["Dockerfile.backend", "Dockerfile.frontend"] +def test_a_website_with_only_a_server_dockerfile_builds_one_image(bundle_dir, builds): + _bundle(bundle_dir, {"envs/mail/Dockerfile.backend": DOCKERFILE, "envs/mail/server.py": CARD.format("mail"), + "envs/mail/env.toml": 'type = "website"\nhealth = "/healthz"\n'}, "mail") + + _run(bundle_dir) + env = Env.get(f"{ROOT}/mail") + + assert (env.server_docker_image_artifact.id, env.frontend_docker_image_artifact) == (f"{ROOT}/mail__backend_image", None) + assert env.health_path == "/healthz" + assert [call["build"][0] for call in builds] == ["Dockerfile.backend"] + + def test_a_multi_names_bundle_and_store_envs_and_is_rewritten_when_a_child_is(bundle_dir, builds): with namespace_routing(): MCPServerEnv.put(id="inbox", docker_image_artifact=_store_image("inbox-image"), environment_name="inbox") diff --git a/tst/unit/env/envs/test_website_server_image.py b/tst/unit/env/envs/test_website_server_image.py index 303308358..8bfc78af5 100644 --- a/tst/unit/env/envs/test_website_server_image.py +++ b/tst/unit/env/envs/test_website_server_image.py @@ -57,8 +57,6 @@ def test_a_separate_frontend_image_is_deprecated_but_still_deploys(local_stores, frontend_docker_image_artifact=_image("shop-frontend"), environment_name="shop") assert "separate frontend image, which is deprecated" in caplog.text assert env.deploy_refusal() is None - one_image = WebsiteEnv(id="one", version=1, server_docker_image_artifact=_image("one-server"), environment_name="one") - assert "deploys once the gateway serves a one-image site" in one_image.deploy_refusal() def test_an_env_toml_takes_a_health_path_and_refuses_a_bad_one(): diff --git a/tst/unit/env/gateway/website_reserved_paths_test.py b/tst/unit/env/gateway/website_reserved_paths_test.py new file mode 100644 index 000000000..a8362d413 --- /dev/null +++ b/tst/unit/env/gateway/website_reserved_paths_test.py @@ -0,0 +1,42 @@ +"""A site's route never reaches its data plane or its env card, however the path is spelled, so an agent browsing the +site can't read what grades it; agentenv still reaches both through /svc//.""" + +import httpx +import pytest + +from agent_env.env.gateway import rest_proxy as rest_proxy_module +from agent_env.env.gateway.gateway import reserved_site_path +from tst.unit.env.gateway.changes_test import _Changelog, _gateway, _http, _RealAsyncClient + + +@pytest.mark.parametrize(("path", "reserved"), [ + ("/agentenv", True), ("/agentenv/", True), ("/agentenv/data", True), ("//agentenv", True), + ("/x/../agentenv", True), ("/./.well-known/agent-env.json", True), ("/.well-known/agent-env.json", True), + ("/agentenv-help", False), ("/api/agentenv", False), ("/.well-known/other.json", False), ("/", False), +]) +def test_the_data_plane_and_card_are_reserved_however_their_path_is_spelled(path, reserved): + assert reserved_site_path(path) is reserved + + +@pytest.mark.asyncio +async def test_a_sites_route_refuses_its_reserved_paths_and_svc_still_reaches_them(monkeypatch): + reached = [] + + async def body(): + yield b"ok" + + def site(request): + reached.append(request.url.path) + return httpx.Response(200, content=body()) + + transport = httpx.MockTransport(site) + monkeypatch.setattr(rest_proxy_module.httpx, "AsyncClient", lambda **kw: _RealAsyncClient(transport=transport, **kw)) + gw = _gateway(_Changelog(), website_urls={"mail": "http://mail-website-backend:8000"}, + rest_proxy_urls={"mail": "http://mail-website-backend:8000"}) + + assert (await _http(gw, "GET", "/website/mail/.well-known/agent-env.json"))[0] == 404 + assert (await _http(gw, "POST", "/website/mail/agentenv", body=b"{}"))[0] == 404 + assert (await _http(gw, "POST", "/website/mail/%61gentenv", body=b"{}"))[0] == 404 + assert (await _http(gw, "GET", "/website/mail/inbox"))[0] == 200 + assert (await _http(gw, "GET", "/svc/mail/.well-known/agent-env.json"))[0] == 200 + assert reached == ["/inbox", "/.well-known/agent-env.json"] 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 9941bdb5b..44395302b 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -2426,3 +2426,38 @@ async def stalls(*args): assert raised.value is _COMPOSE_ERROR assert "Can't list the stack's services: no answer in 0s" in caplog.text + + +def test_a_one_image_site_runs_alone_on_its_port_checked_at_its_health_path_and_its_route_reaches_it(): + """A site whose server image serves its pages gets one container: the gateway's /website// and /svc// + both reach it, checked at its declared health path; a site still built on a frontend image keeps both.""" + from agent_env.env.envs.service_db import ServiceDBConfig + + compose = EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=[], + gateway_image="agent-gateway", + website_configs=[WebsiteConfig(backend_image="mail-server", frontend_image=None, environment_name="mail", + health_path="/healthz"), + WebsiteConfig(backend_image="shop-be", frontend_image="shop-fe", environment_name="shop")], + state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance(), + ) + + services = yaml.safe_load(compose)["services"] + assert {"mail-website-backend", "shop-website-backend", "shop-website-frontend"} <= set(services) + assert "mail-website-frontend" not in services + assert "http://localhost:8000/healthz" in services["mail-website-backend"]["healthcheck"]["test"] + assert "http://localhost:8000/api/health" in services["shop-website-backend"]["healthcheck"]["test"] + gateway = dict(item.split("=", 1) for item in services[GATEWAY_SERVICE_NAME]["environment"]) + assert gateway["WEBSITE_URLS"] == "mail=http://mail-website-backend:8000,shop=http://shop-website-frontend:80" + assert gateway["REST_PROXY_URLS"] == "mail=http://mail-website-backend:8000,shop=http://shop-website-backend:8000" + + +def test_a_one_image_website_env_is_fronted_with_only_its_server_image(): + server = MagicMock(image_name="mail-server") + env = WebsiteEnv(id="web-mail", version=1, server_docker_image_artifact=server, environment_name="mail", + health_path="/healthz") + topology = env_gateway_provider._gateway_topology(env) + assert topology.website_configs == [WebsiteConfig(backend_image="mail-server", frontend_image=None, + environment_name="mail", health_path="/healthz")] + assert topology.website_images == [server]