From e5082eb62bbe9d1431a4ec1800039d7f07c9f7fb Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 12:52:03 -0800 Subject: [PATCH 01/57] feat(website): sync the clock of, snapshot, and configure a multi env's websites as its MCP servers (FD-4683) A website env, and a multi env's websites, get the harness treatment MCP servers get: sync_env_clock syncs them (and tells them main's timezone), snapshot_env exports each through its data plane and fails on a site whose card can't be read, and an apply_server_config broadcast reaches them; a site without the clock, a data plane or the config extension is skipped. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/env/legacy_protocol.py | 53 +++++- .../task_steps/apply_server_config.py | 75 ++++---- .../task_step/task_steps/snapshot_env.py | 90 ++++++--- .../task_step/task_steps/sync_env_clock.py | 57 ++++-- tst/unit/env/test_legacy_protocol.py | 19 ++ .../env_gateway_provider_test.py | 12 ++ tst/unit/task_step/sync_env_clock_test.py | 153 ++++++++++++++- .../task_step/test_apply_server_config.py | 91 +++++++++ tst/unit/task_step/test_snapshot_env.py | 174 ++++++++++++++++++ 9 files changed, 635 insertions(+), 89 deletions(-) diff --git a/src/agent_env/env/legacy_protocol.py b/src/agent_env/env/legacy_protocol.py index 16899025b..8790157ac 100644 --- a/src/agent_env/env/legacy_protocol.py +++ b/src/agent_env/env/legacy_protocol.py @@ -9,7 +9,8 @@ import asyncio import logging -from typing import TYPE_CHECKING, Optional +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional import httpx from agentenv_protocol import RPC_PATH, DataPart, client as protocol_v1 @@ -33,20 +34,62 @@ def environment_base_url(gateway_url: Optional[str], environment_name: str, mcp: return f"{gateway_url}/svc/{prefix}{environment_name}" +@dataclass(frozen=True) +class BackingServer: + """A server behind an env's gateway, by its environment_name: an MCP server, at ``/svc/mcp-{name}``, or a website, + at ``/svc/{name}``.""" + + environment_name: str + mcp: bool = True + + +def backing_servers(env: Any) -> list[BackingServer]: + """The servers behind env's gateway: a MultiEnv's MCP servers then its websites, a WebsiteEnv's website, or else + the env's one MCP server; empty for an env with none.""" + from agent_env.env.envs.website import WebsiteEnv + + if isinstance(env, WebsiteEnv): + return [BackingServer(env.environment_name, mcp=False)] + servers = [BackingServer(e.environment_name) for e in getattr(env, "mcp_server_envs", None) or []] + servers += [BackingServer(e.environment_name, mcp=False) for e in getattr(env, "website_envs", None) or []] + if not servers and getattr(env, "environment_name", None): + servers = [BackingServer(env.environment_name)] + return servers + + +def shared_names(servers: list[BackingServer]) -> set[str]: + """The environment_names an MCP server and a website in ``servers`` share, which a stored env card can't tell apart.""" + return {s.environment_name for s in servers if s.mcp} & {s.environment_name for s in servers if not s.mcp} + + +def mcp_wins(servers: list[BackingServer]) -> list[BackingServer]: + """``servers`` without each website that shares its environment_name with an MCP server, as a MultiEnv routes an + artifact of that name to the MCP server.""" + shared = shared_names(servers) + return [s for s in servers if s.mcp or s.environment_name not in shared] + + async def v1_base_url(deployed: Optional[DeployedEnv], gateway_url: Optional[str], environment_name: str, mcp: bool = True) -> Optional[str]: - """A child env's v1 data-plane base from the stored env card, or None for legacy; without a stored card, the live probe decides.""" + """A child env's v1 data-plane base from the stored env card, or None for legacy; without a stored card, the live probe + decides. A website's probe is a card read that is None only on a 404 and raises on any other failure, so a website whose + card could not be read is never taken for one serving no data plane.""" if deployed is None or not deployed.environment_card: base_url = environment_base_url(gateway_url, environment_name, mcp=mcp) + if not mcp: + _, card = await child_env_card(None, gateway_url, environment_name, mcp=False) + return base_url if card is not None else None return base_url if await protocol_v1.supports_v1(base_url) else None child = deployed.get_child_env_card(environment_name) return deployed.environment_url + child.get("url", RPC_PATH).removesuffix(RPC_PATH) if child else None -async def child_env_card(deployed: Optional[DeployedEnv], gateway_url: Optional[str], environment_name: str, timeout: int = 10) -> tuple[str, Optional[dict]]: - """An MCP child env's card and the base its endpoints resolve against: from the stored env card, or without one a live read (None on 404).""" +async def child_env_card(deployed: Optional[DeployedEnv], gateway_url: Optional[str], environment_name: str, timeout: int = 10, + mcp: bool = True) -> tuple[str, Optional[dict]]: + """A child env's card and the base its endpoints resolve against: from the stored env card, or without one a live read + at the MCP server's gateway path, or with ``mcp=False`` the website's (None on 404).""" if deployed is None or not deployed.environment_card: - base_url = environment_base_url((gateway_url or "").rstrip("/"), environment_name) + base_url = environment_base_url((gateway_url or "").rstrip("/"), environment_name, mcp=mcp) try: return base_url, await protocol_v1.get_card(base_url, timeout=timeout) except httpx.HTTPStatusError as e: diff --git a/src/agent_env/task_step/task_steps/apply_server_config.py b/src/agent_env/task_step/task_steps/apply_server_config.py index 41f82ffd0..c8c8e1f08 100644 --- a/src/agent_env/task_step/task_steps/apply_server_config.py +++ b/src/agent_env/task_step/task_steps/apply_server_config.py @@ -1,15 +1,15 @@ """Task step that applies a task's behavior config to deployed servers. At setup (after the env is deployed + universe loaded, before the agent runs) -this step invokes each backing MCP server's ``set_*`` config extensions over the -gateway. Extensions are advertised on the server's ``EnvironmentCard`` (served at +this step invokes each backing MCP server's or website's ``set_*`` config extensions +over the gateway. Extensions are advertised on the server's ``EnvironmentCard`` (served at ``/.well-known/agent-env.json``) under ``capabilities.extensions[]`` and invoked at their advertised REST endpoint (e.g. ``/agentenv/ext/set_errors``). Config lives on the backing server, not on the gateway itself. Each server's card comes from the env card stored on the deployed record, whose extension endpoints the -gateway has already rewritten to its ``/svc/mcp-{service}`` proxy; a record without a -stored card reads it live there. The agent never sees the card or these endpoints; +gateway has already rewritten to its ``/svc/mcp-{service}`` (or a website's ``/svc/{service}``) +proxy; a record without a stored card reads it live there. The agent never sees the card or these endpoints; only this harness step invokes them. """ @@ -76,7 +76,7 @@ class ApplyServerConfigStep(TaskStep): entity_refs = (EntityRef.env("env_id"),) # A directive whose ``service`` is this sentinel is a broadcast: at execution - # it expands to one directive per MCP server in the env (same uri + args). + # it expands to one directive per MCP server and website in the env (same uri + args). # Lets a caller apply an env-wide config (e.g. set_acting_user across every # server) without enumerating services itself. Pairs naturally with # ``tolerate_unadvertised`` so servers that don't opt in are simply skipped. @@ -131,44 +131,51 @@ def from_dict(cls, data: dict) -> "ApplyServerConfigStep": tolerate_unadvertised=data.get("tolerate_unadvertised", False), ) - def _expand_directives(self, deployed) -> list[ConfigDirective]: + def _expand_directives(self, deployed) -> tuple[list[ConfigDirective], set[str], set[int]]: """Expand any broadcast directive (service == ``*``) into one directive - per MCP server in the env, resolved from the env definition. The env's - child service names aren't otherwise enumerable here (the deployed card - list carries no roster), so we read them from ``Env.get`` — matching how - deploy_env / load_artifact resolve the env. Non-broadcast directives - pass through untouched.""" + per MCP server and website in the env, resolved from the env definition, + and return them with the names that are websites, whose cards a live read + finds at ``/svc/{name}``, and the positions of the directives a broadcast + made for a website. Those are skipped, as under ``tolerate_unadvertised``, + when the website has no card or doesn't advertise the URI, so a website + the task didn't name never fails it; a directive naming a website, and + every MCP server, stays strict. The env's child service names aren't otherwise + enumerable here (the deployed card list carries no roster), so we read + them from ``Env.get`` — matching how deploy_env / load_artifact resolve + the env. A website sharing its name with an MCP server is left to the MCP + server, as the stored env card can't tell the two apart. Non-broadcast + directives pass through untouched.""" if not any(d.service == self._BROADCAST for d in self.directives): - return self.directives + return self.directives, set(), set() + from agent_env.env import legacy_protocol from agent_env.env.env import Env env = Env.get(deployed.env_id, deployed.env_version) - environment_names = [ - e.environment_name for e in (getattr(env, "mcp_server_envs", None) or []) - ] # A single-server env (a bare MCPServerEnv) exposes `environment_name` and no - # `mcp_server_envs`; mirror snapshot_env / exporter enumeration and fall - # back to it, else a broadcast silently matches nothing on single-server envs. - if not environment_names and getattr(env, "environment_name", None): - environment_names = [env.environment_name] + # `mcp_server_envs`; backing_servers falls back to it, as snapshot_env does, + # else a broadcast silently matches nothing on single-server envs. + servers = legacy_protocol.mcp_wins(legacy_protocol.backing_servers(env)) # A broadcast that resolves to zero services would apply nothing while the # step reports success: the exact silent misconfig this step prevents. Fail # loud (tolerate_unadvertised governs advertisement, not missing servers). - if not environment_names: + if not servers: raise ApplyServerConfigError( - f"apply_server_config: broadcast directive matched no MCP servers " - f"in env {self.env_id!r} (env exposes neither mcp_server_envs nor a " + f"apply_server_config: broadcast directive matched no MCP servers or websites " + f"in env {self.env_id!r} (env exposes neither mcp_server_envs, website_envs nor a " f"environment_name); nothing to apply, likely a task authoring error" ) expanded: list[ConfigDirective] = [] + lenient: set[int] = set() for d in self.directives: if d.service != self._BROADCAST: expanded.append(d) continue - for svc in environment_names: - expanded.append(ConfigDirective(service=svc, uri=d.uri, args=d.args)) - return expanded + for server in servers: + if not server.mcp: + lenient.add(len(expanded)) + expanded.append(ConfigDirective(service=server.environment_name, uri=d.uri, args=d.args)) + return expanded, {server.environment_name for server in servers if not server.mcp}, lenient async def execute(self, context: TaskStepContext) -> TaskStepContext: deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None) @@ -180,7 +187,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: # actually armed with — a partial apply must not look like a no-op. changes = context.metadata.setdefault("server_config_changes", []) # Directives skipped because the target server doesn't advertise the - # extension (only when tolerate_unadvertised). Kept separate from + # extension (when tolerate_unadvertised, or for a website a broadcast reached). Kept separate from # `changes` so the audit trail never shows a skip as an applied change. skipped = context.metadata.setdefault("server_config_skipped", []) # Expand broadcast (service="*") directives to one-per-env-service. @@ -188,7 +195,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: # network), so wrap it to keep the broadcast path's failure contract the # same ApplyServerConfigError as the per-directive HTTP calls below. try: - directives = self._expand_directives(deployed) + directives, websites, lenient = self._expand_directives(deployed) except ApplyServerConfigError: raise except Exception as e: @@ -204,15 +211,17 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: # card: a server predating env-card support, one whose card name differs, or one # whose card the deploy couldn't read. cards: dict[str, tuple[str, Optional[dict]]] = {} - for directive in directives: + for position, directive in enumerate(directives): + tolerate = self.tolerate_unadvertised or position in lenient try: if directive.service not in cards: cards[directive.service] = await legacy_protocol.child_env_card( - deployed, gateway_url_of(deployed), directive.service, timeout=self.timeout_seconds + deployed, gateway_url_of(deployed), directive.service, timeout=self.timeout_seconds, + mcp=directive.service not in websites, ) base_url, card = cards[directive.service] if card is None: - if not self.tolerate_unadvertised: + if not tolerate: raise ApplyServerConfigError( f"Service {directive.service!r} has no env card (env={self.env_id}), so {directive.uri!r} " f"can't be applied: it serves none, its card isn't named {directive.service!r}, or the deploy couldn't read it" @@ -220,7 +229,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: logger.warning( f"apply_server_config: service {directive.service!r} serves no " f"environment card (env={self.env_id}); skipping {directive.uri!r} " - f"(tolerate_unadvertised)" + f"({'tolerate_unadvertised' if self.tolerate_unadvertised else 'broadcast to a website'})" ) skipped.append({ "step_id": self.id, @@ -233,11 +242,11 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: continue ext = protocol_v1.find_extension(card, directive.uri) if ext is None: - if self.tolerate_unadvertised: + if tolerate: logger.warning( f"apply_server_config: {directive.uri!r} not advertised on " f"service {directive.service!r} (env={self.env_id}); skipping " - f"(tolerate_unadvertised)" + f"({'tolerate_unadvertised' if self.tolerate_unadvertised else 'broadcast to a website'})" ) skipped.append({ "step_id": self.id, diff --git a/src/agent_env/task_step/task_steps/snapshot_env.py b/src/agent_env/task_step/task_steps/snapshot_env.py index 48fc9d6dc..9415dac98 100644 --- a/src/agent_env/task_step/task_steps/snapshot_env.py +++ b/src/agent_env/task_step/task_steps/snapshot_env.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import PurePosixPath -from typing import ClassVar, Optional +from typing import TYPE_CHECKING, ClassVar, Optional from urllib.parse import urlparse import httpx @@ -37,6 +37,9 @@ from agent_env.entity.scopes import FieldScope from agent_env.task_step.task_step import TaskStep, TaskStepDependency +if TYPE_CHECKING: + from agent_env.env.legacy_protocol import BackingServer + logger = logging.getLogger(__name__) DEFAULT_EXPORT_TIMEOUT_SECONDS = 600 @@ -167,6 +170,9 @@ class SnapshotEnvTaskStep(TaskStep): Output: ``context.metadata["env_snapshotted_universes"][self.id]`` = ``{"id", "version"}``. Fails the step if any service fails to export. + Websites are snapshotted through their v1 data plane; a website serving + none is left out, and one sharing its name with an MCP server is left to + the MCP server, as a MultiEnv loads that name's artifact into it. ``include_env_changes`` also stores who changed what (urn:agentenv:changes/v1) under ``metadata["env_changes"]``. @@ -469,28 +475,36 @@ def _resolve_loaded_universe_ref(deployed) -> Optional[tuple[str, Optional[int]] return None return (su["id"], su.get("version")) if su and su.get("id") else None + @classmethod + def _enumerate_environments(cls, env) -> list[str]: + """environment names for a multi env, a website env or a single MCP server env.""" + return [server.environment_name for server in cls._enumerate_servers(env)] + @staticmethod - def _enumerate_environments(env) -> list[str]: - """environment names for a multi env or a single MCP server env.""" - if getattr(env, "mcp_server_envs", None): - return [e.environment_name for e in env.mcp_server_envs] - if getattr(env, "environment_name", None): - return [env.environment_name] - raise RuntimeError( - f"Env '{env.id}' v{env.version} has no MCP services to snapshot " - "(expected mcp_server_envs or environment_name)" - ) + def _enumerate_servers(env) -> list[BackingServer]: + """The servers to snapshot: the env's MCP servers and websites, a website sharing its name with an MCP + server left to it.""" + from agent_env.env import legacy_protocol + + servers = legacy_protocol.mcp_wins(legacy_protocol.backing_servers(env)) + if not servers: + raise RuntimeError( + f"Env '{env.id}' v{env.version} has no MCP services or websites to snapshot " + "(expected mcp_server_envs, website_envs or environment_name)" + ) + return servers @staticmethod async def _export_environment_to_file( gateway_url: Optional[str], environment_name: str, tmp_path: str, timeout_seconds: float, deployed=None, - data_get_params: Optional[dict] = None, - ) -> str | tuple[GetDataResponse, Optional[_SnapshotUpload]]: - """Export one service; return the suffix written (".json"/".zip") to - ``tmp_path``, or the object url a FilePart names when the service uploaded the - bundle itself (nothing written to ``tmp_path`` then — export_one registers it), or, for - an environment whose card declares a file tree, its data/get answer and the upload it - answered under, which export_one keeps file by file. ``data_get_params`` go to its data/get. + data_get_params: Optional[dict] = None, mcp: bool = True, + ) -> Optional[str | tuple[GetDataResponse, Optional[_SnapshotUpload]]]: + """Export one service, an MCP server or with ``mcp=False`` a website; return the + suffix written (".json"/".zip") to ``tmp_path``, or the object url a FilePart names + when the service uploaded the bundle itself (nothing written to ``tmp_path`` then — + export_one registers it), or, for an environment whose card declares a file tree, its + data/get answer and the upload it answered under, which export_one keeps file by file, + or None for a website serving no v1 data plane. ``data_get_params`` go to its data/get. v1 ``get_data``, handed a namespace grant when the service takes the data-objects form: an upload through it → its object url; ``DataPart`` → json; ``FilePart`` → @@ -499,10 +513,12 @@ async def _export_environment_to_file( guard. Errors are caught by the caller (fails just this service).""" from agent_env.env import legacy_protocol - base_url = await legacy_protocol.v1_base_url(deployed, gateway_url, environment_name, mcp=True) + base_url = await legacy_protocol.v1_base_url(deployed, gateway_url, environment_name, mcp=mcp) + if base_url is None and not mcp: + return None if base_url is not None: headers = deployed.control_headers_for(base_url) if deployed is not None else None - card_base, card = await legacy_protocol.child_env_card(deployed, gateway_url, environment_name) + card_base, card = await legacy_protocol.child_env_card(deployed, gateway_url, environment_name, mcp=mcp) if returns_file_tree(card or {}): upload = await asyncio.to_thread(_snapshot_upload, card or {}, timeout_seconds, getattr(deployed, "sandbox_type", None), FILE_TREE_LIMITS) @@ -611,7 +627,10 @@ async def snapshot_env_state( from agent_env.artifact import FileArtifact, EnvironmentArtifact, EnvironmentUniverseArtifact from agent_env.env.env import gateway_url_of - services = cls._enumerate_environments(env) if gateway_url else _card_environments(deployed) + from agent_env.env.legacy_protocol import BackingServer + + servers = cls._enumerate_servers(env) if gateway_url else [BackingServer(name) for name in _card_environments(deployed)] + services = [server.environment_name for server in servers] gateway = gateway_url.rstrip("/") if gateway_url else None # `gateway` identifies which deployment was exported: env= alone doesn't when one env_id has several. logger.info( @@ -643,9 +662,12 @@ async def snapshot_env_state( # The stored card describes the deployment's own gateway; an override pointing elsewhere keeps the live probe. card_record = deployed if deployed is not None and (gateway is None or (gateway_url_of(deployed) or "").rstrip("/") == gateway) else None sem = asyncio.Semaphore(concurrency) + no_data_plane: set[str] = set() - async def export_one(environment_name: str): - """Returns EnvironmentArtifact on success, (public_err, log_err) on failure.""" + async def export_one(server: BackingServer): + """Returns EnvironmentArtifact on success, (public_err, log_err) on failure, None for a website serving no data plane.""" + environment_name = server.environment_name + website = {} if server.mcp else {"mcp": False} async with sem: # Suffix (.json/.zip) is reported by _export_environment_to_file. fd, tmp_path = tempfile.mkstemp(prefix=f"{environment_name}-") @@ -656,7 +678,7 @@ async def export_one(environment_name: str): try: result = await cls._export_environment_to_file( gateway, environment_name, tmp_path, export_timeout_seconds, - deployed=card_record, data_get_params=(data_get_params or {}).get(environment_name), + deployed=card_record, data_get_params=(data_get_params or {}).get(environment_name), **website, ) except httpx.HTTPStatusError as e: body_snippet = "" @@ -671,6 +693,9 @@ async def export_one(environment_name: str): return public, f"{public}: {e}" except ValueError as e: return str(e), str(e) + if result is None: + no_data_plane.add(environment_name) + return None if isinstance(result, tuple): try: @@ -744,7 +769,7 @@ def _register_uploaded_bundle(): except OSError: pass - results = await asyncio.gather(*(export_one(name) for name in services)) + results = await asyncio.gather(*(export_one(server) for server in servers)) environment_artifacts = [] skipped: dict[str, tuple[str, str]] = {} @@ -767,9 +792,18 @@ def _register_uploaded_bundle(): f"{len(skipped)}/{len(services)} services were not exported " f"(missing={sorted(skipped)}): {public_summary}" ) - if empty := [name for name, result in zip(services, results) if result is None]: - if len(empty) == len(services): - raise RuntimeError(f"{', '.join(map(repr, empty))} returned no files to snapshot") + without_data_plane = [name for name in services if name in no_data_plane] + if without_data_plane: + logger.warning( + f"snapshot_env: websites {without_data_plane} of {env.id} serve no v1 data plane; " + "their state is not in the snapshot" + ) + empty = [name for name, result in zip(services, results) if result is None and name not in no_data_plane] + if not environment_artifacts: + reasons = [f"its websites {without_data_plane} serve no v1 data plane"] if without_data_plane else [] + reasons += [f"{', '.join(map(repr, empty))} returned no files to snapshot"] if empty else [] + raise RuntimeError(f"Env '{env.id}' v{env.version} exported no state: {'; '.join(reasons)}") + if empty: logger.info(f"snapshot_env: {empty} returned no files to keep, so {snapshot_id} leaves them out") universe = await asyncio.to_thread( diff --git a/src/agent_env/task_step/task_steps/sync_env_clock.py b/src/agent_env/task_step/task_steps/sync_env_clock.py index 76ff196e8..c80cef8a8 100644 --- a/src/agent_env/task_step/task_steps/sync_env_clock.py +++ b/src/agent_env/task_step/task_steps/sync_env_clock.py @@ -1,16 +1,16 @@ """Arm a deployed env gateway's virtual clock, then sync its backing servers to it. Arms the clock (set_time) and reads the gateway's server-reachable env_get_time_url (state), both as the -stored env card declares clock/v1, then invokes sync_time on each backing server whose child env card -advertises it. Non-advertisers are skipped when tolerate_missing_sync_time (default True). Place right -before prompt_agent so the virtual timeline starts at agent-start. +stored env card declares clock/v1, then invokes sync_time on each backing server, MCP server or website, +whose child env card advertises it. Non-advertisers are skipped when tolerate_missing_sync_time (default +True), and websites always. Place right before prompt_agent so the virtual timeline starts at agent-start. """ from __future__ import annotations import logging from datetime import datetime, timezone as _utc_tz -from typing import ClassVar, Optional +from typing import TYPE_CHECKING, ClassVar, Optional from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import httpx @@ -22,6 +22,9 @@ from agent_env.entity.refs import EntityRef from agent_env.task_step.task_step import TaskStep, TaskStepDependency +if TYPE_CHECKING: + from agent_env.env.legacy_protocol import BackingServer + logger = logging.getLogger(__name__) @@ -107,16 +110,15 @@ def from_dict(cls, data: dict) -> SyncEnvClockTaskStep: timezone=data.get("timezone"), ) - def _service_names(self, deployed) -> list[str]: - """Backing MCP server names for the env (mirrors apply_server_config's enumeration).""" + def _backing_servers(self, deployed) -> list[BackingServer]: + """The env's backing MCP servers and websites (``legacy_protocol.backing_servers``). A website sharing its + name with an MCP server has its card read live, since the stored env card can't tell the two apart.""" + from agent_env.env import legacy_protocol from agent_env.env.env import Env - env = Env.get(deployed.env_id, deployed.env_version) - names = [e.environment_name for e in (getattr(env, "mcp_server_envs", None) or [])] - if not names and getattr(env, "environment_name", None): - names = [env.environment_name] - return names + return legacy_protocol.backing_servers(Env.get(deployed.env_id, deployed.env_version)) async def execute(self, context: TaskStepContext) -> TaskStepContext: + from agent_env.env import legacy_protocol from agent_env.env.env import gateway_url_of deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None) @@ -131,10 +133,17 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: state = await deployed.invoke(EXT_CLOCK_URI, "state", timeout=self.timeout_seconds) env_get_time_url = state.get("env_get_time_url") + servers = self._backing_servers(deployed) + shared = legacy_protocol.shared_names(servers) synced, skipped = [], [] - for service in self._service_names(deployed): - outcome = await self._sync_one(deployed, gateway_url_of(deployed), service, env_get_time_url) - (synced if outcome.get("synced") else skipped).append({"service": service, "environment": service, **outcome}) + for server in servers: + service = server.environment_name + record = None if service in shared and not server.mcp else deployed + outcome = await self._sync_one(record, gateway_url_of(deployed), service, env_get_time_url, mcp=server.mcp, + tolerate=self.tolerate_missing_sync_time or not server.mcp, + headers=deployed.control_headers) + entry = {"service": service, "environment": service, **({} if server.mcp else {"website": True}), **outcome} + (synced if outcome.get("synced") else skipped).append(entry) configuration = { "step_id": self.id, "env_id": self.env_id, @@ -151,17 +160,24 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: f"({[(s['service'], s.get('reason')) for s in skipped]}) — every tool still answers wall time") return context - async def _sync_one(self, deployed, gateway_url: str, service: str, env_get_time_url: Optional[str]) -> dict: - """Invoke sync_time on one server if its card advertises clock/v1; skip (or raise) per the flag.""" + async def _sync_one(self, deployed, gateway_url: str, service: str, env_get_time_url: Optional[str], mcp: bool = True, + tolerate: Optional[bool] = None, headers: Optional[dict] = None) -> dict: + """Invoke sync_time on one server, an MCP server or with ``mcp=False`` a website, if its card advertises + clock/v1; skip a server with no card or no clock/v1 when ``tolerate`` (tolerate_missing_sync_time by default), + else raise. ``execute`` tolerates every website, so one that doesn't follow the clock never fails the step; one + that follows it but can't take the task's timezone does, as an MCP server does. ``headers`` are the env's control + headers, ``deployed``'s by default; ``execute`` passes them because it hands a website that shares its name with + an MCP server no record, so its card is read live.""" + tolerate = self.tolerate_missing_sync_time if tolerate is None else tolerate from agent_env.env import legacy_protocol - base_url, card = await legacy_protocol.child_env_card(deployed, gateway_url, service, timeout=self.timeout_seconds) + base_url, card = await legacy_protocol.child_env_card(deployed, gateway_url, service, timeout=self.timeout_seconds, mcp=mcp) if card is None: - if self.tolerate_missing_sync_time: + if tolerate: return {"synced": False, "reason": "no_env_card"} raise RuntimeError(f"{service} has no env card: it serves none, its card isn't named {service!r}, or the deploy couldn't read it") if protocol_v1.find_extension(card, EXT_CLOCK_URI) is None: - if self.tolerate_missing_sync_time: + if tolerate: logger.info(f"sync_env_clock: {service} does not advertise {EXT_CLOCK_URI}; skipping") return {"synced": False, "reason": "not_advertised"} raise RuntimeError(f"{service} does not advertise {EXT_CLOCK_URI}") @@ -176,5 +192,6 @@ async def _sync_one(self, deployed, gateway_url: str, service: str, env_get_time params["timezone"] = self.timezone result = await protocol_v1.invoke_extension(base_url, card, EXT_CLOCK_URI, params=params, - timeout=self.timeout_seconds, headers=deployed.control_headers) + timeout=self.timeout_seconds, + headers=deployed.control_headers if headers is None else headers) return {"synced": True, "result": result} diff --git a/tst/unit/env/test_legacy_protocol.py b/tst/unit/env/test_legacy_protocol.py index 9508e8d2e..7d107ad34 100644 --- a/tst/unit/env/test_legacy_protocol.py +++ b/tst/unit/env/test_legacy_protocol.py @@ -177,3 +177,22 @@ async def test_a_v1_services_state_is_the_data_it_answers_with_else_its_export_s assert await legacy_protocol.service_state(None, "http://gw", "slack") == state assert ("GET /svc/mcp-slack/export-state" in asked) is reads_export_state + + +def test_backing_servers_are_an_envs_mcp_servers_then_its_websites(): + from types import SimpleNamespace + + from agent_env.env.envs.website import WebsiteEnv + from agent_env.env.legacy_protocol import BackingServer + + named = lambda *names: [SimpleNamespace(environment_name=n) for n in names] + multi = SimpleNamespace(mcp_server_envs=named("slack", "email"), website_envs=named("shop", "slack")) + + assert legacy_protocol.backing_servers(multi) == [BackingServer("slack"), BackingServer("email"), + BackingServer("shop", mcp=False), BackingServer("slack", mcp=False)] + assert legacy_protocol.shared_names(legacy_protocol.backing_servers(multi)) == {"slack"} + assert legacy_protocol.mcp_wins(legacy_protocol.backing_servers(multi)) == [ + BackingServer("slack"), BackingServer("email"), BackingServer("shop", mcp=False)] + assert legacy_protocol.backing_servers(WebsiteEnv(id="w", version=1, environment_name="shop")) == [BackingServer("shop", mcp=False)] + assert legacy_protocol.backing_servers(SimpleNamespace(environment_name="notes")) == [BackingServer("notes")] + assert legacy_protocol.backing_servers(SimpleNamespace()) == [] 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 a2f9958bc..f604e42cf 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -2466,6 +2466,18 @@ def test_a_one_image_website_env_is_fronted_with_only_its_server_image(): assert topology.website_images == [server] +def test_a_websites_server_reaches_the_gateways_clock_at_the_url_the_gateway_hands_it(): + """sync_env_clock hands each server http://gateway:/clock/time: the gateway's compose service is that host, + listening on that port, on the one network the website's server is on.""" + from agent_env.env.gateway.constants import GATEWAY_COMPOSE_HOST + + services = yaml.safe_load(_render([], [WebsiteConfig(backend_image="shop-be", frontend_image=None, environment_name="shop")]))["services"] + gateway, site = services[GATEWAY_COMPOSE_HOST], services["shop-website-backend"] + + assert "MCP_PORT=18765" in gateway["environment"] + assert gateway["networks"] == site["networks"] == ["env-network"] + + def _sites(handler): """Route the deploy's site checks to ``handler(request) -> httpx.Response``.""" real = httpx.AsyncClient diff --git a/tst/unit/task_step/sync_env_clock_test.py b/tst/unit/task_step/sync_env_clock_test.py index 5ab7bee8e..5b83c51d7 100644 --- a/tst/unit/task_step/sync_env_clock_test.py +++ b/tst/unit/task_step/sync_env_clock_test.py @@ -5,6 +5,7 @@ from agent_env.env.env import DeployedEnv, DeployedGatewayEnv, EnvCapabilityUnsupported from agent_env.env.gateway.constants import EXT_CLOCK_URI +from agent_env.env.legacy_protocol import BackingServer from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_steps import sync_env_clock as mod from agent_env.task_step.task_steps.sync_env_clock import SyncEnvClockTaskStep, resolve_virtual_time @@ -94,7 +95,7 @@ async def test_sync_one_skips_or_raises_for_a_child_env_missing_from_the_stored_ @pytest.mark.asyncio async def test_execute_syncs_each_child_env_through_the_stored_card(monkeypatch): sent = _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) - monkeypatch.setattr(SyncEnvClockTaskStep, "_service_names", lambda self, deployed: ["email"]) + monkeypatch.setattr(SyncEnvClockTaskStep, "_backing_servers", lambda self, deployed: [BackingServer("email")]) context = TaskStepContext(deployed_envs=[_carded(await _composed_card({"mcp-email": [_CLOCK_EXT]}))]) await _step().execute(context) @@ -124,6 +125,96 @@ async def test_execute_keeps_the_arm_failure_message(monkeypatch): await _step().execute(context) +@pytest.mark.asyncio +async def test_execute_syncs_a_multi_envs_websites_alongside_its_mcp_servers(monkeypatch): + from types import SimpleNamespace + from unittest.mock import patch + + sent = _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) + env = SimpleNamespace(mcp_server_envs=[SimpleNamespace(environment_name="email")], + website_envs=[SimpleNamespace(environment_name="shop"), SimpleNamespace(environment_name="blog")]) + context = TaskStepContext(deployed_envs=[_carded(await _composed_card({"mcp-email": [_CLOCK_EXT], "shop": [_CLOCK_EXT], "blog": []}))]) + + with patch("agent_env.env.env.Env.get", return_value=env): + await _step().execute(context) + + assert [(r.method, str(r.url)) for r in sent][2:] == [ + ("POST", f"{_CARD}/svc/mcp-email/agentenv/ext/sync_time"), ("POST", f"{_CARD}/svc/shop/agentenv/ext/sync_time"), + ] + record = context.metadata["clock_configurations"][0] + assert [(s["service"], s.get("website")) for s in record["synced"]] == [("email", None), ("shop", True)] + assert [(s["service"], s["website"], s["reason"]) for s in record["skipped"]] == [("blog", True, "not_advertised")] + + +@pytest.mark.asyncio +async def test_execute_syncs_a_single_website_env(monkeypatch): + from unittest.mock import patch + + from agent_env.env.envs.website import WebsiteEnv + + sent = _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) + context = TaskStepContext(deployed_envs=[_carded(await _composed_card({"shop": [_CLOCK_EXT]}))]) + + with patch("agent_env.env.env.Env.get", return_value=WebsiteEnv(id="e1", version=1, environment_name="shop")): + await _step().execute(context) + + assert [(r.method, str(r.url)) for r in sent][2:] == [("POST", f"{_CARD}/svc/shop/agentenv/ext/sync_time")] + assert json.loads(sent[2].content) == {"env_get_time_url": CLOCK_URL} + + +@pytest.mark.asyncio +async def test_sync_one_reads_a_websites_card_live_at_its_own_gateway_path(monkeypatch): + read = [] + + async def fake_card(base_url, **kw): + read.append(base_url) + return {"capabilities": {"extensions": []}} + + monkeypatch.setattr(mod.protocol_v1, "get_card", fake_card) + assert await _step()._sync_one(None, "http://gw", "shop", CLOCK_URL, mcp=False) == {"synced": False, "reason": "not_advertised"} + assert read == ["http://gw/svc/shop"] + + +@pytest.mark.asyncio +async def test_execute_reads_a_website_sharing_an_mcp_servers_name_live(monkeypatch): + from types import SimpleNamespace + from unittest.mock import patch + + sent = _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) + env = SimpleNamespace(mcp_server_envs=[SimpleNamespace(environment_name="email")], + website_envs=[SimpleNamespace(environment_name="email")]) + context = TaskStepContext(deployed_envs=[_carded(await _composed_card({"mcp-email": [_CLOCK_EXT]}))]) + + with patch("agent_env.env.env.Env.get", return_value=env): + await _step().execute(context) + + assert [(r.method, str(r.url)) for r in sent][2:] == [ + ("POST", f"{_CARD}/svc/mcp-email/agentenv/ext/sync_time"), ("GET", "http://gw/svc/email/.well-known/agent-env.json"), + ] + + +@pytest.mark.asyncio +async def test_strict_execute_skips_websites_but_not_mcp_servers_that_do_not_follow_the_clock(monkeypatch): + from types import SimpleNamespace + from unittest.mock import patch + + _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) + env = SimpleNamespace(mcp_server_envs=[SimpleNamespace(environment_name="email")], + website_envs=[SimpleNamespace(environment_name="shop"), SimpleNamespace(environment_name="legacy")]) + context = TaskStepContext(deployed_envs=[_carded(await _composed_card({"mcp-email": [_CLOCK_EXT], "shop": []}))]) + + with patch("agent_env.env.env.Env.get", return_value=env): + await _step(tolerate_missing_sync_time=False).execute(context) + + record = context.metadata["clock_configurations"][0] + assert [s["service"] for s in record["synced"]] == ["email"] + assert [(s["service"], s["reason"]) for s in record["skipped"]] == [("shop", "not_advertised"), ("legacy", "no_env_card")] + + unsynced = TaskStepContext(deployed_envs=[_carded(await _composed_card({"mcp-email": [], "shop": [_CLOCK_EXT]}))]) + with patch("agent_env.env.env.Env.get", return_value=env), pytest.raises(RuntimeError, match="email does not advertise"): + await _step(tolerate_missing_sync_time=False).execute(unsynced) + + _CLOCK_EXT = {"uri": EXT_CLOCK_URI, "params": {"endpoint": "/agentenv/ext/sync_time", "methods": {"sync_time": {"method": "POST"}}}} _ZONE_EXT = {"uri": EXT_CLOCK_URI, "params": {"endpoint": "/agentenv/ext/sync_time", "methods": {"sync_time": {"method": "POST", "request": { "type": "object", "properties": {"env_get_time_url": {"type": "string"}, "timezone": {"type": "string"}}, "required": ["env_get_time_url"]}}}}} @@ -226,7 +317,7 @@ def test_without_a_zone_virtual_time_is_passed_through_untouched(): @pytest.mark.asyncio async def test_execute_arms_the_gateway_at_the_local_instant_and_tells_every_server_the_zone(monkeypatch): sent = _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) - monkeypatch.setattr(SyncEnvClockTaskStep, "_service_names", lambda self, deployed: ["email"]) + monkeypatch.setattr(SyncEnvClockTaskStep, "_backing_servers", lambda self, deployed: [BackingServer("email")]) context = TaskStepContext(deployed_envs=[_carded(await _composed_card({"mcp-email": [_ZONE_EXT]}))]) await _zoned().execute(context) @@ -241,7 +332,7 @@ async def test_execute_arms_the_gateway_at_the_local_instant_and_tells_every_ser @pytest.mark.parametrize("tolerate", [True, False]) async def test_a_server_that_cannot_take_the_zone_fails_the_step_before_any_sync_is_sent(monkeypatch, tolerate): sent = _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) - monkeypatch.setattr(SyncEnvClockTaskStep, "_service_names", lambda self, deployed: ["email"]) + monkeypatch.setattr(SyncEnvClockTaskStep, "_backing_servers", lambda self, deployed: [BackingServer("email")]) context = TaskStepContext(deployed_envs=[_carded(await _composed_card({"mcp-email": [_OLD_EXT]}))]) with pytest.raises(RuntimeError, match="takes no timezone"): @@ -264,6 +355,62 @@ async def test_a_server_that_does_not_advertise_clock_is_still_skipped_when_a_zo assert out == {"synced": False, "reason": "not_advertised"} +@pytest.mark.asyncio +async def test_a_zoned_step_tells_a_website_that_takes_the_zone_and_fails_on_one_that_cannot(monkeypatch): + """A website is tolerated for not following the clock, but one that follows it without taking the zone fails the + step, as an MCP server does.""" + from types import SimpleNamespace + from unittest.mock import patch + + env = SimpleNamespace(mcp_server_envs=[], website_envs=[SimpleNamespace(environment_name="shop")]) + sent = _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) + context = TaskStepContext(deployed_envs=[_carded(await _composed_card({"shop": [_ZONE_EXT]}))]) + with patch("agent_env.env.env.Env.get", return_value=env): + await _zoned().execute(context) + assert json.loads(sent[2].content) == {"env_get_time_url": CLOCK_URL, "timezone": "America/New_York"} + assert context.metadata["clock_configurations"][0]["synced"][0]["website"] is True + + _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) + older = TaskStepContext(deployed_envs=[_carded(await _composed_card({"shop": [_OLD_EXT]}))]) + with patch("agent_env.env.env.Env.get", return_value=env), pytest.raises(RuntimeError, match="shop advertises .* takes no timezone"): + await _zoned().execute(older) + + +@pytest.mark.asyncio +async def test_a_website_sharing_an_mcp_servers_name_is_synced_with_the_envs_control_headers(monkeypatch): + """Its card is read live, with no record, and its sync_time still carries the env's control token.""" + import dataclasses + from types import SimpleNamespace + from unittest.mock import patch + + import httpx + + sent, real = [], httpx.AsyncClient + website_card = {"name": "email", "url": "/agentenv", "capabilities": {"extensions": [_CLOCK_EXT]}} + + def handle(request: httpx.Request) -> httpx.Response: + sent.append(request) + if request.url.path.endswith("/clock/state"): + return httpx.Response(200, json={"env_get_time_url": CLOCK_URL}) + if request.url.path == "/svc/email/.well-known/agent-env.json": + return httpx.Response(200, json=website_card) + return httpx.Response(200, json={}) + + monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: real(transport=httpx.MockTransport(handle))) + env = SimpleNamespace(mcp_server_envs=[SimpleNamespace(environment_name="email")], + website_envs=[SimpleNamespace(environment_name="email")]) + record = dataclasses.replace(_carded(await _composed_card({"mcp-email": [_CLOCK_EXT]})), control_token="ctl") + context = TaskStepContext(deployed_envs=[record]) + + with patch("agent_env.env.env.Env.get", return_value=env): + await _step().execute(context) + + website_sync = [r for r in sent if str(r.url) == "http://gw/svc/email/agentenv/ext/sync_time"] + assert len(website_sync) == 1 + assert {k: website_sync[0].headers.get(k) for k in record.control_headers} == record.control_headers + assert [s["service"] for s in context.metadata["clock_configurations"][0]["synced"]] == ["email", "email"] + + def test_a_padded_zone_is_stored_and_sent_stripped(): s = _zoned(timezone=" America/New_York ") assert s.timezone == "America/New_York" and s.to_dict()["timezone"] == "America/New_York" diff --git a/tst/unit/task_step/test_apply_server_config.py b/tst/unit/task_step/test_apply_server_config.py index f9badb26c..ddeb96806 100644 --- a/tst/unit/task_step/test_apply_server_config.py +++ b/tst/unit/task_step/test_apply_server_config.py @@ -589,6 +589,97 @@ async def test_live_read_strips_a_trailing_slash_from_the_gateway_url(): assert [url for url, _ in gw.post_calls] == [f"{_GATEWAY}/svc/mcp-slack/agentenv/ext/set_errors"] +# --- websites --- + + +def _fake_multi_env(mcp_names, website_names): + return SimpleNamespace(mcp_server_envs=[SimpleNamespace(environment_name=s) for s in mcp_names], + website_envs=[SimpleNamespace(environment_name=s) for s in website_names]) + + +class _KeyedGateway(_MockGateway): + """A gateway serving each card at /svc//.well-known/agent-env.json, where a website's key is its bare name.""" + + async def get(self, url, *, timeout=None, params=None): + self.get_calls.append(url) + for key, card in self.cards.items(): + if url.endswith(f"/svc/{key}/.well-known/agent-env.json"): + return _resp(200, url, card) + return _resp(404, url, {"error": "no card"}) + + +@pytest.mark.asyncio +async def test_stored_card_broadcast_reaches_websites_and_leaves_a_shared_name_to_the_mcp_server(): + gw = _MockGateway({}) + ext = _ext(_SET_ACTING_USER_URI, "/agentenv/ext/set_acting_user") + ctx = _carded_context(await _composed_card({"mcp-slack": [ext], "shop": [ext]})) + step = _step(directives=[{"service": "*", "uri": _SET_ACTING_USER_URI, "args": {"user_email": "a@x.com"}}]) + with _patch_httpx(gw), patch("agent_env.env.env.Env.get", return_value=_fake_multi_env(["slack"], ["shop", "slack"])): + out = await step.execute(ctx) + + assert gw.get_calls == [] + assert [url for url, _ in gw.post_calls] == [f"{_CARD}/svc/mcp-slack/agentenv/ext/set_acting_user", + f"{_CARD}/svc/shop/agentenv/ext/set_acting_user"] + assert [c["service"] for c in out.metadata["server_config_changes"]] == ["slack", "shop"] + + +@pytest.mark.asyncio +async def test_live_read_broadcast_reads_a_websites_card_at_its_own_gateway_path(): + ext = _ext(_SET_ACTING_USER_URI, "/agentenv/ext/set_acting_user") + gw = _KeyedGateway({"mcp-slack": _card(ext), "shop": _card(ext)}) + step = _step(directives=[{"service": "*", "uri": _SET_ACTING_USER_URI, "args": {"user_email": "a@x.com"}}]) + with _patch_httpx(gw), patch("agent_env.env.env.Env.get", return_value=_fake_multi_env(["slack"], ["shop"])): + await step.execute(_make_context()) + + assert gw.get_calls == [f"{_GATEWAY}/svc/mcp-slack/.well-known/agent-env.json", f"{_GATEWAY}/svc/shop/.well-known/agent-env.json"] + assert [url for url, _ in gw.post_calls] == [f"{_GATEWAY}/svc/mcp-slack/agentenv/ext/set_acting_user", + f"{_GATEWAY}/svc/shop/agentenv/ext/set_acting_user"] + + +@pytest.mark.asyncio +async def test_broadcast_reaches_a_single_website_env(): + from agent_env.env.envs.website import WebsiteEnv + + gw = _MockGateway({}) + ctx = _carded_context(await _composed_card({"shop": [_ext(_SET_ACTING_USER_URI, "/agentenv/ext/set_acting_user")]})) + step = _step(directives=[{"service": "*", "uri": _SET_ACTING_USER_URI, "args": {"user_email": "a@x.com"}}]) + with _patch_httpx(gw), patch("agent_env.env.env.Env.get", return_value=WebsiteEnv(id="env-x", version=1, environment_name="shop")): + await step.execute(ctx) + + assert [url for url, _ in gw.post_calls] == [f"{_CARD}/svc/shop/agentenv/ext/set_acting_user"] + + +@pytest.mark.asyncio +async def test_strict_broadcast_skips_websites_without_a_card_or_the_extension(): + gw = _MockGateway({}) + ext = _ext(_SET_ERRORS_URI, "/agentenv/ext/set_errors") + ctx = _carded_context(await _composed_card({"mcp-slack": [ext], "shop": []})) + step = _step(directives=[{"service": "*", "uri": _SET_ERRORS_URI, "args": {"error_rate": 1.0}}]) + with _patch_httpx(gw), patch("agent_env.env.env.Env.get", return_value=_fake_multi_env(["slack"], ["shop", "legacy"])): + out = await step.execute(ctx) + + assert [url for url, _ in gw.post_calls] == [f"{_CARD}/svc/mcp-slack/agentenv/ext/set_errors"] + assert [c["service"] for c in out.metadata["server_config_changes"]] == ["slack"] + assert [(s["service"], s["reason"]) for s in out.metadata["server_config_skipped"]] == [ + ("shop", "extension_not_advertised"), ("legacy", "no_env_card")] + + +@pytest.mark.asyncio +async def test_strict_broadcast_still_fails_for_an_mcp_server_or_a_named_website_lacking_the_extension(): + ext = _ext(_SET_ERRORS_URI, "/agentenv/ext/set_errors") + broadcast = {"service": "*", "uri": _SET_ERRORS_URI, "args": {"error_rate": 1.0}} + unadvertising_mcp = _carded_context(await _composed_card({"mcp-slack": [], "shop": [ext]})) + with _patch_httpx(_MockGateway({})), patch("agent_env.env.env.Env.get", return_value=_fake_multi_env(["slack"], ["shop"])): + with pytest.raises(ApplyServerConfigError, match="not advertised on card for service 'slack'"): + await _step(directives=[broadcast]).execute(unadvertising_mcp) + + unadvertising_shop = _carded_context(await _composed_card({"mcp-slack": [ext], "shop": []})) + named = {"service": "shop", "uri": _SET_ERRORS_URI, "args": {"error_rate": 1.0}} + with _patch_httpx(_MockGateway({})), patch("agent_env.env.env.Env.get", return_value=_fake_multi_env(["slack"], ["shop"])): + with pytest.raises(ApplyServerConfigError, match="not advertised on card for service 'shop'"): + await _step(directives=[broadcast, named]).execute(unadvertising_shop) + + # --- schema / validation (pure, no I/O) --- diff --git a/tst/unit/task_step/test_snapshot_env.py b/tst/unit/task_step/test_snapshot_env.py index 877244c37..380690fc2 100644 --- a/tst/unit/task_step/test_snapshot_env.py +++ b/tst/unit/task_step/test_snapshot_env.py @@ -1367,3 +1367,177 @@ def shared_credentials_env(self) -> dict[str, str]: store = _SharingS3Store(client=object(), bucket="bucket") monkeypatch.setattr(snapshot_mod, "get_config", lambda: type("Cfg", (), {"get_object_store": lambda self: store})()) + + +# ── websites ───────────────────────────────────────────────────────────────── + +def _multi_env(mcp_names, website_names): + return SimpleNamespace(id="env-1", version=3, mcp_server_envs=[_FakeServiceEnv(n) for n in mcp_names], + website_envs=[_FakeServiceEnv(n) for n in website_names]) + + +def test_enumerate_includes_websites_and_leaves_a_shared_name_to_the_mcp_server(): + servers = SnapshotEnvTaskStep._enumerate_servers(_multi_env(["slack", "gmail"], ["shop", "slack"])) + assert [(s.environment_name, s.mcp) for s in servers] == [("slack", True), ("gmail", True), ("shop", False)] + + +def test_enumerate_a_single_website_env_and_a_multi_env_of_websites_only(): + from agent_env.env.envs.website import WebsiteEnv + + site = WebsiteEnv(id="site-env", version=1, environment_name="shop") + assert [(s.environment_name, s.mcp) for s in SnapshotEnvTaskStep._enumerate_servers(site)] == [("shop", False)] + assert SnapshotEnvTaskStep._enumerate_environments(_multi_env([], ["shop"])) == ["shop"] + + +@pytest.mark.asyncio +async def test_export_of_a_website_reads_its_data_plane_from_the_stored_card(monkeypatch, tmp_path): + record = _carded(await _card({"mcp-slack": [], "shop": []})) + sent = _mock_http(monkeypatch, rpc_result={"parts": [{"kind": "data", "data": {"orders": 2}}]}) + + out = str(tmp_path / "export-tmp") + suffix = await SnapshotEnvTaskStep._export_environment_to_file("https://gw-1", "shop", out, 30, record, mcp=False) + + assert suffix == ".json" + assert [(r.method, str(r.url)) for r in sent] == [("POST", f"{_CARD}/svc/shop/agentenv")] + with open(out) as f: + assert json.load(f) == {"orders": 2} + + +@pytest.mark.asyncio +async def test_export_of_a_website_without_a_stored_card_probes_its_own_gateway_path(monkeypatch, tmp_path): + import dataclasses + + record = dataclasses.replace(_carded({}), environment_card=None) + sent = _mock_http(monkeypatch, rpc_result={"parts": [{"kind": "data", "data": {"orders": 2}}]}, + body=b'{"name": "shop", "url": "/agentenv"}') + + suffix = await SnapshotEnvTaskStep._export_environment_to_file("https://gw-1", "shop", str(tmp_path / "out"), 30, record, mcp=False) + + assert suffix == ".json" + assert [(r.method, str(r.url)) for r in sent] == [ + ("GET", "https://gw-1/svc/shop/.well-known/agent-env.json"), + ("GET", "https://gw-1/svc/shop/.well-known/agent-env.json"), + ("POST", "https://gw-1/svc/shop/agentenv"), + ] + + +def _serve_website_card(monkeypatch, card_response): + """A gateway whose website ``shop`` answers its card read with ``card_response`` (a response or an exception to + raise) and whose MCP server ``slack`` serves a v1 data plane; returns the requests sent.""" + import httpx + + sent, real = [], httpx.AsyncClient + + def handle(request: httpx.Request) -> httpx.Response: + sent.append(request) + if request.url.path == f"/svc/shop{WELL_KNOWN_PATH}": + if isinstance(card_response, Exception): + raise card_response + return card_response + if request.url.path.endswith("/agentenv"): + return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": {"parts": [{"kind": "data", "data": {}}]}}) + return httpx.Response(200, json={}) + + monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: real(transport=httpx.MockTransport(handle))) + return sent + + +def _card_read_failures(): + import httpx + + return [httpx.Response(500, json={}), httpx.Response(503, json={}), httpx.ReadTimeout("card read timed out"), + httpx.ConnectError("connection refused")] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("card_response", _card_read_failures(), ids=["500", "503", "timeout", "connect-error"]) +async def test_export_of_a_website_whose_card_read_fails_raises_rather_than_skipping_it(monkeypatch, tmp_path, card_response): + """Without a stored card, a website card read that fails is no proof the website serves no data plane.""" + import httpx + + sent = _serve_website_card(monkeypatch, card_response) + + with pytest.raises(httpx.HTTPError): + await SnapshotEnvTaskStep._export_environment_to_file("https://gw-1", "shop", str(tmp_path / "out"), 30, mcp=False) + assert [str(r.url) for r in sent] == [f"https://gw-1/svc/shop{WELL_KNOWN_PATH}"] + + +@pytest.mark.asyncio +async def test_export_of_a_website_without_a_card_is_skipped(monkeypatch, tmp_path): + import httpx + + sent = _serve_website_card(monkeypatch, httpx.Response(404, json={})) + + assert await SnapshotEnvTaskStep._export_environment_to_file("https://gw-1", "shop", str(tmp_path / "out"), 30, mcp=False) is None + assert [str(r.url) for r in sent] == [f"https://gw-1/svc/shop{WELL_KNOWN_PATH}"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("card_response", _card_read_failures(), ids=["500", "503", "timeout", "connect-error"]) +async def test_core_fails_a_snapshot_whose_website_card_cannot_be_read(monkeypatch, card_response): + """Another server exporting does not let the snapshot through without a website it could not check.""" + real_export = SnapshotEnvTaskStep.__dict__["_export_environment_to_file"] + _install_core_artifact_fakes(monkeypatch, EnvironmentUniverseArtifact) + monkeypatch.setattr(SnapshotEnvTaskStep, "_export_environment_to_file", real_export) + _serve_website_card(monkeypatch, card_response) + + with pytest.raises(RuntimeError, match=r"1/2 services were not exported \(missing=\['shop'\]\)"): + await SnapshotEnvTaskStep.snapshot_env_state( + env=_multi_env(["slack"], ["shop"]), gateway_url="https://gw-1", snapshot_id="snap-x", + ) + + +@pytest.mark.asyncio +async def test_export_of_a_website_serving_no_data_plane_exports_nothing_and_sends_nothing(monkeypatch, tmp_path): + record = _carded(await _card({"mcp-slack": []})) + sent = _mock_http(monkeypatch) + + assert await SnapshotEnvTaskStep._export_environment_to_file("https://gw-1", "shop", str(tmp_path / "out"), 30, record, mcp=False) is None + assert sent == [] + + +@pytest.mark.asyncio +async def test_core_snapshots_websites_through_their_data_plane_and_leaves_out_those_without_one(monkeypatch): + calls = [] + + async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None, **website): + calls.append((name, website)) + if name == "legacy-site": + return None + with open(tmp_path, "w") as f: + json.dump({"ok": name}, f) + return ".json" + + universe_puts = {} + + class _FakeUniverse: + id = "snap-x" + version = 1 + + @staticmethod + def put(id, *, environment_artifacts, metadata=None): + universe_puts["names"] = [sa.environment_name for sa in environment_artifacts] + return _FakeUniverse() + + _install_core_artifact_fakes(monkeypatch, _FakeUniverse) + monkeypatch.setattr(SnapshotEnvTaskStep, "_export_environment_to_file", staticmethod(fake_export)) + + result = await SnapshotEnvTaskStep.snapshot_env_state( + env=_multi_env(["slack"], ["shop", "slack", "legacy-site"]), gateway_url="https://gw", snapshot_id="snap-x", + ) + + assert sorted(calls) == [("legacy-site", {"mcp": False}), ("shop", {"mcp": False}), ("slack", {})] + assert universe_puts["names"] == ["slack", "shop"] + assert result.environments_snapshotted == ["slack", "shop"] and result.total == 3 + + +@pytest.mark.asyncio +async def test_core_raises_when_no_service_exported_state(monkeypatch): + async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, **website): + return None + + _install_core_artifact_fakes(monkeypatch, EnvironmentUniverseArtifact) + monkeypatch.setattr(SnapshotEnvTaskStep, "_export_environment_to_file", staticmethod(fake_export)) + + with pytest.raises(RuntimeError, match=r"exported no state: its websites \['shop'\] serve no v1 data plane"): + await SnapshotEnvTaskStep.snapshot_env_state(env=_multi_env([], ["shop"]), gateway_url="https://gw", snapshot_id="snap-x") From b18049d5eeaed225444da4c5da146f8850b4e1ef Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 12:52:03 -0800 Subject: [PATCH 02/57] fix(website): sync_env_clock, snapshot_env and broadcasts reach each server the env card lists, at its own route (FD-4683) The servers behind an env's gateway come from its card's children_environments, each at its own /svc route, through new protocol helpers (ChildEnv, children, child_at, child_named, serves), not from the env's definition: a website sharing an MCP server's name is its own server, and a card without data/get in its operations serves no v1 data plane. The definition is read only for servers that serve no card, and for a record whose card can't be read, logged. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/agentenv-protocol/README.md | 10 ++ .../src/agentenv_protocol/client.py | 80 +++++++++ .../agentenv-protocol/tests/test_client.py | 52 ++++++ src/agent_env/env/legacy_protocol.py | 105 ++++++++++-- .../task_steps/apply_server_config.py | 81 +++++---- .../task_step/task_steps/snapshot_env.py | 86 +++++----- .../task_step/task_steps/sync_env_clock.py | 44 ++--- tst/unit/env/test_legacy_protocol.py | 100 ++++++++++- tst/unit/task/local_run_write_fence_test.py | 2 +- tst/unit/task_step/sync_env_clock_test.py | 80 ++++++--- .../task_step/test_apply_server_config.py | 30 +++- tst/unit/task_step/test_snapshot_env.py | 156 ++++++++++++++---- 12 files changed, 631 insertions(+), 195 deletions(-) diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index b6cc8c649..4e3b0de48 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -198,6 +198,16 @@ with any other transport are kept beside the SDK's entry. Card URLs are paths, r address the card was fetched from. On the client side, `client.mcp_path(card)` returns the declared path, or `/mcp` for a card without one. +A composed card, such as a gateway's, nests each backing environment's card under +`children_environments`, its `url` and endpoints rooted at the route the composer serves it at. +`client.children(card)` returns each as a `ChildEnv` (its `name`, `url` and `card`, with its +`extensions`, `operations` and `serves(method)`, and `base_url(address)`, its data plane resolved +against the composed card's address). Two children may share a name, as a website and an MCP +server can, so `client.child_at(card, url)` finds one by its url or route; `client.child_named(card, +name)` raises `AmbiguousChildError` for a shared name, and `client.find_child` returns the first. +`client.serves(card, method)` says whether a card serves a data-plane method by its declared +`operations`, None for a card that declares none. + Extensions are invoked from what the card advertises. `client.find_extension_method(card, uri, method)` returns one advertised method, whose `endpoint` is the method's own or else the extension's, and `client.invoke_extension(base_url, card, uri, params, method=...)` calls it with diff --git a/packages/agentenv-protocol/src/agentenv_protocol/client.py b/packages/agentenv-protocol/src/agentenv_protocol/client.py index dcddaf541..a1e352879 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/client.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/client.py @@ -7,6 +7,7 @@ import logging from collections.abc import Iterator from contextvars import ContextVar +from dataclasses import dataclass, field import httpx @@ -210,6 +211,83 @@ def find_tool(card: dict, name: str) -> dict | None: return None +def serves(card: dict, method: str) -> bool | None: + """Whether a card serves the data-plane ``method`` (``data/get`` say): True or False by its declared + ``capabilities.operations``, or None when it declares none, as an older card doesn't, so the caller decides as it + did before cards declared them (a probe, say).""" + declared = operations(card) + return None if declared is None else method in declared + + +@dataclass(frozen=True) +class ChildEnv: + """One child env a composed card nests under ``children_environments``: its ``name`` and ``url`` as its card gives + them, and that ``card``, which says what it offers. A composer roots each child's ``url`` (and its endpoints) at + the route it serves the child at, so two children sharing a name have distinct urls; ``url`` is None for a child + whose card names none.""" + + name: str | None + url: str | None + card: dict = field(hash=False, repr=False) + + @property + def extensions(self) -> list[dict]: + """The extensions the child's card advertises (``capabilities.extensions``).""" + return list((self.card.get("capabilities") or {}).get("extensions") or []) + + @property + def operations(self) -> list[str] | None: + """The data-plane methods the child's card declares (``operations``), or None when it declares none.""" + return operations(self.card) + + def find_extension(self, uri: str) -> dict | None: + """The extension the child's card advertises under ``uri``, or None (``find_extension``).""" + return find_extension(self.card, uri) + + def serves(self, method: str) -> bool | None: + """Whether the child's card serves the data-plane ``method``, None when it declares no operations (``serves``).""" + return serves(self.card, method) + + def base_url(self, address: str) -> str: + """Where the child's data plane is, its ``url`` without the RPC path, resolved against ``address``, the composed + card's: a path joins onto it, a full URL stands alone, and a child naming no url is at ``address`` itself.""" + url = (self.url or RPC_PATH).removesuffix(RPC_PATH) + return url if "://" in url else f"{address.rstrip('/')}{url}" + + +def children(card: dict) -> list[ChildEnv]: + """Each child env a composed card nests under ``children_environments``, in its order; none for a leaf card, or + one whose ``children_environments`` is missing or null. An entry that isn't an object is skipped.""" + return [ChildEnv(name=child.get("name"), url=child.get("url"), card=child) + for child in card.get("children_environments") or [] if isinstance(child, dict)] + + +def child_at(card: dict, url: str) -> ChildEnv | None: + """The child env of a composed card at ``url``: the one whose ``url`` is ``url``, or whose data plane is there + (``url`` without the RPC path, ``/svc/mcp-slack`` for ``/svc/mcp-slack/agentenv`` say), a trailing slash aside; + None when no child is. A composer serves each child at its own route, so this is the lookup two children sharing a + name can't confuse.""" + wanted = url.rstrip("/") + for child in children(card): + if child.url is not None and wanted in (child.url.rstrip("/"), child.url.removesuffix(RPC_PATH).rstrip("/")): + return child + return None + + +class AmbiguousChildError(LookupError): + """More than one child env of a composed card has the name asked for, so the name can't say which is meant.""" + + +def child_named(card: dict, name: str) -> ChildEnv | None: + """The one child env of a composed card named ``name``, or None when none is; raises ``AmbiguousChildError`` when + several are, as a website and an MCP server sharing a name are, which ``child_at`` tells apart by their urls.""" + found = [child for child in children(card) if child.name == name] + if len(found) > 1: + raise AmbiguousChildError(f"{len(found)} child envs are named {name!r} ({', '.join(str(c.url) for c in found)}); " + "look one up by its url") + return found[0] if found else None + + def find_child(card: dict, name: str) -> dict | None: """Return the nested child EnvironmentCard named `name`, or None. @@ -218,6 +296,8 @@ def find_child(card: dict, name: str) -> dict | None: rewritten to be reachable from the composed card's base_url. Callers resolve a service by name here, then use the child card with `find_extension`/`invoke_extension`. Tolerates a missing/null `children_environments` so single/leaf cards return None rather than raising. + Two children sharing `name` return the first; `child_named` refuses that, and `child_at` + finds a child by its url, which tells them apart. """ for child in card.get("children_environments") or []: if child.get("name") == name: diff --git a/packages/agentenv-protocol/tests/test_client.py b/packages/agentenv-protocol/tests/test_client.py index edcd38798..bfc65e9a7 100644 --- a/packages/agentenv-protocol/tests/test_client.py +++ b/packages/agentenv-protocol/tests/test_client.py @@ -190,6 +190,58 @@ def test_find_child_tolerates_missing_children(): assert protocol_v1.find_child({"children_environments": None}, "slack") is None +_SHARED_NAME = {"name": "gw", "children_environments": [ + {"name": "mail", "url": "/svc/mcp-mail/agentenv", "capabilities": {"operations": ["data/get"], "extensions": [ + {"uri": "urn:agentenv:clock/v1"}]}}, + {"name": "mail", "url": "/svc/mail/agentenv", "capabilities": {"operations": []}}, + {"name": "docs", "capabilities": {}}, + "not a card", +]} + + +def test_children_are_each_nested_card_by_its_name_url_and_card(): + found = protocol_v1.children(_SHARED_NAME) + + assert [(c.name, c.url) for c in found] == [("mail", "/svc/mcp-mail/agentenv"), ("mail", "/svc/mail/agentenv"), ("docs", None)] + assert found[0].card is _SHARED_NAME["children_environments"][0] + assert [c.extensions for c in found] == [[{"uri": "urn:agentenv:clock/v1"}], [], []] + assert [c.operations for c in found] == [["data/get"], [], None] + assert [c.serves("data/get") for c in found] == [True, False, None] + assert found[0].find_extension("urn:agentenv:clock/v1") is not None and found[1].find_extension("urn:agentenv:clock/v1") is None + assert protocol_v1.children({"name": "leaf"}) == [] and protocol_v1.children({"children_environments": None}) == [] + + +def test_a_childs_base_url_is_its_url_without_the_rpc_path_against_the_composed_cards_address(): + mcp, website, docs = protocol_v1.children(_SHARED_NAME) + + assert mcp.base_url("https://env.example/") == "https://env.example/svc/mcp-mail" + assert website.base_url("https://env.example") == "https://env.example/svc/mail" + assert docs.base_url("https://env.example") == "https://env.example" + assert protocol_v1.ChildEnv("x", "https://x.example/agentenv", {}).base_url("https://env.example") == "https://x.example" + + +def test_child_at_finds_a_child_by_its_url_or_data_plane_route_where_two_share_a_name(): + assert protocol_v1.child_at(_SHARED_NAME, "/svc/mail").url == "/svc/mail/agentenv" + assert protocol_v1.child_at(_SHARED_NAME, "/svc/mcp-mail/").url == "/svc/mcp-mail/agentenv" + assert protocol_v1.child_at(_SHARED_NAME, "/svc/mail/agentenv").url == "/svc/mail/agentenv" + assert protocol_v1.child_at(_SHARED_NAME, "/svc/docs") is None + assert protocol_v1.child_at({"name": "leaf"}, "/svc/mail") is None + + +def test_child_named_refuses_a_name_two_children_share(): + assert protocol_v1.child_named(_SHARED_NAME, "docs").name == "docs" + assert protocol_v1.child_named(_SHARED_NAME, "gmail") is None + with pytest.raises(protocol_v1.AmbiguousChildError, match="2 child envs are named 'mail'"): + protocol_v1.child_named(_SHARED_NAME, "mail") + assert protocol_v1.find_child(_SHARED_NAME, "mail")["url"] == "/svc/mcp-mail/agentenv" + + +def test_serves_is_what_a_card_declares_of_a_method_and_none_when_it_declares_no_operations(): + assert protocol_v1.serves({"capabilities": {"operations": ["data/get"]}}, "data/get") is True + assert protocol_v1.serves({"capabilities": {"operations": ["data/add"]}}, "data/get") is False + assert protocol_v1.serves({"capabilities": {}}, "data/get") is None + + def test_find_extension_ignores_children(): # set-errors/v1 is advertised only on the children, never at the top level assert protocol_v1.find_extension(_CARD_WITH_CHILDREN, "urn:agentenv:set-errors/v1") is None diff --git a/src/agent_env/env/legacy_protocol.py b/src/agent_env/env/legacy_protocol.py index 8790157ac..f5ca7a002 100644 --- a/src/agent_env/env/legacy_protocol.py +++ b/src/agent_env/env/legacy_protocol.py @@ -8,12 +8,15 @@ from __future__ import annotations import asyncio +import dataclasses import logging from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Optional import httpx -from agentenv_protocol import RPC_PATH, DataPart, client as protocol_v1 +from agentenv_protocol import METHOD_GET, RPC_PATH, DataPart, client as protocol_v1 + +from agent_env.env.gateway.constants import EXT_WEBSITE_URI if TYPE_CHECKING: from agent_env.env.env import DeployedEnv @@ -36,16 +39,28 @@ def environment_base_url(gateway_url: Optional[str], environment_name: str, mcp: @dataclass(frozen=True) class BackingServer: - """A server behind an env's gateway, by its environment_name: an MCP server, at ``/svc/mcp-{name}``, or a website, - at ``/svc/{name}``.""" + """A server behind an env's address, by its environment_name: an MCP server or, with ``mcp=False``, a website. + + One the env's card lists (``card_servers``) carries that ``child`` and the ``address`` its paths resolve against. + One the card lists none for, a server serving no card, carries the ``address`` alone: it has no card, and none is + read for it. One named by the env definition of a record whose card can't be read carries neither, so its card is + read live at its gateway path, ``/svc/mcp-{name}`` or a website's ``/svc/{name}`` (``server_card``).""" environment_name: str mcp: bool = True + child: Optional[protocol_v1.ChildEnv] = None + address: Optional[str] = None + + @property + def card(self) -> Optional[dict]: + """The server's card as the env's card lists it, or None.""" + return self.child.card if self.child is not None else None -def backing_servers(env: Any) -> list[BackingServer]: - """The servers behind env's gateway: a MultiEnv's MCP servers then its websites, a WebsiteEnv's website, or else - the env's one MCP server; empty for an env with none.""" +def definition_servers(env: Any) -> list[BackingServer]: + """The servers an env definition names: a MultiEnv's MCP servers then its websites, a WebsiteEnv's website, or + else the env's one MCP server; empty for an env with none. Read only for what the env's card can't say + (``backing_servers``).""" from agent_env.env.envs.website import WebsiteEnv if isinstance(env, WebsiteEnv): @@ -57,16 +72,74 @@ def backing_servers(env: Any) -> list[BackingServer]: return servers -def shared_names(servers: list[BackingServer]) -> set[str]: - """The environment_names an MCP server and a website in ``servers`` share, which a stored env card can't tell apart.""" - return {s.environment_name for s in servers if s.mcp} & {s.environment_name for s in servers if not s.mcp} - - -def mcp_wins(servers: list[BackingServer]) -> list[BackingServer]: - """``servers`` without each website that shares its environment_name with an MCP server, as a MultiEnv routes an - artifact of that name to the MCP server.""" - shared = shared_names(servers) - return [s for s in servers if s.mcp or s.environment_name not in shared] +def card_servers(card: dict, address: str) -> list[BackingServer]: + """The servers ``card``, read at ``address``, lists, each with its own card: a leaf card's env itself; else each + child the composed card nests, after the env itself when its own card serves ``data/get``, as a computer's driver + fronting its apps does. A child is a website when its card carries ``website/v1`` or its url is at a website's + gateway route, ``/svc/{name}`` (an MCP server's is ``/svc/mcp-{name}``), so a website and an MCP server sharing a + name are two servers.""" + own = protocol_v1.ChildEnv(name=card.get("name"), url=card.get("url"), card=card) + if not isinstance(card.get("children_environments"), list): + return [_listed(own, address)] + servers = [_listed(own, address)] if protocol_v1.serves(card, METHOD_GET) else [] + return servers + [_listed(child, address) for child in protocol_v1.children(card)] + + +def _listed(child: protocol_v1.ChildEnv, address: str) -> BackingServer: + route, url = f"/svc/{child.name}", (child.url or "").rstrip("/") + website = child.find_extension(EXT_WEBSITE_URI) is not None or url == route or url.startswith(f"{route}/") + return BackingServer(child.name, mcp=not website, child=child, address=address) + + +async def backing_servers(deployed: Optional[DeployedEnv], gateway_url: Optional[str], env: Any, + timeout: int = 10) -> list[BackingServer]: + """The servers behind an env as its card lists them (``card_servers``), then, behind a gateway, each server its + definition ``env`` names that the card lists none of the same name and kind for: one serving no card, which the + gateway leaves off its card, so it has none. + + The card is ``deployed``'s stored one when ``gateway_url`` is its own gateway or None, else read live at + ``gateway_url`` (an override, or a record that kept no card). A record with no card URL, an older one, or no + record, or a live read that fails, leaves the definition alone: its servers, each read live at its gateway path, + logged once.""" + from agent_env.env.env import gateway_url_of + + gateway = gateway_url.rstrip("/") if gateway_url else None + card, address, why = None, None, "it has no record" + if deployed is not None and deployed.environment_card and (gateway is None or (gateway_url_of(deployed) or "").rstrip("/") == gateway): + card, address = deployed.environment_card, deployed.environment_url + elif deployed is not None and deployed.environment_card_url and gateway: + try: + card, address = await protocol_v1.get_card(gateway, timeout=timeout), gateway + except httpx.HTTPError as e: + why = f"its card at {gateway} could not be read ({type(e).__name__})" + elif deployed is not None: + why = "its record has no card URL" + named = definition_servers(env) + if not isinstance(card, dict): + logger.info(f"env {getattr(env, 'id', None)!r}: {why}, so its servers are the ones its definition names, " + f"each read live at its gateway path: {[(s.environment_name, s.mcp) for s in named]}") + return named + servers = card_servers(card, address) + if not gateway: + return servers + listed = {(s.environment_name, s.mcp) for s in servers} + return servers + [dataclasses.replace(s, address=address) for s in named if (s.environment_name, s.mcp) not in listed] + + +async def server_card(server: BackingServer, gateway_url: Optional[str], timeout: int = 10) -> tuple[Optional[str], Optional[dict]]: + """A server's card and the base its endpoints resolve against: as the env's card lists it, None for a server the + card lists none for, else read live at its gateway path (``child_env_card``).""" + if server.address is not None: + return server.address, server.card + return await child_env_card(None, gateway_url, server.environment_name, timeout=timeout, mcp=server.mcp) + + +async def server_v1_base_url(server: BackingServer, gateway_url: Optional[str]) -> Optional[str]: + """A server's v1 data-plane base: its card's ``url`` resolved against the env's address, None for a server the + env's card lists none for, else as the live probe at its gateway path finds it (``v1_base_url``).""" + if server.address is not None: + return server.child.base_url(server.address) if server.child is not None else None + return await v1_base_url(None, gateway_url, server.environment_name, mcp=server.mcp) async def v1_base_url(deployed: Optional[DeployedEnv], gateway_url: Optional[str], environment_name: str, mcp: bool = True) -> Optional[str]: diff --git a/src/agent_env/task_step/task_steps/apply_server_config.py b/src/agent_env/task_step/task_steps/apply_server_config.py index c8c8e1f08..91cc24167 100644 --- a/src/agent_env/task_step/task_steps/apply_server_config.py +++ b/src/agent_env/task_step/task_steps/apply_server_config.py @@ -9,14 +9,16 @@ Config lives on the backing server, not on the gateway itself. Each server's card comes from the env card stored on the deployed record, whose extension endpoints the gateway has already rewritten to its ``/svc/mcp-{service}`` (or a website's ``/svc/{service}``) -proxy; a record without a stored card reads it live there. The agent never sees the card or these endpoints; -only this harness step invokes them. +proxy; a record without a stored card reads it live there. A broadcast reaches each server +the env card lists, by that server's own card and route, so a website sharing its name with +an MCP server is reached too (``legacy_protocol.backing_servers``). The agent never sees the +card or these endpoints; only this harness step invokes them. """ from __future__ import annotations import logging -from typing import Any, ClassVar, Optional +from typing import TYPE_CHECKING, Any, ClassVar, Optional import httpx from agentenv_protocol import client as protocol_v1 @@ -25,6 +27,9 @@ from agent_env.entity.refs import EntityRef from agent_env.task_step.task_step import TaskStep, TaskStepDependency +if TYPE_CHECKING: + from agent_env.env.legacy_protocol import BackingServer + logger = logging.getLogger(__name__) @@ -131,51 +136,40 @@ def from_dict(cls, data: dict) -> "ApplyServerConfigStep": tolerate_unadvertised=data.get("tolerate_unadvertised", False), ) - def _expand_directives(self, deployed) -> tuple[list[ConfigDirective], set[str], set[int]]: - """Expand any broadcast directive (service == ``*``) into one directive - per MCP server and website in the env, resolved from the env definition, - and return them with the names that are websites, whose cards a live read - finds at ``/svc/{name}``, and the positions of the directives a broadcast - made for a website. Those are skipped, as under ``tolerate_unadvertised``, - when the website has no card or doesn't advertise the URI, so a website - the task didn't name never fails it; a directive naming a website, and - every MCP server, stays strict. The env's child service names aren't otherwise - enumerable here (the deployed card list carries no roster), so we read - them from ``Env.get`` — matching how deploy_env / load_artifact resolve - the env. A website sharing its name with an MCP server is left to the MCP - server, as the stored env card can't tell the two apart. Non-broadcast - directives pass through untouched.""" + async def _expand_directives(self, deployed) -> list[tuple[ConfigDirective, Optional[BackingServer], bool]]: + """Each directive with the server it targets and whether it is lenient: a broadcast (service ``*``) expanded + into one directive per MCP server and website the env card lists (``legacy_protocol.backing_servers``), each + targeting that server by its own card and route. A directive a broadcast made for a website is lenient: it is + skipped, as under ``tolerate_unadvertised``, when the website has no card or doesn't advertise the URI, so a + website the task didn't name never fails it. A named directive, and every MCP server, stays strict; a named one + targets the server of that name, the MCP server when a website shares it, as a MultiEnv's load does, or, without + a broadcast to list them, is looked up by name (None).""" if not any(d.service == self._BROADCAST for d in self.directives): - return self.directives, set(), set() + return [(d, None, False) for d in self.directives] from agent_env.env import legacy_protocol - from agent_env.env.env import Env + from agent_env.env.env import Env, gateway_url_of - env = Env.get(deployed.env_id, deployed.env_version) - # A single-server env (a bare MCPServerEnv) exposes `environment_name` and no - # `mcp_server_envs`; backing_servers falls back to it, as snapshot_env does, - # else a broadcast silently matches nothing on single-server envs. - servers = legacy_protocol.mcp_wins(legacy_protocol.backing_servers(env)) + servers = await legacy_protocol.backing_servers(deployed, gateway_url_of(deployed), Env.get(deployed.env_id, deployed.env_version), + timeout=self.timeout_seconds) # A broadcast that resolves to zero services would apply nothing while the # step reports success: the exact silent misconfig this step prevents. Fail # loud (tolerate_unadvertised governs advertisement, not missing servers). if not servers: raise ApplyServerConfigError( f"apply_server_config: broadcast directive matched no MCP servers or websites " - f"in env {self.env_id!r} (env exposes neither mcp_server_envs, website_envs nor a " - f"environment_name); nothing to apply, likely a task authoring error" + f"in env {self.env_id!r} (its card lists no child envs, and its definition names neither " + f"mcp_server_envs, website_envs nor an environment_name); nothing to apply, likely a task authoring error" ) - expanded: list[ConfigDirective] = [] - lenient: set[int] = set() + expanded: list[tuple[ConfigDirective, Optional[BackingServer], bool]] = [] for d in self.directives: if d.service != self._BROADCAST: - expanded.append(d) + named = [s for s in servers if s.environment_name == d.service] + expanded.append((d, next((s for s in named if s.mcp), named[0] if named else None), False)) continue for server in servers: - if not server.mcp: - lenient.add(len(expanded)) - expanded.append(ConfigDirective(service=server.environment_name, uri=d.uri, args=d.args)) - return expanded, {server.environment_name for server in servers if not server.mcp}, lenient + expanded.append((ConfigDirective(service=server.environment_name, uri=d.uri, args=d.args), server, not server.mcp)) + return expanded async def execute(self, context: TaskStepContext) -> TaskStepContext: deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None) @@ -195,7 +189,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: # network), so wrap it to keep the broadcast path's failure contract the # same ApplyServerConfigError as the per-directive HTTP calls below. try: - directives, websites, lenient = self._expand_directives(deployed) + directives = await self._expand_directives(deployed) except ApplyServerConfigError: raise except Exception as e: @@ -210,16 +204,17 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: # multiple tools) against the same server. A None card marks a child env with no # card: a server predating env-card support, one whose card name differs, or one # whose card the deploy couldn't read. - cards: dict[str, tuple[str, Optional[dict]]] = {} - for position, directive in enumerate(directives): - tolerate = self.tolerate_unadvertised or position in lenient + cards: dict[Any, tuple[Optional[str], Optional[dict]]] = {} + for directive, server, lenient in directives: + tolerate = self.tolerate_unadvertised or lenient + key = directive.service if server is None else server try: - if directive.service not in cards: - cards[directive.service] = await legacy_protocol.child_env_card( - deployed, gateway_url_of(deployed), directive.service, timeout=self.timeout_seconds, - mcp=directive.service not in websites, - ) - base_url, card = cards[directive.service] + if key not in cards: + cards[key] = (await legacy_protocol.child_env_card(deployed, gateway_url_of(deployed), directive.service, + timeout=self.timeout_seconds) + if server is None else + await legacy_protocol.server_card(server, gateway_url_of(deployed), timeout=self.timeout_seconds)) + base_url, card = cards[key] if card is None: if not tolerate: raise ApplyServerConfigError( diff --git a/src/agent_env/task_step/task_steps/snapshot_env.py b/src/agent_env/task_step/task_steps/snapshot_env.py index 9415dac98..abb5573f3 100644 --- a/src/agent_env/task_step/task_steps/snapshot_env.py +++ b/src/agent_env/task_step/task_steps/snapshot_env.py @@ -475,36 +475,43 @@ def _resolve_loaded_universe_ref(deployed) -> Optional[tuple[str, Optional[int]] return None return (su["id"], su.get("version")) if su and su.get("id") else None - @classmethod - def _enumerate_environments(cls, env) -> list[str]: - """environment names for a multi env, a website env or a single MCP server env.""" - return [server.environment_name for server in cls._enumerate_servers(env)] - @staticmethod - def _enumerate_servers(env) -> list[BackingServer]: - """The servers to snapshot: the env's MCP servers and websites, a website sharing its name with an MCP - server left to it.""" - from agent_env.env import legacy_protocol - - servers = legacy_protocol.mcp_wins(legacy_protocol.backing_servers(env)) + def _snapshot_servers(servers: list[BackingServer], env) -> list[BackingServer]: + """The servers to snapshot of ``servers`` (``legacy_protocol.backing_servers``), one per environment_name: a + universe names each environment by it, and a load hands an environment of a name an MCP server and a website + share to the MCP server, so a server whose name one kept earlier holds (the env itself, else an MCP server) is + left out, and logged.""" if not servers: raise RuntimeError( f"Env '{env.id}' v{env.version} has no MCP services or websites to snapshot " - "(expected mcp_server_envs, website_envs or environment_name)" + "(its card lists no child envs, and its definition names no mcp_server_envs, website_envs or environment_name)" ) - return servers + if not all(server.environment_name for server in servers): + raise RuntimeError(f"Env '{env.id}' v{env.version} has a card naming no environment to snapshot") + kept: dict[str, BackingServer] = {} + for server in sorted(servers, key=lambda s: not s.mcp): + if (holder := kept.get(server.environment_name)) is not None: + logger.warning(f"snapshot_env: {'website' if not server.mcp else 'server'} {server.environment_name!r} " + f"of {env.id} is not snapshotted: it shares its name with the {'MCP server' if holder.mcp else 'website'} " + "kept, and a universe names an environment by its name") + continue + kept[server.environment_name] = server + return list(kept.values()) @staticmethod async def _export_environment_to_file( gateway_url: Optional[str], environment_name: str, tmp_path: str, timeout_seconds: float, deployed=None, - data_get_params: Optional[dict] = None, mcp: bool = True, + data_get_params: Optional[dict] = None, mcp: bool = True, server: Optional[BackingServer] = None, ) -> Optional[str | tuple[GetDataResponse, Optional[_SnapshotUpload]]]: """Export one service, an MCP server or with ``mcp=False`` a website; return the suffix written (".json"/".zip") to ``tmp_path``, or the object url a FilePart names when the service uploaded the bundle itself (nothing written to ``tmp_path`` then — export_one registers it), or, for an environment whose card declares a file tree, its data/get answer and the upload it answered under, which export_one keeps file by file, - or None for a website serving no v1 data plane. ``data_get_params`` go to its data/get. + or None for a service serving no v1 data plane: a website with no card, or a service whose card declares + operations without data/get. ``data_get_params`` go to its data/get. ``server`` is the service as the env's + card lists it (``legacy_protocol.backing_servers``), its card and route; without it, the service is looked up + by name on ``deployed``'s stored card, or probed live at its gateway path. v1 ``get_data``, handed a namespace grant when the service takes the data-objects form: an upload through it → its object url; ``DataPart`` → json; ``FilePart`` → @@ -513,12 +520,22 @@ async def _export_environment_to_file( guard. Errors are caught by the caller (fails just this service).""" from agent_env.env import legacy_protocol - base_url = await legacy_protocol.v1_base_url(deployed, gateway_url, environment_name, mcp=mcp) + if server is not None: + mcp = server.mcp + base_url = await legacy_protocol.server_v1_base_url(server, gateway_url) + else: + base_url = await legacy_protocol.v1_base_url(deployed, gateway_url, environment_name, mcp=mcp) + card_base, card = None, None + if base_url is not None: + card_base, card = (await legacy_protocol.server_card(server, gateway_url) if server is not None else + await legacy_protocol.child_env_card(deployed, gateway_url, environment_name, mcp=mcp)) + if card is not None and protocol_v1.serves(card, METHOD_GET) is False: + logger.info(f"snapshot_env: {environment_name}'s card declares {protocol_v1.operations(card)}, no {METHOD_GET}") + return None if base_url is None and not mcp: return None if base_url is not None: headers = deployed.control_headers_for(base_url) if deployed is not None else None - card_base, card = await legacy_protocol.child_env_card(deployed, gateway_url, environment_name, mcp=mcp) if returns_file_tree(card or {}): upload = await asyncio.to_thread(_snapshot_upload, card or {}, timeout_seconds, getattr(deployed, "sandbox_type", None), FILE_TREE_LIMITS) @@ -625,13 +642,12 @@ async def snapshot_env_state( A classmethod so callers that have no step instance can reuse it.""" from agent_env.artifact import FileArtifact, EnvironmentArtifact, EnvironmentUniverseArtifact + from agent_env.env import legacy_protocol from agent_env.env.env import gateway_url_of - from agent_env.env.legacy_protocol import BackingServer - - servers = cls._enumerate_servers(env) if gateway_url else [BackingServer(name) for name in _card_environments(deployed)] - services = [server.environment_name for server in servers] gateway = gateway_url.rstrip("/") if gateway_url else None + servers = cls._snapshot_servers(await legacy_protocol.backing_servers(deployed, gateway, env), env) + services = [server.environment_name for server in servers] # `gateway` identifies which deployment was exported: env= alone doesn't when one env_id has several. logger.info( f"snapshot_env: env={env.id} v{env.version} snapshot_id={snapshot_id} " @@ -665,7 +681,7 @@ async def snapshot_env_state( no_data_plane: set[str] = set() async def export_one(server: BackingServer): - """Returns EnvironmentArtifact on success, (public_err, log_err) on failure, None for a website serving no data plane.""" + """Returns EnvironmentArtifact on success, (public_err, log_err) on failure, None for a service serving no data plane.""" environment_name = server.environment_name website = {} if server.mcp else {"mcp": False} async with sem: @@ -678,7 +694,8 @@ async def export_one(server: BackingServer): try: result = await cls._export_environment_to_file( gateway, environment_name, tmp_path, export_timeout_seconds, - deployed=card_record, data_get_params=(data_get_params or {}).get(environment_name), **website, + deployed=card_record, data_get_params=(data_get_params or {}).get(environment_name), + server=server, **website, ) except httpx.HTTPStatusError as e: body_snippet = "" @@ -795,12 +812,12 @@ def _register_uploaded_bundle(): without_data_plane = [name for name in services if name in no_data_plane] if without_data_plane: logger.warning( - f"snapshot_env: websites {without_data_plane} of {env.id} serve no v1 data plane; " + f"snapshot_env: {without_data_plane} of {env.id} serve no v1 data plane; " "their state is not in the snapshot" ) empty = [name for name, result in zip(services, results) if result is None and name not in no_data_plane] if not environment_artifacts: - reasons = [f"its websites {without_data_plane} serve no v1 data plane"] if without_data_plane else [] + reasons = [f"{without_data_plane} serve no v1 data plane"] if without_data_plane else [] reasons += [f"{', '.join(map(repr, empty))} returned no files to snapshot"] if empty else [] raise RuntimeError(f"Env '{env.id}' v{env.version} exported no state: {'; '.join(reasons)}") if empty: @@ -883,25 +900,6 @@ def registered_upload(object_url: str, file_id: str, filename: str, content_type id=file_id, description=description, filename=filename, content_type=content_type, object_url=object_url)) -def _card_environments(deployed) -> list[str]: - """The environments a deployment without a gateway serves, as the card it recorded names them: the env itself when - it has no children; else each child its name reaches that exports, and the env too, first, when it exports beside - them, as a computer fronting its apps does.""" - card = deployed.environment_card or {} - children = card.get("children_environments") - if not isinstance(children, list): - names = [card.get("name")] - else: - names = [child.get("name") for child in children if deployed.get_child_env_card(child.get("name")) is child - and ((operations := protocol_v1.operations(child)) is None - or METHOD_GET in operations)] - if METHOD_GET in (protocol_v1.operations(card) or []): - names.insert(0, card.get("name")) - if not all(names): - raise RuntimeError(f"Deployed env '{deployed.env_id}' recorded a card naming no environment to snapshot") - return names - - def _changes_route(deployed, override_url: Optional[str]) -> tuple[str, str]: """The verb and URL of the env's changes: an override keeps `{override}/changes`, else the stored card names them.""" if override_url: diff --git a/src/agent_env/task_step/task_steps/sync_env_clock.py b/src/agent_env/task_step/task_steps/sync_env_clock.py index c80cef8a8..8ae463f56 100644 --- a/src/agent_env/task_step/task_steps/sync_env_clock.py +++ b/src/agent_env/task_step/task_steps/sync_env_clock.py @@ -1,9 +1,10 @@ """Arm a deployed env gateway's virtual clock, then sync its backing servers to it. Arms the clock (set_time) and reads the gateway's server-reachable env_get_time_url (state), both as the -stored env card declares clock/v1, then invokes sync_time on each backing server, MCP server or website, -whose child env card advertises it. Non-advertisers are skipped when tolerate_missing_sync_time (default -True), and websites always. Place right before prompt_agent so the virtual timeline starts at agent-start. +stored env card declares clock/v1, then invokes sync_time on each backing server, MCP server or website, the +env card lists (``legacy_protocol.backing_servers``) whose own card advertises it, at the route that card gives. +Non-advertisers are skipped when tolerate_missing_sync_time (default True), and websites always. Place right +before prompt_agent so the virtual timeline starts at agent-start. """ from __future__ import annotations @@ -110,15 +111,15 @@ def from_dict(cls, data: dict) -> SyncEnvClockTaskStep: timezone=data.get("timezone"), ) - def _backing_servers(self, deployed) -> list[BackingServer]: - """The env's backing MCP servers and websites (``legacy_protocol.backing_servers``). A website sharing its - name with an MCP server has its card read live, since the stored env card can't tell the two apart.""" + async def _backing_servers(self, deployed) -> list[BackingServer]: + """The env's backing MCP servers and websites, as its card lists them (``legacy_protocol.backing_servers``): + a website sharing its name with an MCP server is a server of its own, at its own route.""" from agent_env.env import legacy_protocol - from agent_env.env.env import Env - return legacy_protocol.backing_servers(Env.get(deployed.env_id, deployed.env_version)) + from agent_env.env.env import Env, gateway_url_of + return await legacy_protocol.backing_servers(deployed, gateway_url_of(deployed), Env.get(deployed.env_id, deployed.env_version), + timeout=self.timeout_seconds) async def execute(self, context: TaskStepContext) -> TaskStepContext: - from agent_env.env import legacy_protocol from agent_env.env.env import gateway_url_of deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None) @@ -133,15 +134,11 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: state = await deployed.invoke(EXT_CLOCK_URI, "state", timeout=self.timeout_seconds) env_get_time_url = state.get("env_get_time_url") - servers = self._backing_servers(deployed) - shared = legacy_protocol.shared_names(servers) synced, skipped = [], [] - for server in servers: + for server in await self._backing_servers(deployed): service = server.environment_name - record = None if service in shared and not server.mcp else deployed - outcome = await self._sync_one(record, gateway_url_of(deployed), service, env_get_time_url, mcp=server.mcp, - tolerate=self.tolerate_missing_sync_time or not server.mcp, - headers=deployed.control_headers) + outcome = await self._sync_one(deployed, gateway_url_of(deployed), service, env_get_time_url, mcp=server.mcp, + tolerate=self.tolerate_missing_sync_time or not server.mcp, server=server) entry = {"service": service, "environment": service, **({} if server.mcp else {"website": True}), **outcome} (synced if outcome.get("synced") else skipped).append(entry) @@ -161,17 +158,20 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: return context async def _sync_one(self, deployed, gateway_url: str, service: str, env_get_time_url: Optional[str], mcp: bool = True, - tolerate: Optional[bool] = None, headers: Optional[dict] = None) -> dict: + tolerate: Optional[bool] = None, server: Optional[BackingServer] = None) -> dict: """Invoke sync_time on one server, an MCP server or with ``mcp=False`` a website, if its card advertises clock/v1; skip a server with no card or no clock/v1 when ``tolerate`` (tolerate_missing_sync_time by default), else raise. ``execute`` tolerates every website, so one that doesn't follow the clock never fails the step; one - that follows it but can't take the task's timezone does, as an MCP server does. ``headers`` are the env's control - headers, ``deployed``'s by default; ``execute`` passes them because it hands a website that shares its name with - an MCP server no record, so its card is read live.""" + that follows it but can't take the task's timezone does, as an MCP server does. ``server`` is the server as the + env's card lists it (``legacy_protocol.server_card``); without it, the card is ``service``'s on ``deployed``'s + stored card, or read live at its gateway path.""" tolerate = self.tolerate_missing_sync_time if tolerate is None else tolerate from agent_env.env import legacy_protocol - base_url, card = await legacy_protocol.child_env_card(deployed, gateway_url, service, timeout=self.timeout_seconds, mcp=mcp) + if server is not None: + base_url, card = await legacy_protocol.server_card(server, gateway_url, timeout=self.timeout_seconds) + else: + base_url, card = await legacy_protocol.child_env_card(deployed, gateway_url, service, timeout=self.timeout_seconds, mcp=mcp) if card is None: if tolerate: return {"synced": False, "reason": "no_env_card"} @@ -193,5 +193,5 @@ async def _sync_one(self, deployed, gateway_url: str, service: str, env_get_time result = await protocol_v1.invoke_extension(base_url, card, EXT_CLOCK_URI, params=params, timeout=self.timeout_seconds, - headers=deployed.control_headers if headers is None else headers) + headers=deployed.control_headers) return {"synced": True, "result": result} diff --git a/tst/unit/env/test_legacy_protocol.py b/tst/unit/env/test_legacy_protocol.py index 7d107ad34..ef8fee3f8 100644 --- a/tst/unit/env/test_legacy_protocol.py +++ b/tst/unit/env/test_legacy_protocol.py @@ -179,7 +179,7 @@ async def test_a_v1_services_state_is_the_data_it_answers_with_else_its_export_s assert ("GET /svc/mcp-slack/export-state" in asked) is reads_export_state -def test_backing_servers_are_an_envs_mcp_servers_then_its_websites(): +def test_definition_servers_are_an_envs_mcp_servers_then_its_websites(): from types import SimpleNamespace from agent_env.env.envs.website import WebsiteEnv @@ -188,11 +188,93 @@ def test_backing_servers_are_an_envs_mcp_servers_then_its_websites(): named = lambda *names: [SimpleNamespace(environment_name=n) for n in names] multi = SimpleNamespace(mcp_server_envs=named("slack", "email"), website_envs=named("shop", "slack")) - assert legacy_protocol.backing_servers(multi) == [BackingServer("slack"), BackingServer("email"), - BackingServer("shop", mcp=False), BackingServer("slack", mcp=False)] - assert legacy_protocol.shared_names(legacy_protocol.backing_servers(multi)) == {"slack"} - assert legacy_protocol.mcp_wins(legacy_protocol.backing_servers(multi)) == [ - BackingServer("slack"), BackingServer("email"), BackingServer("shop", mcp=False)] - assert legacy_protocol.backing_servers(WebsiteEnv(id="w", version=1, environment_name="shop")) == [BackingServer("shop", mcp=False)] - assert legacy_protocol.backing_servers(SimpleNamespace(environment_name="notes")) == [BackingServer("notes")] - assert legacy_protocol.backing_servers(SimpleNamespace()) == [] + assert legacy_protocol.definition_servers(multi) == [BackingServer("slack"), BackingServer("email"), + BackingServer("shop", mcp=False), BackingServer("slack", mcp=False)] + assert legacy_protocol.definition_servers(WebsiteEnv(id="w", version=1, environment_name="shop")) == [BackingServer("shop", mcp=False)] + assert legacy_protocol.definition_servers(SimpleNamespace(environment_name="notes")) == [BackingServer("notes")] + assert legacy_protocol.definition_servers(SimpleNamespace()) == [] + + +_COMPOSED = {"name": "gw", "url": RPC_PATH, "capabilities": {"extensions": []}, "children_environments": [ + {"name": "slack", "url": "/svc/mcp-slack/agentenv", "capabilities": {"extensions": []}}, + {"name": "slack", "url": "/svc/slack/agentenv", "capabilities": {"extensions": []}}, + {"name": "docs", "url": "/svc/mcp-docs/agentenv", "capabilities": {"extensions": [{"uri": "urn:agentenv:website/v1"}]}}, +]} + + +def test_card_servers_are_the_cards_children_a_website_told_apart_by_its_route_or_its_website_extension(): + servers = legacy_protocol.card_servers(_COMPOSED, "https://env.example") + + assert [(s.environment_name, s.mcp, s.address) for s in servers] == [ + ("slack", True, "https://env.example"), ("slack", False, "https://env.example"), ("docs", False, "https://env.example")] + assert [s.child.base_url(s.address) for s in servers] == [ + "https://env.example/svc/mcp-slack", "https://env.example/svc/slack", "https://env.example/svc/mcp-docs"] + + +def test_card_servers_of_a_leaf_card_is_the_env_itself_and_a_composed_card_serving_data_get_comes_first(): + leaf = {"name": "notes", "url": RPC_PATH} + assert [(s.environment_name, s.card) for s in legacy_protocol.card_servers(leaf, "https://n")] == [("notes", leaf)] + driver = {**_COMPOSED, "name": "desktop", "capabilities": {"operations": ["data/get"]}} + assert [s.environment_name for s in legacy_protocol.card_servers(driver, "https://d")] == ["desktop", "slack", "slack", "docs"] + + +def _record(card: dict | None, card_url: str | None = "https://env.example/.well-known/agent-env.json"): + from agent_env.env.env import DeployedGatewayEnv + + return DeployedGatewayEnv(env_id="e", env_version=1, gateway_url="http://gw", mcp_url="http://gw/mcp", db_web_url=None, + sandbox_id="sb", environment_card_url=card_url, environment_card=card) + + +@pytest.mark.asyncio +async def test_backing_servers_are_the_stored_cards_then_those_the_definition_names_serving_no_card(): + from types import SimpleNamespace + + env = SimpleNamespace(mcp_server_envs=[SimpleNamespace(environment_name=n) for n in ("slack", "legacy")], + website_envs=[SimpleNamespace(environment_name="slack")]) + + servers = await legacy_protocol.backing_servers(_record(_COMPOSED), "http://gw", env) + + assert [(s.environment_name, s.mcp, s.card is not None, s.address) for s in servers] == [ + ("slack", True, True, "https://env.example"), ("slack", False, True, "https://env.example"), + ("docs", False, True, "https://env.example"), ("legacy", True, False, "https://env.example")] + assert await legacy_protocol.server_card(servers[-1], "http://gw") == ("https://env.example", None) + assert await legacy_protocol.server_v1_base_url(servers[-1], "http://gw") is None + + +@pytest.mark.asyncio +async def test_backing_servers_read_the_card_live_without_a_stored_one_or_for_another_gateway(monkeypatch): + from types import SimpleNamespace + + read = [] + + async def get_card(base_url, **kw): + read.append(base_url) + return _COMPOSED + + monkeypatch.setattr(legacy_protocol.protocol_v1, "get_card", get_card) + env = SimpleNamespace(environment_name="slack") + for record, gateway in ((_record(None), "http://gw/"), (_record(_COMPOSED), "http://elsewhere")): + servers = await legacy_protocol.backing_servers(record, gateway, env) + assert [(s.environment_name, s.address) for s in servers] == [ + ("slack", gateway.rstrip("/")), ("slack", gateway.rstrip("/")), ("docs", gateway.rstrip("/"))] + assert read == ["http://gw", "http://elsewhere"] + + +@pytest.mark.asyncio +async def test_a_record_whose_card_cant_be_read_falls_back_to_its_definition_logged_once(monkeypatch, caplog): + from types import SimpleNamespace + + async def get_card(base_url, **kw): + raise httpx.ConnectError("refused") + + monkeypatch.setattr(legacy_protocol.protocol_v1, "get_card", get_card) + env = SimpleNamespace(id="e", mcp_server_envs=[SimpleNamespace(environment_name="slack")], + website_envs=[SimpleNamespace(environment_name="slack")]) + with caplog.at_level("INFO", logger="agent_env.env.legacy_protocol"): + for record in (_record(None, card_url=None), _record(None), None): + servers = await legacy_protocol.backing_servers(record, "http://gw", env) + assert [(s.environment_name, s.mcp, s.address) for s in servers] == [("slack", True, None), ("slack", False, None)] + + assert [r.getMessage().split(",")[0] for r in caplog.records] == [ + "env 'e': its record has no card URL", "env 'e': its card at http://gw could not be read (ConnectError)", + "env 'e': it has no record"] diff --git a/tst/unit/task/local_run_write_fence_test.py b/tst/unit/task/local_run_write_fence_test.py index d3f60293e..108709d94 100644 --- a/tst/unit/task/local_run_write_fence_test.py +++ b/tst/unit/task/local_run_write_fence_test.py @@ -264,7 +264,7 @@ def __init__(self, id, version=None): self.mcp_server_envs = [SimpleNamespace(environment_name="gmail")] -async def _export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None): +async def _export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None, **_): Path(tmp_path).write_text(json.dumps({"service": name})) return ".json" diff --git a/tst/unit/task_step/sync_env_clock_test.py b/tst/unit/task_step/sync_env_clock_test.py index 5b83c51d7..a9a109e2a 100644 --- a/tst/unit/task_step/sync_env_clock_test.py +++ b/tst/unit/task_step/sync_env_clock_test.py @@ -1,11 +1,11 @@ """Unit tests for SyncEnvClockTaskStep — round-trip + the per-server sync_time fan-out (mocked).""" import json +from types import SimpleNamespace import pytest from agent_env.env.env import DeployedEnv, DeployedGatewayEnv, EnvCapabilityUnsupported from agent_env.env.gateway.constants import EXT_CLOCK_URI -from agent_env.env.legacy_protocol import BackingServer from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_steps import sync_env_clock as mod from agent_env.task_step.task_steps.sync_env_clock import SyncEnvClockTaskStep, resolve_virtual_time @@ -95,7 +95,7 @@ async def test_sync_one_skips_or_raises_for_a_child_env_missing_from_the_stored_ @pytest.mark.asyncio async def test_execute_syncs_each_child_env_through_the_stored_card(monkeypatch): sent = _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) - monkeypatch.setattr(SyncEnvClockTaskStep, "_backing_servers", lambda self, deployed: [BackingServer("email")]) + monkeypatch.setattr("agent_env.env.env.Env.get", lambda *a, **k: SimpleNamespace(environment_name="email")) context = TaskStepContext(deployed_envs=[_carded(await _composed_card({"mcp-email": [_CLOCK_EXT]}))]) await _step().execute(context) @@ -176,21 +176,64 @@ async def fake_card(base_url, **kw): @pytest.mark.asyncio -async def test_execute_reads_a_website_sharing_an_mcp_servers_name_live(monkeypatch): - from types import SimpleNamespace +async def test_execute_syncs_a_website_sharing_an_mcp_servers_name_at_its_own_route(monkeypatch): + """The card lists the two as children at distinct urls, so each is synced by its own card, with no card read.""" from unittest.mock import patch sent = _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) env = SimpleNamespace(mcp_server_envs=[SimpleNamespace(environment_name="email")], website_envs=[SimpleNamespace(environment_name="email")]) - context = TaskStepContext(deployed_envs=[_carded(await _composed_card({"mcp-email": [_CLOCK_EXT]}))]) + context = TaskStepContext(deployed_envs=[_carded(await _composed_card({"mcp-email": [_CLOCK_EXT], "email": [_CLOCK_EXT]}))]) with patch("agent_env.env.env.Env.get", return_value=env): await _step().execute(context) assert [(r.method, str(r.url)) for r in sent][2:] == [ - ("POST", f"{_CARD}/svc/mcp-email/agentenv/ext/sync_time"), ("GET", "http://gw/svc/email/.well-known/agent-env.json"), + ("POST", f"{_CARD}/svc/mcp-email/agentenv/ext/sync_time"), ("POST", f"{_CARD}/svc/email/agentenv/ext/sync_time"), ] + record = context.metadata["clock_configurations"][0] + assert [(s["service"], s.get("website")) for s in record["synced"]] == [("email", None), ("email", True)] + + +@pytest.mark.asyncio +async def test_execute_without_a_card_url_syncs_the_servers_the_definition_names_read_live(monkeypatch): + """An older record kept no card URL, so its servers come from its env definition and each card is read live at + its gateway path.""" + from unittest.mock import patch + + import httpx + + sent, real = [], httpx.AsyncClient + cards = {"/svc/mcp-email/.well-known/agent-env.json": {"name": "email", "capabilities": {"extensions": [_CLOCK_EXT]}}, + "/svc/shop/.well-known/agent-env.json": {"name": "shop", "capabilities": {"extensions": []}}} + + def handle(request: httpx.Request) -> httpx.Response: + sent.append(request) + if request.url.path == "/clock/state": + return httpx.Response(200, json={"env_get_time_url": CLOCK_URL}) + return httpx.Response(200, json=cards.get(request.url.path, {})) + + monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: real(transport=httpx.MockTransport(handle))) + record = _cardless() + + async def invoke(uri, method, params=None, **kw): + return {"env_get_time_url": CLOCK_URL} if method == "state" else {} + + record.invoke = invoke + env = SimpleNamespace(mcp_server_envs=[SimpleNamespace(environment_name="email")], + website_envs=[SimpleNamespace(environment_name="shop")]) + context = TaskStepContext(deployed_envs=[record]) + + with patch("agent_env.env.env.Env.get", return_value=env): + await _step().execute(context) + + assert [(r.method, str(r.url)) for r in sent] == [ + ("GET", "http://gw/svc/mcp-email/.well-known/agent-env.json"), ("POST", "http://gw/svc/mcp-email/agentenv/ext/sync_time"), + ("GET", "http://gw/svc/shop/.well-known/agent-env.json"), + ] + cfg = context.metadata["clock_configurations"][0] + assert [s["service"] for s in cfg["synced"]] == ["email"] + assert [(s["service"], s["reason"]) for s in cfg["skipped"]] == [("shop", "not_advertised")] @pytest.mark.asyncio @@ -317,7 +360,7 @@ def test_without_a_zone_virtual_time_is_passed_through_untouched(): @pytest.mark.asyncio async def test_execute_arms_the_gateway_at_the_local_instant_and_tells_every_server_the_zone(monkeypatch): sent = _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) - monkeypatch.setattr(SyncEnvClockTaskStep, "_backing_servers", lambda self, deployed: [BackingServer("email")]) + monkeypatch.setattr("agent_env.env.env.Env.get", lambda *a, **k: SimpleNamespace(environment_name="email")) context = TaskStepContext(deployed_envs=[_carded(await _composed_card({"mcp-email": [_ZONE_EXT]}))]) await _zoned().execute(context) @@ -332,7 +375,7 @@ async def test_execute_arms_the_gateway_at_the_local_instant_and_tells_every_ser @pytest.mark.parametrize("tolerate", [True, False]) async def test_a_server_that_cannot_take_the_zone_fails_the_step_before_any_sync_is_sent(monkeypatch, tolerate): sent = _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) - monkeypatch.setattr(SyncEnvClockTaskStep, "_backing_servers", lambda self, deployed: [BackingServer("email")]) + monkeypatch.setattr("agent_env.env.env.Env.get", lambda *a, **k: SimpleNamespace(environment_name="email")) context = TaskStepContext(deployed_envs=[_carded(await _composed_card({"mcp-email": [_OLD_EXT]}))]) with pytest.raises(RuntimeError, match="takes no timezone"): @@ -378,34 +421,19 @@ async def test_a_zoned_step_tells_a_website_that_takes_the_zone_and_fails_on_one @pytest.mark.asyncio async def test_a_website_sharing_an_mcp_servers_name_is_synced_with_the_envs_control_headers(monkeypatch): - """Its card is read live, with no record, and its sync_time still carries the env's control token.""" import dataclasses - from types import SimpleNamespace from unittest.mock import patch - import httpx - - sent, real = [], httpx.AsyncClient - website_card = {"name": "email", "url": "/agentenv", "capabilities": {"extensions": [_CLOCK_EXT]}} - - def handle(request: httpx.Request) -> httpx.Response: - sent.append(request) - if request.url.path.endswith("/clock/state"): - return httpx.Response(200, json={"env_get_time_url": CLOCK_URL}) - if request.url.path == "/svc/email/.well-known/agent-env.json": - return httpx.Response(200, json=website_card) - return httpx.Response(200, json={}) - - monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: real(transport=httpx.MockTransport(handle))) + sent = _mock_http(monkeypatch, state={"env_get_time_url": CLOCK_URL}) env = SimpleNamespace(mcp_server_envs=[SimpleNamespace(environment_name="email")], website_envs=[SimpleNamespace(environment_name="email")]) - record = dataclasses.replace(_carded(await _composed_card({"mcp-email": [_CLOCK_EXT]})), control_token="ctl") + record = dataclasses.replace(_carded(await _composed_card({"mcp-email": [_CLOCK_EXT], "email": [_CLOCK_EXT]})), control_token="ctl") context = TaskStepContext(deployed_envs=[record]) with patch("agent_env.env.env.Env.get", return_value=env): await _step().execute(context) - website_sync = [r for r in sent if str(r.url) == "http://gw/svc/email/agentenv/ext/sync_time"] + website_sync = [r for r in sent if str(r.url) == f"{_CARD}/svc/email/agentenv/ext/sync_time"] assert len(website_sync) == 1 assert {k: website_sync[0].headers.get(k) for k in record.control_headers} == record.control_headers assert [s["service"] for s in context.metadata["clock_configurations"][0]["synced"]] == ["email", "email"] diff --git a/tst/unit/task_step/test_apply_server_config.py b/tst/unit/task_step/test_apply_server_config.py index ddeb96806..94ed35986 100644 --- a/tst/unit/task_step/test_apply_server_config.py +++ b/tst/unit/task_step/test_apply_server_config.py @@ -609,18 +609,38 @@ async def get(self, url, *, timeout=None, params=None): @pytest.mark.asyncio -async def test_stored_card_broadcast_reaches_websites_and_leaves_a_shared_name_to_the_mcp_server(): +async def test_stored_card_broadcast_reaches_websites_and_a_website_sharing_an_mcp_servers_name(): + """The card lists a website and an MCP server sharing a name as two children at distinct urls, so a broadcast + reaches both, each at its own route; a directive naming the name means the MCP server.""" gw = _MockGateway({}) ext = _ext(_SET_ACTING_USER_URI, "/agentenv/ext/set_acting_user") - ctx = _carded_context(await _composed_card({"mcp-slack": [ext], "shop": [ext]})) - step = _step(directives=[{"service": "*", "uri": _SET_ACTING_USER_URI, "args": {"user_email": "a@x.com"}}]) + ctx = _carded_context(await _composed_card({"mcp-slack": [ext], "shop": [ext], "slack": [ext]})) + step = _step(directives=[{"service": "*", "uri": _SET_ACTING_USER_URI, "args": {"user_email": "a@x.com"}}, + {"service": "slack", "uri": _SET_ACTING_USER_URI, "args": {"user_email": "b@x.com"}}]) with _patch_httpx(gw), patch("agent_env.env.env.Env.get", return_value=_fake_multi_env(["slack"], ["shop", "slack"])): out = await step.execute(ctx) + assert gw.get_calls == [] + assert gw.post_calls == [(f"{_CARD}/svc/mcp-slack/agentenv/ext/set_acting_user", {"user_email": "a@x.com"}), + (f"{_CARD}/svc/shop/agentenv/ext/set_acting_user", {"user_email": "a@x.com"}), + (f"{_CARD}/svc/slack/agentenv/ext/set_acting_user", {"user_email": "a@x.com"}), + (f"{_CARD}/svc/mcp-slack/agentenv/ext/set_acting_user", {"user_email": "b@x.com"})] + assert [c["service"] for c in out.metadata["server_config_changes"]] == ["slack", "shop", "slack", "slack"] + + +@pytest.mark.asyncio +async def test_stored_card_broadcast_to_an_mcp_only_env_is_unchanged(): + gw = _MockGateway({}) + ext = _ext(_SET_ACTING_USER_URI, "/agentenv/ext/set_acting_user") + ctx = _carded_context(await _composed_card({"mcp-slack": [ext], "mcp-email": [ext]})) + step = _step(directives=[{"service": "*", "uri": _SET_ACTING_USER_URI, "args": {"user_email": "a@x.com"}}]) + with _patch_httpx(gw), patch("agent_env.env.env.Env.get", return_value=_fake_env("slack", "email")): + out = await step.execute(ctx) + assert gw.get_calls == [] assert [url for url, _ in gw.post_calls] == [f"{_CARD}/svc/mcp-slack/agentenv/ext/set_acting_user", - f"{_CARD}/svc/shop/agentenv/ext/set_acting_user"] - assert [c["service"] for c in out.metadata["server_config_changes"]] == ["slack", "shop"] + f"{_CARD}/svc/mcp-email/agentenv/ext/set_acting_user"] + assert out.metadata["server_config_skipped"] == [] @pytest.mark.asyncio diff --git a/tst/unit/task_step/test_snapshot_env.py b/tst/unit/task_step/test_snapshot_env.py index 380690fc2..cf75fb7ca 100644 --- a/tst/unit/task_step/test_snapshot_env.py +++ b/tst/unit/task_step/test_snapshot_env.py @@ -14,6 +14,7 @@ from agent_env.artifact import EnvironmentUniverseArtifact from agent_env.artifact.store import reset_artifact_store from agent_env.config import set_object_store +from agent_env.env import legacy_protocol from agent_env.env.env import DeployedEnv, DeployedGatewayEnv, DeployedSandboxEnv from agent_env.env.gateway.constants import EXT_STATE_URI, GATEWAY_EXTENSIONS, WELL_KNOWN_PATH from agent_env.task_step.context import TaskStepContext @@ -33,7 +34,7 @@ def _deployed(env_id="env-1", instance_id="env-1-abc123", gateway="https://gw-1" sandbox_id="sb-1", instance_id=instance_id, environment_card_url=f"{gateway}{WELL_KNOWN_PATH}", - environment_card={"capabilities": {"extensions": GATEWAY_EXTENSIONS}}, + environment_card={"capabilities": {"extensions": GATEWAY_EXTENSIONS}, "children_environments": []}, ) @@ -565,7 +566,7 @@ def __init__(self, names): @pytest.mark.asyncio async def test_core_all_or_nothing_on_partial_failure(monkeypatch): - async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None): + async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None, **_): if name == "slack": raise ValueError("export-state returned non-JSON body (starts with b'<')") with open(tmp_path, "w") as f: @@ -609,7 +610,7 @@ def put(id, *, environment_name, file_artifact): @pytest.mark.asyncio async def test_core_success_creates_universe_with_metadata(monkeypatch): - async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None): + async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None, **_): with open(tmp_path, "w") as f: json.dump({"ok": name}, f) return ".json" @@ -673,7 +674,7 @@ async def test_core_zip_export_creates_zip_filename_artifact(monkeypatch): """A FilePart(zip) export flows through as a .zip-suffixed FileArtifact path, so FileArtifact.filename (= basename) ends in .zip — the load side routes a .zip file:// artifact to reset_data() for a lossless round-trip.""" - async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None): + async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None, **_): # Simulate the lossless-zip case: write zip bytes and report ".zip". with open(tmp_path, "wb") as f: f.write(b"PK\x03\x04zip-bytes") @@ -911,7 +912,7 @@ async def test_core_registers_a_service_upload_in_place_and_it_loads_back(local_ bundle_file.write_bytes(b"PK\x03\x04bundle") uploaded = store.put_file_at(upload_url, str(bundle_file)) - async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None): + async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None, **_): return uploaded monkeypatch.setattr(SnapshotEnvTaskStep, "_export_environment_to_file", staticmethod(fake_export)) @@ -933,7 +934,7 @@ async def test_core_fails_a_service_whose_upload_the_store_cannot_find(local_sto set_object_store(FakeObjectStore()) reset_artifact_store() - async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None): + async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None, **_): return "fake://shared/snapshots/missing.zip" monkeypatch.setattr(SnapshotEnvTaskStep, "_export_environment_to_file", staticmethod(fake_export)) @@ -950,7 +951,7 @@ async def test_core_snapshots_under_an_id_longer_than_a_filename_can_be(local_st local store may be the whole id.""" snapshot_id = f"task-{'x' * 250}__snapshot-snap-1" - async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None): + async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None, **_): with open(tmp_path, "w") as f: json.dump({"service": name}, f) return ".json" @@ -1044,14 +1045,16 @@ async def test_export_of_a_child_env_missing_from_the_card_streams_legacy_export @pytest.mark.asyncio async def test_snapshot_uses_the_stored_card_only_for_the_deployments_own_gateway(monkeypatch): + """An override's gateway has its card read live, and the record's stored card describes its own gateway only.""" seen = [] - async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None): - seen.append((gateway_url, deployed)) + async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None, server=None, **_): + seen.append((gateway_url, deployed, server.address, server.card and server.card["url"])) raise ValueError("stop after the export call") monkeypatch.setattr(SnapshotEnvTaskStep, "_export_environment_to_file", staticmethod(fake_export)) - record = _carded({"name": "gw", "children_environments": []}) + sent = _mock_http(monkeypatch, body=json.dumps(await _card({"mcp-slack": []})).encode()) + record = _carded(await _card({"mcp-slack": []})) record.gateway_url = "https://gw-1/" for gateway_url in ("https://gw-1/", "https://elsewhere"): with pytest.raises(RuntimeError, match="not exported"): @@ -1059,7 +1062,9 @@ async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_ env=_FakeMultiEnv(["slack"]), gateway_url=gateway_url, snapshot_id="snap-x", deployed=record, ) - assert seen == [("https://gw-1", record), ("https://elsewhere", None)] + assert seen == [("https://gw-1", record, _CARD, "/svc/mcp-slack/agentenv"), + ("https://elsewhere", None, "https://elsewhere", "/svc/mcp-slack/agentenv")] + assert [str(r.url) for r in sent] == [f"https://elsewhere{WELL_KNOWN_PATH}"] @pytest.mark.asyncio @@ -1081,19 +1086,26 @@ async def test_export_with_a_record_that_has_no_stored_card_keeps_the_live_probe ] -def test_enumerate_services_single_mcp_server_env(): +@pytest.mark.asyncio +async def test_a_record_without_a_card_snapshots_the_servers_its_definition_names(): env = _FakeServiceEnv("calendar") env.id, env.version = "cal-env", 1 - assert SnapshotEnvTaskStep._enumerate_environments(env) == ["calendar"] + servers = await legacy_protocol.backing_servers(_deployed_without_a_card(), "https://gw-1", env) + assert [(s.environment_name, s.mcp, s.address) for s in SnapshotEnvTaskStep._snapshot_servers(servers, env)] == [ + ("calendar", True, None)] -def test_enumerate_services_no_services_raises(): +def test_snapshot_servers_refuses_an_env_with_none(): env = type("E", (), {"id": "x", "version": 1})() with pytest.raises(RuntimeError, match="no MCP services"): - SnapshotEnvTaskStep._enumerate_environments(env) + SnapshotEnvTaskStep._snapshot_servers(legacy_protocol.definition_servers(env), env) -# ── loaded-universe metadata carry-over ────────────────────────────────────── +def _deployed_without_a_card() -> DeployedEnv: + """An older record: no card URL, so its servers are the ones its definition names.""" + return DeployedGatewayEnv(env_id="env-1", env_version=3, gateway_url="https://gw-1", mcp_url="https://gw-1/mcp", + db_web_url=None, sandbox_id="sb-1") + def test_resolve_loaded_universe_ref_reads_store(monkeypatch): import agent_env.env.store as store_mod @@ -1139,7 +1151,7 @@ def get_environment_universe(self, instance_id): def _install_core_artifact_fakes(monkeypatch, universe_cls): """Wire the export + FileArtifact/EnvironmentArtifact fakes shared by the core carry-over tests; the caller supplies the EnvironmentUniverseArtifact fake.""" - async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None): + async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None, **_): with open(tmp_path, "w") as f: json.dump({"ok": name}, f) return ".json" @@ -1199,7 +1211,7 @@ def get_environment_universe(self, instance_id): await SnapshotEnvTaskStep.snapshot_env_state( env=_FakeMultiEnv(["slack"]), - gateway_url="https://gw", + gateway_url="https://gw-1", snapshot_id="snap-x", deployed=_deployed(instance_id="env-1-abc123"), ) @@ -1240,7 +1252,7 @@ def get_environment_universe(self, instance_id): await SnapshotEnvTaskStep.snapshot_env_state( original_universe_artifact_id="explicit-base", env=_FakeMultiEnv(["slack"]), - gateway_url="https://gw", + gateway_url="https://gw-1", snapshot_id="snap-x", deployed=_deployed(), ) @@ -1276,7 +1288,7 @@ def get_environment_universe(self, instance_id): result = await SnapshotEnvTaskStep.snapshot_env_state( env=_FakeMultiEnv(["slack"]), - gateway_url="https://gw", + gateway_url="https://gw-1", snapshot_id="snap-x", deployed=_deployed(), ) @@ -1302,7 +1314,7 @@ def get(artifact_id, version=None): await SnapshotEnvTaskStep.snapshot_env_state( original_universe_artifact_id="bad", env=_FakeMultiEnv(["slack"]), - gateway_url="https://gw", + gateway_url="https://gw-1", snapshot_id="snap-x", deployed=_deployed(), ) @@ -1376,17 +1388,103 @@ def _multi_env(mcp_names, website_names): website_envs=[_FakeServiceEnv(n) for n in website_names]) -def test_enumerate_includes_websites_and_leaves_a_shared_name_to_the_mcp_server(): - servers = SnapshotEnvTaskStep._enumerate_servers(_multi_env(["slack", "gmail"], ["shop", "slack"])) - assert [(s.environment_name, s.mcp) for s in servers] == [("slack", True), ("gmail", True), ("shop", False)] +@pytest.mark.asyncio +async def test_the_servers_snapshotted_are_the_cards_children_one_per_name_and_those_serving_no_card(caplog): + """A website sharing an MCP server's name is its own server on the card, left out of the snapshot (logged) since a + universe names an environment by its name; a server serving no card, which the card leaves off, keeps its legacy + export.""" + record = _carded(await _card({"mcp-slack": [], "mcp-gmail": [], "shop": [], "slack": []})) + env = _multi_env(["slack", "gmail", "legacy"], ["shop", "slack"]) + + listed = await legacy_protocol.backing_servers(record, "https://gw-1", env) + with caplog.at_level("WARNING"): + servers = SnapshotEnvTaskStep._snapshot_servers(listed, env) + + assert [(s.environment_name, s.mcp, s.card and s.card["url"]) for s in listed] == [ + ("slack", True, "/svc/mcp-slack/agentenv"), ("gmail", True, "/svc/mcp-gmail/agentenv"), + ("shop", False, "/svc/shop/agentenv"), ("slack", False, "/svc/slack/agentenv"), ("legacy", True, None)] + assert [(s.environment_name, s.mcp) for s in servers] == [("slack", True), ("gmail", True), ("legacy", True), ("shop", False)] + assert "website 'slack' of env-1 is not snapshotted: it shares its name with the MCP server kept" in caplog.text -def test_enumerate_a_single_website_env_and_a_multi_env_of_websites_only(): +@pytest.mark.asyncio +async def test_a_single_website_env_and_a_multi_env_of_websites_only_are_snapshotted_as_websites(): from agent_env.env.envs.website import WebsiteEnv site = WebsiteEnv(id="site-env", version=1, environment_name="shop") - assert [(s.environment_name, s.mcp) for s in SnapshotEnvTaskStep._enumerate_servers(site)] == [("shop", False)] - assert SnapshotEnvTaskStep._enumerate_environments(_multi_env([], ["shop"])) == ["shop"] + assert [(s.environment_name, s.mcp) for s in legacy_protocol.definition_servers(site)] == [("shop", False)] + record = _carded(await _card({"shop": []})) + assert [(s.environment_name, s.mcp) for s in await legacy_protocol.backing_servers(record, "https://gw-1", _multi_env([], ["shop"]))] == [ + ("shop", False)] + + +@pytest.mark.asyncio +async def test_a_card_only_website_is_left_out_as_serving_no_v1_data_plane(monkeypatch, tmp_path): + """Its card declares no data/get, so it is skipped with no request, not failed on a data/get it doesn't serve.""" + record = _carded(await _card({"mcp-slack": [], "shop": []})) + record.environment_card["children_environments"][1]["capabilities"]["operations"] = [] + (shop,) = [s for s in await legacy_protocol.backing_servers(record, "https://gw-1", _multi_env(["slack"], ["shop"])) + if s.environment_name == "shop"] + sent = _mock_http(monkeypatch) + + assert await SnapshotEnvTaskStep._export_environment_to_file("https://gw-1", "shop", str(tmp_path / "out"), 30, record, + server=shop) is None + assert sent == [] + + +@pytest.mark.asyncio +async def test_a_card_only_website_is_reported_as_serving_no_v1_data_plane(monkeypatch): + real_export = SnapshotEnvTaskStep.__dict__["_export_environment_to_file"] + _install_core_artifact_fakes(monkeypatch, EnvironmentUniverseArtifact) + monkeypatch.setattr(SnapshotEnvTaskStep, "_export_environment_to_file", real_export) + record = _carded(await _card({"shop": []})) + record.environment_card["children_environments"][0]["capabilities"]["operations"] = ["data/reset"] + sent = _mock_http(monkeypatch) + + with pytest.raises(RuntimeError, match=r"exported no state: \['shop'\] serve no v1 data plane"): + await SnapshotEnvTaskStep.snapshot_env_state(env=_multi_env([], ["shop"]), gateway_url="https://gw-1", + snapshot_id="snap-x", deployed=record) + assert sent == [] + + +@pytest.mark.asyncio +async def test_an_mcp_only_env_snapshots_each_server_at_its_route_and_one_serving_no_card_by_export_state(monkeypatch): + real_export = SnapshotEnvTaskStep.__dict__["_export_environment_to_file"] + universe_puts = {} + + class _FakeUniverse: + id, version = "snap-x", 1 + + @staticmethod + def put(id, *, environment_artifacts, metadata=None): + universe_puts["names"] = [sa.environment_name for sa in environment_artifacts] + return _FakeUniverse() + + _install_core_artifact_fakes(monkeypatch, _FakeUniverse) + monkeypatch.setattr(SnapshotEnvTaskStep, "_export_environment_to_file", real_export) + sent = _mock_http(monkeypatch, rpc_result={"parts": [{"kind": "data", "data": {"rows": 1}}]}, body=b'{"rows": 2}') + record = _carded(await _card({"mcp-slack": [], "mcp-gmail": []})) + + result = await SnapshotEnvTaskStep.snapshot_env_state(env=_FakeMultiEnv(["slack", "gmail", "legacy"]), gateway_url="https://gw-1", + snapshot_id="snap-x", deployed=record) + + assert sorted((r.method, str(r.url)) for r in sent) == [ + ("GET", "https://gw-1/svc/mcp-legacy/export-state"), ("POST", f"{_CARD}/svc/mcp-gmail/agentenv"), + ("POST", f"{_CARD}/svc/mcp-slack/agentenv")] + assert universe_puts["names"] == ["slack", "gmail", "legacy"] and result.total == 3 + + +@pytest.mark.asyncio +async def test_a_website_sharing_an_mcp_servers_name_is_exported_at_its_own_route(monkeypatch, tmp_path): + """The card tells the two apart by their urls, so the website's data/get goes to /svc/slack, not the MCP server.""" + record = _carded(await _card({"mcp-slack": [], "slack": []})) + servers = await legacy_protocol.backing_servers(record, "https://gw-1", _multi_env(["slack"], ["slack"])) + sent = _mock_http(monkeypatch, rpc_result={"parts": [{"kind": "data", "data": {"orders": 2}}]}) + + for server in servers: + assert await SnapshotEnvTaskStep._export_environment_to_file("https://gw-1", "slack", str(tmp_path / "out"), 30, record, + server=server) == ".json" + assert [str(r.url) for r in sent] == [f"{_CARD}/svc/mcp-slack/agentenv", f"{_CARD}/svc/slack/agentenv"] @pytest.mark.asyncio @@ -1500,7 +1598,7 @@ async def test_export_of_a_website_serving_no_data_plane_exports_nothing_and_sen async def test_core_snapshots_websites_through_their_data_plane_and_leaves_out_those_without_one(monkeypatch): calls = [] - async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None, **website): + async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, data_get_params=None, server=None, **website): calls.append((name, website)) if name == "legacy-site": return None @@ -1539,5 +1637,5 @@ async def fake_export(gateway_url, name, tmp_path, timeout, deployed=None, **web _install_core_artifact_fakes(monkeypatch, EnvironmentUniverseArtifact) monkeypatch.setattr(SnapshotEnvTaskStep, "_export_environment_to_file", staticmethod(fake_export)) - with pytest.raises(RuntimeError, match=r"exported no state: its websites \['shop'\] serve no v1 data plane"): + with pytest.raises(RuntimeError, match=r"exported no state: \['shop'\] serve no v1 data plane"): await SnapshotEnvTaskStep.snapshot_env_state(env=_multi_env([], ["shop"]), gateway_url="https://gw", snapshot_id="snap-x") From 76145d25ad039e3509f6841220b8594234346e1e Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 13:34:07 -0800 Subject: [PATCH 03/57] feat(website): WebsiteEnvironment serves a site on the website/v1 contract, and a conformance check tests any server (FD-4683) This PR's change, rebased as one commit onto #427. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- packages/agentenv-protocol/README.md | 27 + .../src/agentenv_protocol/website/__init__.py | 36 ++ .../src/agentenv_protocol/website/clock.py | 114 ++++ .../agentenv_protocol/website/conformance.py | 351 +++++++++++ .../agentenv_protocol/website/environment.py | 450 ++++++++++++++ .../src/agentenv_protocol/website/errors.py | 39 ++ .../agentenv_protocol/website/readiness.py | 73 +++ .../src/agentenv_protocol/website/routes.py | 369 ++++++++++++ .../src/agentenv_protocol/website/session.py | 60 ++ .../src/agentenv_protocol/website/state.py | 56 ++ .../src/agentenv_protocol/website/store.py | 134 +++++ .../tests/test_website_api.py | 413 +++++++++++++ .../tests/test_website_conformance.py | 265 +++++++++ .../tests/test_website_environment.py | 552 ++++++++++++++++++ .../tests/test_website_readiness.py | 209 +++++++ .../tests/test_website_state.py | 121 ++++ src/agent_env/env/env.py | 59 +- src/agent_env/env/gateway/gateway.py | 85 ++- src/agent_env/env/legacy_protocol.py | 22 +- .../env_providers/env_gateway_provider.py | 21 +- .../task_step/task_steps/add_skills.py | 16 +- .../mcp_cli_builder/build_mcp_cli.py | 11 +- .../verifiers/website_state_verifier.py | 98 +++- tst/unit/env/gateway/changes_test.py | 15 +- .../env/gateway/rest_proxy_stream_test.py | 15 +- tst/unit/env/gateway/triggers_website_test.py | 2 +- .../gateway/website_reserved_paths_test.py | 96 ++- tst/unit/env/test_env.py | 111 +++- tst/unit/env/test_legacy_protocol.py | 15 + .../env_gateway_provider_test.py | 55 ++ tst/unit/task_step/add_skills_env_test.py | 11 + .../task_step/test_manifest_cli_builder.py | 11 + .../test_website_state_verifier_step.py | 74 ++- 34 files changed, 3893 insertions(+), 95 deletions(-) create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/clock.py create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/environment.py create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/errors.py create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/readiness.py create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/routes.py create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/session.py create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/state.py create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/store.py create mode 100644 packages/agentenv-protocol/tests/test_website_api.py create mode 100644 packages/agentenv-protocol/tests/test_website_conformance.py create mode 100644 packages/agentenv-protocol/tests/test_website_environment.py create mode 100644 packages/agentenv-protocol/tests/test_website_readiness.py create mode 100644 packages/agentenv-protocol/tests/test_website_state.py diff --git a/AGENTS.md b/AGENTS.md index aa980e2e8..17a4fa4e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,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.]` (`env/envs/computer/utils/install.py`): files, apt `packages` with extra `sources`, a `python` requirements file for a venv, `downloads` by URL and sha256, `run` scripts, the services the base's init runs, and `ports`; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch on a builder of the put's sandbox provider, by default the env's (`utils/builder.py`; `--sandbox` on `env build`, `bundle register` and `run`): this machine's Docker for `local`, else a VM of the provider, which fetches the put's stored files by signed URL, takes the rest inline and pushes to the image store itself, so it builds only with stores a remote VM reaches; each arch goes to the first link that runs it natively, else one that emulates it (a VM registers QEMU; `--no-emulate` leaves it `skipped` instead, for a later put), and its pair records the provider that built it (`builder`); a run or register that reuses a computer env composes what it has left (`pending`), a run only the forms its sandbox's deploys boot (`booted_forms`: on each link, the first form it boots natively, by the deploy's own rule, `boot.first_native`; a compose of a disk adds its container), and a deploy composes first what it boots that no compose started (`ComputerEnv.compose_unstarted`); tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile, build context and lock so any env with the same inputs reuses it; every layer's packages, requirements and downloads are resolved at put in a container of the base for each arch (`utils/packages.py`, `utils/resolve_in_base.py`), stored in the object store by sha256 under `agentenv-packages/`, and listed in a lock stored by a digest of its inputs, which any env with the same inputs reuses, and builds install only the lock's files, every install step `RUN --network=none`; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 (`utils/probing.py`) then boots each native build on the put's sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`, which declares each OS with its own decorator (`@linux(...)`, or `@install(os_type, spec)` for any OS type) holding its install, its launch and, optionally, a skill and settings and build settings that add to the class's there, and install steps and ready checks for every OS or those their `os=` names; everything that reads its settings, launch or steps asks for the computer's OS type; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference, over its env.toml's `[settings]` and a computer env's `settings` beside it in `apps`, each by top-level field (`params.app_settings`; checked at put, a required one left to the deploy; the deploy record keeps the merged values as `app_settings`, secrets withheld); its env.toml's `build_settings`, values of its `BuildSettings` checked at put, are written to `.agentenv/build_settings.json` in its folder, so they're part of the image's key, its install steps read them, and a deploy can't change them). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `env/gateway/gateway.py` is the env-side gateway server, container-only code. 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`. | +| `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, ready when its card's `website/v1` `ready` answers a `WebsiteReadiness` that says so, as `agentenv_protocol.website` (`website_readiness`, `env_readiness`) defines a website env's health for the deploy and `DeployedEnv.readiness()` alike, or, for a card without `ready`, when its `health` path answers 200, which is deprecated; the gateway container's `env/gateway/constants.py` mirrors the URI and op, a test keeping them equal; 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.]` (`env/envs/computer/utils/install.py`): files, apt `packages` with extra `sources`, a `python` requirements file for a venv, `downloads` by URL and sha256, `run` scripts, the services the base's init runs, and `ports`; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch on a builder of the put's sandbox provider, by default the env's (`utils/builder.py`; `--sandbox` on `env build`, `bundle register` and `run`): this machine's Docker for `local`, else a VM of the provider, which fetches the put's stored files by signed URL, takes the rest inline and pushes to the image store itself, so it builds only with stores a remote VM reaches; each arch goes to the first link that runs it natively, else one that emulates it (a VM registers QEMU; `--no-emulate` leaves it `skipped` instead, for a later put), and its pair records the provider that built it (`builder`); a run or register that reuses a computer env composes what it has left (`pending`), a run only the forms its sandbox's deploys boot (`booted_forms`: on each link, the first form it boots natively, by the deploy's own rule, `boot.first_native`; a compose of a disk adds its container), and a deploy composes first what it boots that no compose started (`ComputerEnv.compose_unstarted`); tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile, build context and lock so any env with the same inputs reuses it; every layer's packages, requirements and downloads are resolved at put in a container of the base for each arch (`utils/packages.py`, `utils/resolve_in_base.py`), stored in the object store by sha256 under `agentenv-packages/`, and listed in a lock stored by a digest of its inputs, which any env with the same inputs reuses, and builds install only the lock's files, every install step `RUN --network=none`; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 (`utils/probing.py`) then boots each native build on the put's sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`, which declares each OS with its own decorator (`@linux(...)`, or `@install(os_type, spec)` for any OS type) holding its install, its launch and, optionally, a skill and settings and build settings that add to the class's there, and install steps and ready checks for every OS or those their `os=` names; everything that reads its settings, launch or steps asks for the computer's OS type; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference, over its env.toml's `[settings]` and a computer env's `settings` beside it in `apps`, each by top-level field (`params.app_settings`; checked at put, a required one left to the deploy; the deploy record keeps the merged values as `app_settings`, secrets withheld); its env.toml's `build_settings`, values of its `BuildSettings` checked at put, are written to `.agentenv/build_settings.json` in its folder, so they're part of the image's key, its install steps read them, and a deploy can't change them). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `env/gateway/gateway.py` is the env-side gateway server, container-only code. 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`: its environment's files by path, stored as the one wrapped `file_artifact_ref` every environment artifact had when it has one file at its filename, else as `file_artifact_refs`; `get_file_artifacts()` reads either, and a load hands each file to the env's `data/add` with its path, which a pre-v1 load, taking one file, refuses), `EnvironmentUniverseArtifact` (`environment_universe`), `SkillArtifact` (`skill`); `[artifacts] type_aliases` maps another stored spelling of a type onto one of them. A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull, or a published image's tag with its `image_digest` and its build context, which sandboxes pull by the digest and build from the context when they can't (the infra images, and a bundle agent built from a shipped agent's sources, `Plan.published`), or, written by `put_context`, only its build context, which a VM sandbox builds before running it (`VmSandbox.load_docker_images`) and Modal builds before creating its container (`ModalSandboxProvider.prepare_image`, outside a fallback chain's deadline); anything else that runs images by name (a container on the local provider, a lone server on any provider but Modal) refuses such an image. A build context (`utils/docker/context.py`) is what `docker build` would send, `.dockerignore` applied, written so equal files give equal bytes and an equal `source_digest`. | | `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, among them `website_state_verifier`, which reads each checked session of a deployed website env's sites, or a multi env's, at the site's `/go?sid=` through `/svc//` and checks one dot path of its `current_state`, `initial_state` or `state_diff` each, optionally reduced by `len`, `keys` or `bool`, with `eq`, `ne`, `ge`, `gt`, `le`, `lt`, `in` or `contains`, its sites checked before a run against the `env_version` it pins, `start_computer_recording` and `end_computer_recording`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. A computer's framework-only routes (everything but MCP and the card) take the per-deploy control token its provider boots it with (`DeployedEnv.control_token`, `AGENTENV_CONTROL_TOKEN`); framework calls send `deployed.control_headers`, `Task.run` runs every step under `protocol_v1.authorized` with each deployed env's (a `verify` included), and a `run_code` script inside the computer gets `input["env"]["headers"]`. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each env still up closes (`Env.close`; a computer env ends any recording still open), what stayed open is registered from its namespace, then every sandbox is terminated by id. `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB, DynamoDB, Firestore with MongoDB compatibility), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. `store/routing.py` is the local namespaces, `@local` and `@hf` (`is_local_id`), each with a documents file of its own: while the CLI or a `build_app` server runs, a local id and what a local task's run records go to local stores beside the configured ones, and that run's writes to the configured stores are refused. A new backend must pass the conformance kits in `tst/store/`. | diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index 4e3b0de48..847455739 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -430,6 +430,33 @@ async with ToolSession(mcp_url=mcp_url, role="alice@example.com") as session: - **Tools:** `list_tools()` returns the tools the role may use as `EnvironmentTool`, the type a card's `capabilities.tools` holds. Each `inputSchema` is the JSON Schema a model API takes as the tool's parameters. +## Website readiness + +`agentenv_protocol.website` is the one definition of whether a website env is healthy. A site's card advertises +`urn:agentenv:website/v1`'s `ready` (`WEBSITE_READY_OP`), a GET answering a `WebsiteReadiness`: `ready`, a boolean, +and `problems`, the names of what keeps it from being ready (a `WebsiteEnvironment`'s are its `@ready_check` methods +not yet true). The model is strict about those two (`"yes"` is no `ready`) and ignores fields it doesn't know, so a +reader keeps reading a newer site. Everything that judges a site reads it through these helpers: agent-env's deploy, +`DeployedEnv.readiness()`, and the conformance checker's `health`, which fails an answer that isn't a +`WebsiteReadiness`. + +```python +from agentenv_protocol.website import env_readiness, website_readiness + +site = await website_readiness(base_url, card) # WebsiteReadiness, or None for a card without ready +env = await env_readiness(gateway_url, env_card) # WebsiteReadiness of every site of the env +print(env.ready, env.problems) # False ['shop: seeded'] +``` + +- **One site:** `website_readiness(base_url, card, *, timeout=READY_TIMEOUT_S, headers=None)` asks the site as its card + advertises `ready`. It raises httpx's error when the site can't be reached, `ExtensionCallError` for a non-2xx + answer and `ValueError` (pydantic's `ValidationError`) for an answer that isn't a `WebsiteReadiness`. +- **An env:** `env_readiness(base_url, card, ...)` asks, at once, the card itself when it advertises `ready` (a single + site's card), and every child under a composed card's `children_environments` that does, its endpoints rooted at + `base_url` as a gateway's are. A child's problems are prefixed with its name (`shop: seeded`); one that can't be + asked, or answers no `WebsiteReadiness`, is a problem itself. A card or child without `ready`, an MCP server's or an + older site's, isn't counted, so an env with no site serving it is ready with no problems. + ## Computer drivers A computer driver is what runs inside a computer an agent uses through screenshots and input. Write diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py new file mode 100644 index 000000000..cfdc1a39e --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py @@ -0,0 +1,36 @@ +"""The website SDK: a site's server, meeting ``urn:agentenv:website/v1``. Subclass WebsiteEnvironment, declare the +site with ``@website(domains=[...], static="dist/")``, mark its API with ``@api.get/post/put/patch/delete(path)``, and +``serve()`` it; it needs only the plain package, and uvicorn to serve. The class is optional: a server in any language +that meets the contract works, which ``python -m agentenv_protocol.website.conformance `` checks. Whether a site +is ready is its ``WebsiteReadiness`` answer to ``ready``, which ``website_readiness`` reads of one site and +``env_readiness`` of every site of an env. What a task left a site in is its ``WebsiteState`` answer to ``state``, +which ``website_state`` reads of one site of those ``website_sites`` names.""" + +from __future__ import annotations + +from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, UniverseClock +from .environment import ( + DEFAULT_HEALTH_PATH, + SESSION_PATH, + STATE_PATH, + WebsiteDeclaration, + WebsiteEnvironment, + ready_check, + website, +) +from .errors import ApiError, BadRequest, Conflict, Forbidden, NotFound, Unauthorized +from .readiness import READY_TIMEOUT_S, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness, env_readiness, website_readiness +from .routes import ROLE_KEY_ENV, ROLE_KEY_HEADER, ApiContext, ApiRoute, api, reserved_path +from .session import SESSION_COOKIE, SessionSigner +from .state import STATE_TIMEOUT_S, WEBSITE_STATE_OP, WebsiteState, advertises_state, website_sites, website_state +from .store import Collections + +__all__ = [ + "WebsiteEnvironment", "WebsiteDeclaration", "website", "api", "ApiContext", "ApiRoute", "ready_check", + "ApiError", "BadRequest", "Unauthorized", "Forbidden", "NotFound", "Conflict", + "Collections", "UniverseClock", "SessionSigner", "reserved_path", + "WEBSITE_EXTENSION_URI", "CLOCK_EXTENSION_URI", "UNIVERSE_NOW_HEADER", "DEFAULT_HEALTH_PATH", "SESSION_PATH", + "STATE_PATH", "SESSION_COOKIE", "ROLE_KEY_ENV", "ROLE_KEY_HEADER", + "WEBSITE_READY_OP", "READY_TIMEOUT_S", "WebsiteReadiness", "website_readiness", "env_readiness", + "WEBSITE_STATE_OP", "STATE_TIMEOUT_S", "WebsiteState", "advertises_state", "website_sites", "website_state", +] diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/clock.py b/packages/agentenv-protocol/src/agentenv_protocol/website/clock.py new file mode 100644 index 000000000..0d27d86a9 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/clock.py @@ -0,0 +1,114 @@ +"""The universe's clock as a site reads it: ``clock/v1``'s ``sync_time`` hands the server the gateway's ``/clock/time`` +URL, as it hands it to MCP servers, and the site's time then follows that clock's ``{"virtual_time": }``, +read at most once a second and extrapolated between reads. The rate it extrapolates at is one two successive intervals +between reads agreed on, so a clock set or advanced between two reads is taken for a jump, not a speed; until two agree +it is real time's. An unsynced site reads wall time. Every answer of a site carries the time it answered at as ``X-Universe-Now``, +RFC3339 in UTC. ``sync_time`` may also name the universe's time zone, an IANA name, which the clock keeps for the site +to show local times in; the time itself stays UTC.""" +from __future__ import annotations + +import asyncio +import datetime +import json +import logging +import time + +import httpx + +logger = logging.getLogger(__name__) + +CLOCK_EXTENSION_URI = "urn:agentenv:clock/v1" +UNIVERSE_NOW_HEADER = "X-Universe-Now" +REFRESH_S = 1.0 +READ_TIMEOUT_S = 5.0 +RATE_AGREEMENT = 0.1 + + +def iso(moment: datetime.datetime) -> str: + return moment.astimezone(datetime.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def parse_time(value: str) -> datetime.datetime: + moment = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + return moment if moment.tzinfo else moment.replace(tzinfo=datetime.timezone.utc) + + +class UniverseClock: + """The time a site answers with: wall time until ``sync`` names a gateway clock, that clock's after.""" + + def __init__(self) -> None: + self.url: str | None = None + self.timezone: str | None = None + self._virtual: datetime.datetime | None = None + self._read_at = 0.0 + self._tried_at = 0.0 + self._rate = 1.0 + self._measured: float | None = None + self._lock: asyncio.Lock | None = None + + @property + def synced(self) -> bool: + return self._virtual is not None + + def now(self) -> datetime.datetime: + if self._virtual is None: + return datetime.datetime.now(datetime.timezone.utc) + return self._virtual + datetime.timedelta(seconds=(time.monotonic() - self._read_at) * self._rate) + + async def sync(self, url: str, timezone: str | None = None) -> datetime.datetime: + """Follow the clock at ``url`` from now on, in ``timezone`` (none when the sync names none); raises when it + can't be read, keeping the clock and zone it followed. It waits for a refresh reading the clock it followed, + so that read never lands on the new clock.""" + async with self._reading(): + virtual = await _read(url) + self.url, self._virtual, self._rate, self._measured = url, virtual, 1.0, None + self.timezone = timezone + self._read_at = self._tried_at = time.monotonic() + return virtual + + async def refresh(self) -> None: + """Read the synced clock again when the last read is a second old; a failed read keeps extrapolating.""" + if self.url is None or time.monotonic() - self._tried_at < REFRESH_S: + return + async with self._reading(): + started = time.monotonic() + if started - self._tried_at < REFRESH_S: + return + self._tried_at = started + try: + virtual = await _read(self.url) + except Exception as e: + logger.debug("reading the universe's clock at %s failed: %r", self.url, e) + return + read_at = time.monotonic() + if self._virtual is not None and read_at > self._read_at: + measured = max(0.0, (virtual - self._virtual).total_seconds() / (read_at - self._read_at)) + if self._measured is not None and abs(measured - self._measured) <= RATE_AGREEMENT * max(measured, self._measured, 1.0): + self._rate = measured + self._measured = measured + self._virtual, self._read_at = virtual, read_at + + def _reading(self) -> asyncio.Lock: + """The lock a sync and a refresh each read the clock under.""" + self._lock = self._lock or asyncio.Lock() + return self._lock + + +async def _read(url: str) -> datetime.datetime: + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=READ_TIMEOUT_S) + response.raise_for_status() + return parse_time(response.json()["virtual_time"]) + + +def clock_patch_script(now: datetime.datetime) -> bytes: + """An inline script that shifts the page's ``Date`` to the universe's time ``now``, and follows the + ``X-Universe-Now`` header of every ``fetch`` the page makes after. The ``Date`` it puts in place answers called, + with the time's string, as constructed, and keeps ``Date.parse``, ``Date.UTC`` and ``instanceof Date``.""" + return ("" % (int(now.timestamp() * 1000), json.dumps(UNIVERSE_NOW_HEADER))).encode() diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py new file mode 100644 index 000000000..8df978f4b --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -0,0 +1,351 @@ +"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in: +``python -m agentenv_protocol.website.conformance `` prints each check and exits 1 when one failed, 2 when the +server couldn't be reached. Everything goes over HTTP: the card, ``/`` under a made-up host, readiness by the card's +``website/v1`` ``ready``, whose answer must be a ``WebsiteReadiness`` (by the declared health path, deprecated, for a +card without it), ``X-Universe-Now`` on every answer the other checks got, a data-plane round trip at the card's ``url`` +(data/get, data/reset, data/get, which no longer returns the exported data, data/add of what the first data/get +returned, data/get again), ``clock/v1``'s +``sync_time`` against a clock the checker serves, and the forwarded headers wherever an answer shows them, on ``/`` and +on a GET route with its trailing slash toggled, which a server may redirect. + +The round trip leaves the server's data as it found it, adding the export back when a step after the reset fails too. +data/add sets the state a task starts from, so a server whose state shows changes since its initial state, as an SDK +site's does, would have them folded into it: the round trip is skipped for such a server. Its state is its card's +``website/v1`` ``state``, else, for a card without it, what the path its ``params.state.path`` names answers, else +``/go``'s, deprecated. The clock check leaves the +server following a clock that is gone once the check ends, so a server it ran against keeps extrapolating from that +clock's time until it's synced again. The checker's clock starts at ``CLOCK_START``, far enough from wall time that a +synced server shows it, and a synced server's ``X-Universe-Now`` may be ``CLOCK_SLACK_S`` seconds from it.""" +from __future__ import annotations + +import argparse +import contextlib +import dataclasses +import datetime +import json +import sys +import threading +import time +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Callable, Iterator, Literal + +import httpx +from pydantic import ValidationError + +from ..client import find_extension, find_extension_method +from ..types import METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH +from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, iso, parse_time +from .environment import DEFAULT_HEALTH_PATH, STATE_PATH +from .readiness import WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness +from .state import WEBSITE_STATE_OP + +Status = Literal["passed", "failed", "skipped"] +CLOCK_START = datetime.datetime(2031, 5, 6, 7, 8, 9, tzinfo=datetime.timezone.utc) +CLOCK_SLACK_S = 120 + + +@dataclasses.dataclass(frozen=True) +class Result: + name: str + status: Status + detail: str = "" + + @property + def passed(self) -> bool: + return self.status != "failed" + + +def check(base_url: str, *, client: httpx.Client | None = None, clock_host: str = "127.0.0.1", + timeout: float = 30) -> list[Result]: + """Each check's result against the server at ``base_url``. ``clock_host`` is the address the server reaches the + checker's clock at. Raises httpx's error when the server can't be reached at all.""" + own = client is None + client = client or httpx.Client(timeout=timeout) + try: + return _Checker(base_url.rstrip("/"), client, clock_host).run() + finally: + if own: + client.close() + + +class _Checker: + def __init__(self, base_url: str, client: httpx.Client, clock_host: str) -> None: + self.base_url, self.client, self.clock_host = base_url, client, clock_host + self.rpc_url = base_url + RPC_PATH + self.responses: list[httpx.Response] = [] + + def get(self, path: str, **kwargs) -> httpx.Response: + return self._seen(self.client.get(self.base_url + path, **kwargs)) + + def _seen(self, response: httpx.Response) -> httpx.Response: + self.responses.append(response) + return response + + def run(self) -> list[Result]: + response = self.get(WELL_KNOWN_PATH) + try: + card = response.json() if response.status_code == 200 else {} + except ValueError: + card = {} + self.rpc_url = _rpc_url(self.base_url, card) + site = (find_extension(card, WEBSITE_EXTENSION_URI) or {}).get("params") or {} + health = site.get("health") if isinstance(site.get("health"), str) else DEFAULT_HEALTH_PATH + steps = (("any_host", self._any_host), ("health", lambda: self._health(card, health)), + ("universe_now", self._universe_now), ("data_plane", lambda: self._data_plane(card)), + ("clock", lambda: self._clock(card, health)), ("forwarded", lambda: self._forwarded(site)), + ("cookies", lambda: self._cookies(site))) + outcomes = {name: _outcome(step) for name, step in steps if name != "universe_now"} + outcomes["universe_now"] = _outcome(self._universe_now) + return [self._card(response, site), *(Result(name, *outcomes[name]) for name, _step in steps)] + + def _card(self, response: httpx.Response, site: dict) -> Result: + if response.status_code != 200: + return Result("card", "failed", f"{WELL_KNOWN_PATH} answered {response.status_code}") + problems = [] + if not site: + problems.append(f"declares no {WEBSITE_EXTENSION_URI} with params") + if not isinstance(site.get("domains"), list): + problems.append("its params have no domains list") + if not (isinstance(site.get("health"), str) and site["health"].startswith("/")): + problems.append("its params have no health path") + routes = site.get("routes") + if not isinstance(routes, list) or not all(isinstance(r, dict) and {"method", "path", "name"} <= set(r) + for r in routes): + problems.append("its params' routes aren't a list of {method, path, name}") + return Result("card", "failed" if problems else "passed", "; ".join(problems)) + + def _any_host(self) -> tuple[Status, str]: + host = f"conformance-{uuid.uuid4().hex[:8]}.invalid" + response = self.get("/", headers={"Host": host}) + kind = response.headers.get("content-type", "") + if response.status_code == 200 and kind.startswith("text/html"): + return "passed", f"/ served HTML for Host {host}" + return "failed", f"/ for Host {host} answered {response.status_code} {kind or 'without a content type'}" + + def _health(self, card: dict, health: str) -> tuple[Status, str]: + ready = find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP) + if ready is None: + response = self.get(health) + return (("passed" if response.status_code == 200 else "failed"), + f"{health} answered {response.status_code}; the card has no website/v1 ready, so the health path " + "was read, which is deprecated") + answer = self._seen(self.client.request(ready.get("method", "GET"), self.base_url + ready["endpoint"])).json() + try: + readiness = WebsiteReadiness.model_validate(answer) + except ValidationError as e: + return "failed", (f"website/v1 ready answered {answer!r}, not a WebsiteReadiness: " + f"{'; '.join(_field_error(error) for error in e.errors())}") + if readiness.ready: + return "passed", "website/v1 ready says it is ready" + return "failed", f"website/v1 ready says it is not ready: {', '.join(readiness.problems) or 'no problem named'}" + + def _universe_now(self) -> tuple[Status, str]: + missing = [str(r.url.path) for r in self.responses if UNIVERSE_NOW_HEADER not in r.headers] + if missing: + return "failed", f"no {UNIVERSE_NOW_HEADER} on {', '.join(missing)}" + for response in self.responses: + parse_time(response.headers[UNIVERSE_NOW_HEADER]) + return "passed", f"on all {len(self.responses)} answers" + + def _rpc(self, method: str, params: dict | None = None) -> dict: + response = self._seen(self.client.post(self.rpc_url, json={"jsonrpc": "2.0", "id": method, + "method": method, "params": params or {}})) + body = response.json() + if isinstance(body, dict) and "error" in body: + raise RuntimeError(f"{method} answered {body['error']}") + if not response.is_success or not isinstance(body, dict) or "result" not in body: + raise RuntimeError(f"{method} answered {response.status_code} with no JSON-RPC result: {response.text[:200]}") + return body["result"] or {} + + def _data_plane(self, card: dict) -> tuple[Status, str]: + operations = (card.get("capabilities") or {}).get("operations") + missing = [m for m in (METHOD_GET, METHOD_RESET, METHOD_ADD) if operations is not None and m not in operations] + if missing: + return "failed", f"the card's operations lack {', '.join(missing)}" + exported = self._rpc(METHOD_GET).get("parts") or [] + if where := self._changed_since_initial(card): + return "skipped", (f"data/get answered; {where} shows changes since the initial state, which data/reset " + "and data/add would fold into it, so the round trip was not run") + self._rpc(METHOD_RESET) + added_back = False + try: + cleared = self._rpc(METHOD_GET).get("parts") or [] + if _holds_data(exported) and cleared == exported: + added_back = True + return "failed", "data/get after data/reset returned the data it returned before" + if not exported: + added_back = True + return "passed", "data/get, data/reset and data/get answered; data/get returned nothing to add back" + added_back = True + self._rpc(METHOD_ADD, {"parts": exported}) + again = self._rpc(METHOD_GET).get("parts") or [] + finally: + if not added_back: + with contextlib.suppress(Exception): + self._rpc(METHOD_ADD, {"parts": exported}) + if again != exported: + return "failed", "data/get after data/reset and data/add of its own export returned other data" + return "passed", f"{len(exported)} part(s) round-tripped through reset and add" + + def _changed_since_initial(self, card: dict) -> str | None: + """Where the server's state was read when its ``state_diff`` names changes, None when it names none or can't be + read: the card's ``website/v1`` ``state``, else the path its ``params.state.path`` names, else ``/go``. Read + aside, as no check's answer, since a server without one needn't stamp its 404.""" + op = find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_STATE_OP) + if op is not None and op.get("endpoint"): + where, request = f"website/v1 {WEBSITE_STATE_OP}", (op["method"], self.base_url + op["endpoint"]) + else: + declared = ((find_extension(card, WEBSITE_EXTENSION_URI) or {}).get("params") or {}).get("state") + path = declared.get("path") if isinstance(declared, dict) else None + where = path if isinstance(path, str) and path.startswith("/") else STATE_PATH + request = ("GET", self.base_url + where) + try: + state = self.client.request(*request).json() + except Exception: + return None + return where if isinstance(state, dict) and state.get("state_diff") else None + + def _clock(self, card: dict, health: str) -> tuple[Status, str]: + if find_extension(card, CLOCK_EXTENSION_URI) is None: + return "failed", f"the card declares no {CLOCK_EXTENSION_URI}" + params = find_extension(card, CLOCK_EXTENSION_URI).get("params") or {} + method = find_extension_method(card, CLOCK_EXTENSION_URI, next(iter(params.get("methods") or {}), "sync_time")) + if method is None or not method.get("endpoint"): + return "failed", f"{CLOCK_EXTENSION_URI} advertises no sync_time endpoint" + with _clock_server(self.clock_host) as (url, virtual_now): + response = self._seen(self.client.request(method["method"], self.base_url + method["endpoint"], + json={"env_get_time_url": url})) + if response.status_code >= 400: + return "failed", f"sync_time answered {response.status_code}: {response.text[:200]}" + seen = self.get(health).headers.get(UNIVERSE_NOW_HEADER) + expected = virtual_now() + if seen is None: + return "failed", f"no {UNIVERSE_NOW_HEADER} after sync_time" + off = abs((parse_time(seen) - expected).total_seconds()) + if off > CLOCK_SLACK_S: + return "failed", f"after sync_time {UNIVERSE_NOW_HEADER} is {seen}, {off:.0f}s from the clock's {iso(expected)}" + return "passed", f"{UNIVERSE_NOW_HEADER} followed the clock to {seen}" + + def _forwarded(self, site: dict) -> tuple[Status, str]: + internal = f"internal-{uuid.uuid4().hex[:8]}.invalid" + forwarded = f"site-{uuid.uuid4().hex[:8]}.example" + headers = {"Host": internal, "X-Forwarded-Host": forwarded, "X-Forwarded-Proto": "https"} + paths = ["/", *_slash_toggled(site.get("routes"))] + for path in paths: + response = self.get(path, headers=headers, follow_redirects=False) + location = response.headers.get("location", "") + shown = [where for where, text in (("body", response.text), ("Location", location)) if internal in text] + if shown: + return "failed", f"the {' and '.join(shown)} of {path} named the internal Host {internal}, not {forwarded}" + if location.startswith(f"http://{forwarded}"): + return "failed", f"the Location of {path} is {location}, not https as X-Forwarded-Proto said" + return "passed", f"no answer to {', '.join(paths)} named the internal Host" + + def _cookies(self, site: dict) -> tuple[Status, str]: + if isinstance(site.get("session_path"), str): + self.get(site["session_path"]) + cookies = [c for r in self.responses for c in r.headers.get_list("set-cookie")] + if not cookies: + return "skipped", "no answer set a cookie" + scoped = [c.split("=", 1)[0] for c in cookies if any(a.strip().lower().startswith("domain=") for a in c.split(";"))] + if scoped: + return "failed", f"cookies tied to a domain: {', '.join(sorted(set(scoped)))}" + return "passed", f"{len(cookies)} cookie(s), none tied to a domain" + + +def _rpc_url(base_url: str, card: dict) -> str: + """Where the card says its data plane is: its ``url``, a path joined onto ``base_url`` or a full URL, else + ``RPC_PATH``.""" + url = card.get("url") if isinstance(card.get("url"), str) and card.get("url") else RPC_PATH + return url if "://" in url else base_url + url + + +def _outcome(step: Callable[[], tuple[Status, str]]) -> tuple[Status, str]: + try: + return step() + except Exception as e: + return "failed", f"{type(e).__name__}: {e}" + + +def _field_error(error: dict) -> str: + """One of a ``ValidationError``'s errors as a person reads it: where, then what.""" + where = ".".join(map(str, error.get("loc") or ())) or "the answer" + return f"{where}: {error.get('msg')}" + + +def _holds_data(parts: list) -> bool: + """Whether exported parts carry task data: a part that isn't a data part, or a data part with a value in it.""" + return any(not (isinstance(p, dict) and p.get("kind") == "data") or _filled(p.get("data")) for p in parts) + + +def _filled(value: object) -> bool: + if isinstance(value, (dict, list)): + return any(_filled(v) for v in (value.values() if isinstance(value, dict) else value)) + return value not in (None, "") + + +def _slash_toggled(routes: object) -> list[str]: + """The first GET route without path parameters, with its trailing slash added or taken off: a path a server + may redirect to the route's own, which shows the address the redirect names.""" + for route in routes if isinstance(routes, list) else []: + path = route.get("path") if isinstance(route, dict) and route.get("method") == "GET" else None + if isinstance(path, str) and path.startswith("/") and path != "/" and "{" not in path: + return [path[:-1] if path.endswith("/") else path + "/"] + return [] + + +@contextlib.contextmanager +def _clock_server(host: str) -> Iterator[tuple[str, Callable[[], datetime.datetime]]]: + """A gateway-shaped ``/clock/time`` running from ``CLOCK_START`` at real speed, and its time now.""" + started = time.monotonic() + + def virtual_now() -> datetime.datetime: + return CLOCK_START + datetime.timedelta(seconds=time.monotonic() - started) + + class Clock(BaseHTTPRequestHandler): + def do_GET(self) -> None: + body = json.dumps({"virtual_time": iso(virtual_now())}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args) -> None: + return None + + server = ThreadingHTTPServer(("127.0.0.1" if host in ("127.0.0.1", "localhost") else "0.0.0.0", 0), Clock) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + address = f"[{host}]" if ":" in host else host + yield f"http://{address}:{server.server_port}/clock/time", virtual_now + finally: + server.shutdown() + server.server_close() + + +_MARK = {"passed": "PASS", "failed": "FAIL", "skipped": "----"} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python -m agentenv_protocol.website.conformance", + description="Check a running site's server against urn:agentenv:website/v1.") + parser.add_argument("url", help="where the server answers, such as http://localhost:8000") + parser.add_argument("--clock-host", default="127.0.0.1", help="the address the server reaches this checker at") + parser.add_argument("--timeout", type=float, default=30, help="seconds for each request") + args = parser.parse_args(argv) + try: + results = check(args.url, clock_host=args.clock_host, timeout=args.timeout) + except httpx.TransportError as e: + print(f"couldn't reach {args.url}: {type(e).__name__}: {e}", file=sys.stderr) + return 2 + for result in results: + print(f"{_MARK[result.status]} {result.name}{': ' + result.detail if result.detail else ''}") + failed = sum(not r.passed for r in results) + print(f"{args.url}: {len(results) - failed} passed or skipped, {failed} failed") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py new file mode 100644 index 000000000..31663b55a --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py @@ -0,0 +1,450 @@ +"""WebsiteEnvironment: an AgentEnvEnvironment that is a site's server, meeting ``urn:agentenv:website/v1``. It serves +plain HTTP on ``PORT`` for any host name: its ``@api`` routes, its static folder at ``/`` with ``index.html`` for the +pages a single-page app routes itself, its health path, and the data plane and card the base class serves. Every answer +carries ``X-Universe-Now``, the universe's time, which ``self.now()`` reads and ``clock/v1``'s ``sync_time`` ties to +the gateway's virtual clock. Without data hooks of its own a site keeps its state in ``self.store``. + +Two routes are built in, each replaced by a site's own ``@api`` route at its very path and answering before one whose +path parameters would also match it: ``/api/session``, which a frontend calls to learn who is signed in, signing the +acting user in when nobody is (GET), or to sign out (DELETE); and, for a store-backed site, ``/go``, its +``{initial_state, current_state, state_diff}`` at the path ``website/v1``'s ``params.state.path`` names, which a reader +of a card without ``state`` falls back to. A store-backed site answers ``website/v1``'s ``state`` with the same +``WebsiteState``, among agentenv's own routes, which is how the website state verifier reads it.""" +from __future__ import annotations + +import inspect +import json +import os +import re +from pathlib import Path +from typing import Any, ClassVar, NamedTuple, Optional + +from starlette.applications import Starlette +from starlette.datastructures import MutableHeaders +from starlette.middleware import Middleware +from starlette.requests import Request +from starlette.responses import FileResponse, JSONResponse, Response +from starlette.routing import Route + +from ..agent_env_environment import ( + _OP_ATTR, + AgentEnvEnvironment, + AgentEnvStarletteApplication, + _marked_members, + _maybe_await, + add_data, + extension, + get_data, + reset_data, +) +from ..types import DataPart, EnvironmentExtension +from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, UniverseClock, clock_patch_script, iso +from .errors import NotFound +from .readiness import WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness +from .routes import ApiContext, ApiRoute, endpoint, error_response, openapi, openapi_path, request_role, reserved_path, routes_of +from .session import SESSION_COOKIE, SessionSigner, expires_at +from .state import WEBSITE_STATE_OP, WebsiteState +from .store import Collections + +DEFAULT_HEALTH_PATH = "/api/health" +SESSION_PATH = "/api/session" +STATE_PATH = "/go" +_WEBSITE_ATTR = "_aee_website" +_READY_ATTR = "_aee_website_ready" +_DEFAULT_HOOK_ATTR = "_aee_default_store_hook" +_DEFAULT_HOOKS = {"reset_data": "_store_reset", "add_data": "_store_add", "get_data": "_store_get"} +_HEAD = re.compile(rb"]*>", re.IGNORECASE) + + +class WebsiteDeclaration(NamedTuple): + """What ``@website`` declares: the site's domains, its static folder and seed data file (relative to the class's + file), its health path, and whether its HTML pages read the universe's time.""" + + domains: tuple[str, ...] = () + static: Optional[str] = None + health: str = DEFAULT_HEALTH_PATH + clock_patch: bool = False + data: Optional[str] = None + + +def website(_cls: type | None = None, *, domains: list[str] | tuple[str, ...] = (), static: str | None = None, + health: str = DEFAULT_HEALTH_PATH, clock_patch: bool = False, data: str | None = None) -> Any: + """Declare a WebsiteEnvironment's site. ``data`` names a JSON file the default store starts with.""" + if _cls is not None: + raise TypeError("use @website(...) with parentheses") + if not health.startswith("/") or health == "/" or reserved_path(health): + raise ValueError(f"health is a path of its own under '/', not {health!r}") + + def deco(cls: type) -> type: + if not (isinstance(cls, type) and issubclass(cls, WebsiteEnvironment)): + raise TypeError(f"@website declares a WebsiteEnvironment subclass, not {cls!r}") + setattr(cls, _WEBSITE_ATTR, WebsiteDeclaration(tuple(domains), static, health, clock_patch, data)) + _check_health(cls) + return cls + + return deco + + +def ready_check(fn: Any) -> Any: + """Mark a method that says whether the site is usable yet: the health path answers 200 once every one returns a + truthy value, and 503 while one returns a falsy one or raises.""" + setattr(fn, _READY_ATTR, True) + return fn + + +class WebsiteEnvironment(AgentEnvEnvironment): + """Subclass it, declare the site with ``@website(...)``, mark its API with ``@api``, and ``serve()`` it. A + subclass's ``__init__`` calls ``super().__init__()``. ``app`` may be a Starlette or FastAPI app answering what + ``@api`` and the static folder don't (streaming, uploads); its lifespan is not run.""" + + app: ClassVar[Any] = None + asgi: Any = None + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + routes_of(cls) + own = {getattr(member, _OP_ATTR) for klass in cls.__mro__ for member in vars(klass).values() + if getattr(member, _OP_ATTR, None) is not None and not getattr(member, _DEFAULT_HOOK_ATTR, False)} + for op, name in _DEFAULT_HOOKS.items(): + if op in own: + setattr(cls, name, None) + + def __init__(self) -> None: + declaration = self.declaration() + self.store = Collections() + if declaration.data is not None: + self.store.load(json.loads(self._site_path(declaration.data).read_text(encoding="utf-8"))) + self.store.mark_initial() + self.clock = UniverseClock() + self._signer = SessionSigner() + + @classmethod + def declaration(cls) -> WebsiteDeclaration: + return getattr(cls, _WEBSITE_ATTR, None) or WebsiteDeclaration() + + @classmethod + def routes(cls) -> list[ApiRoute]: + return routes_of(cls) + + @property + def database_url(self) -> str | None: + """Where the site keeps its task state: its own schema of the env's database.""" + return os.environ.get("DATABASE_URL") + + @property + def uses_store(self) -> bool: + """Whether the site's data plane exports ``self.store``: it declares no ``@get_data`` of its own.""" + return type(self)._store_get is not None + + def now(self) -> Any: + """The universe's time, an aware UTC datetime: the gateway's virtual clock once synced, else wall time.""" + return self._clock().now() + + @property + def timezone(self) -> str | None: + """The universe's time zone, an IANA name such as ``America/Chicago``, as the last ``sync_time`` named it; + None when it named none. ``now()`` is UTC either way.""" + return self._clock().timezone + + def acting_user(self) -> str | None: + """Who a browser with no session is signed in as: the store's ``current_user`` unless a site says else.""" + return self.store.current_user if hasattr(self, "store") else None + + def context(self, request: Request) -> ApiContext: + """The ``ApiContext`` of ``request``, for a mounted ``app``'s routes as for ``@api`` ones.""" + session = self._session_signer().decode(request.cookies.get(SESSION_COOKIE)) + user = session["user"] if session is not None else self.acting_user() + return ApiContext(request, user, request_role(request), session, self._session_signer()) + + def create_app(self) -> Starlette: + if self._mounted: + raise RuntimeError(f"{type(self).__name__} is already mounted") + declaration = self.declaration() + _check_health(type(self)) + api_routes = self.routes() + taken = {openapi_path(r.path) for r in api_routes} + root = self._site_path(declaration.static) if declaration.static is not None else None + asgi = Starlette(middleware=[Middleware(_Forwarded), Middleware(_Universe, env=self)]) + AgentEnvStarletteApplication(self._build_card(), self).add_routes_to_app(asgi) + asgi.router.routes.append(Route(declaration.health, self._health, methods=["GET"])) + if SESSION_PATH not in taken: + asgi.router.routes.append(Route(SESSION_PATH, self._session, methods=["GET", "DELETE"])) + if self._serves_state_path(): + asgi.router.routes.append(Route(STATE_PATH, self._state, methods=["GET"])) + asgi.router.routes.extend(Route(r.path, _unreserved(endpoint(getattr(self, r.fn.__name__), r, self.context)), + methods=[r.method]) for r in api_routes) + asgi.router.default = _Fallback(root, type(self).app) + self.asgi = asgi + self._mounted = True + return asgi + + def mount(self, app: Any) -> Any: + raise TypeError(f"{type(self).__name__} builds its own app with create_app(); set app = ... to serve more") + + def serve(self) -> None: + """Serve the site on ``PORT`` (8000 by default) with uvicorn, of the ``agent`` or ``app`` extra.""" + try: + import uvicorn + except ImportError as e: + raise ImportError("WebsiteEnvironment.serve needs uvicorn: pip install 'uvicorn>=0.30'") from e + uvicorn.run(self.asgi or self.create_app(), host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000"))) + + def extension_params(self, uri: str) -> dict: + if uri == WEBSITE_EXTENSION_URI: + declaration = self.declaration() + return {"domains": list(declaration.domains), "health": declaration.health, + "routes": [r.advertised() for r in self.routes()], "session_cookie": SESSION_COOKIE, + "session_path": SESSION_PATH, **({"state": {"path": STATE_PATH}} if self._serves_state_path() else {})} + return super().extension_params(uri) + + def served_extensions(self, extensions: list[EnvironmentExtension]) -> list[EnvironmentExtension]: + """The extensions the base class serves, ``website/v1``'s ``state`` only for a store-backed site: one with data + hooks of its own keeps its state where ``self.store`` can't see it.""" + served = super().served_extensions(extensions) + if self.uses_store: + return served + return [_without_method(e, WEBSITE_STATE_OP) if e.uri == WEBSITE_EXTENSION_URI else e for e in served] + + @extension(CLOCK_EXTENSION_URI, description="Sync this site to the gateway's virtual clock.") + async def sync_time(self, env_get_time_url: str, timezone: Optional[str] = None) -> dict: + """Follow the gateway's virtual time at ``env_get_time_url`` from now on, in ``timezone`` when the task names + one.""" + virtual = await self._clock().sync(env_get_time_url, timezone.strip() if timezone else None) + return {"env_get_time_url": env_get_time_url, "gateway_virtual_time": iso(virtual), + **({"timezone": self.timezone} if self.timezone else {})} + + @extension(WEBSITE_EXTENSION_URI, op="openapi", method="GET", description=( + "A site's server: domains, its health path and its @api routes by name; openapi returns the OpenAPI document " + "of its routes, ready whether the site is usable now and why not, state the task state it is in (its " + "initial and current state and their diff), and session the cookies a browser adds to start signed in as user, " + "or signed out without one.")) + async def _website_openapi(self) -> dict: + name = self._application.environment_card.name if self._application is not None else type(self).__name__ + return openapi(name, self.routes()) + + @extension(WEBSITE_EXTENSION_URI, op=WEBSITE_READY_OP, method="GET") + async def _website_ready(self) -> dict: + """Whether the site is usable now, a ``WebsiteReadiness`` by its ``@ready_check`` methods: ``problems`` names + each not yet true. A deploy reads it through the card, as ``computer-app/v1``'s ``ready`` of an app.""" + problems = await self._not_ready() + return WebsiteReadiness(ready=not problems, problems=problems).model_dump() + + @extension(WEBSITE_EXTENSION_URI, op=WEBSITE_STATE_OP, method="GET") + async def _website_state(self, sid: Optional[str] = None) -> dict: + """The site's task state, a ``WebsiteState`` of ``self.store``: its state when the task started, now, and what + changed between them. A store-backed site keeps one session, so ``sid`` names no other.""" + return WebsiteState.model_validate(self.store.state()).model_dump() + + @extension(WEBSITE_EXTENSION_URI, op="session") + async def _website_session(self, user: Optional[str] = None) -> dict: + cookie = self._session_signer().cookie(user) + return {"user": user, "cookies": [cookie], "expires_at": expires_at(cookie["expires"])} + + @reset_data + async def _store_reset(self) -> None: + self.store.reset() + + @add_data + async def _store_add(self, parts: list) -> None: + self.store.load_parts(parts) + self.store.mark_initial() + + @get_data + async def _store_get(self) -> list: + return [DataPart(data=self.store.export())] + + async def _not_ready(self) -> list[str]: + return [check.__name__ for check, _marker in _marked_members(self, _READY_ATTR) if not await _ready(check)] + + async def _health(self, request: Request) -> Response: + waiting = await self._not_ready() + if waiting: + return JSONResponse({"status": "starting", "waiting_for": waiting}, status_code=503) + return JSONResponse({"status": "ok"}) + + async def _session(self, request: Request) -> Response: + context = self.context(request) + if request.method == "DELETE": + context.sign_out() + elif context.session is None and context.user is not None: + context.sign_in(context.user) + return context.apply(JSONResponse({"user": context.user, "role": context.role, + "signed_in": context.user is not None})) + + def _serves_state_path(self) -> bool: + """Whether the site serves its state at ``STATE_PATH``: it is store-backed and no ``@api`` route takes the path.""" + return self.uses_store and STATE_PATH not in {openapi_path(r.path) for r in self.routes()} + + async def _state(self, request: Request) -> Response: + return JSONResponse(self.store.state()) + + def _clock(self) -> UniverseClock: + if not hasattr(self, "clock"): + raise TypeError(f"{type(self).__qualname__}.__init__ must call super().__init__()") + return self.clock + + def _session_signer(self) -> SessionSigner: + if not hasattr(self, "_signer"): + raise TypeError(f"{type(self).__qualname__}.__init__ must call super().__init__()") + return self._signer + + @classmethod + def _site_path(cls, path: str) -> Path: + given = Path(path) + return given if given.is_absolute() else Path(inspect.getfile(cls)).resolve().parent / given + + +for _hook in _DEFAULT_HOOKS.values(): + setattr(getattr(WebsiteEnvironment, _hook), _DEFAULT_HOOK_ATTR, True) + + +def _without_method(extension: EnvironmentExtension, method: str) -> EnvironmentExtension: + params = dict(extension.params or {}) + params["methods"] = {name: entry for name, entry in (params.get("methods") or {}).items() if name != method} + return extension.model_copy(update={"params": params}) + + +async def _ready(check: Any) -> bool: + try: + return bool(await _maybe_await(check())) + except Exception: + return False + + +def _check_health(cls: type) -> None: + health = cls.declaration().health + clash = [r.name for r in routes_of(cls) if r.method == "GET" and openapi_path(r.path) == health] + if clash: + raise ValueError(f"{cls.__name__}'s health path {health} is served by @api {clash[0]}; use @ready_check") + + +class _Forwarded: + """Puts the address the browser reached the site at in the scope, from ``X-Forwarded-Host`` and + ``X-Forwarded-Proto``, before anything routes it, so redirects, ``request.url`` and a mounted app's ``url_for`` + name that address and not the internal one the proxy reached the server at.""" + + def __init__(self, app: Any) -> None: + self.app = app + + async def __call__(self, scope: dict, receive: Any, send: Any) -> None: + if scope["type"] in ("http", "websocket"): + headers = dict(scope["headers"]) + host = _first(headers.get(b"x-forwarded-host")) + proto = _first(headers.get(b"x-forwarded-proto")).lower() + scope = dict(scope) + if host: + scope["headers"] = [(k, v) for k, v in scope["headers"] if k != b"host"] + [(b"host", host.encode())] + if proto in ("http", "https"): + scope["scheme"] = proto if scope["type"] == "http" else {"http": "ws", "https": "wss"}[proto] + await self.app(scope, receive, send) + + +def _first(value: bytes | None) -> str: + return value.decode("latin-1").split(",")[0].strip() if value else "" + + +class _Universe: + """Stamps ``X-Universe-Now`` on every answer, and with ``clock_patch`` puts the clock script in its HTML.""" + + def __init__(self, app: Any, env: WebsiteEnvironment) -> None: + self.app, self.env = app, env + + async def __call__(self, scope: dict, receive: Any, send: Any) -> None: + if scope["type"] != "http": + return await self.app(scope, receive, send) + clock = self.env._clock() + await clock.refresh() + patch = self.env.declaration().clock_patch and scope["method"] != "HEAD" + held: dict = {} + + async def stamped(message: dict) -> None: + if message["type"] == "http.response.start": + headers = MutableHeaders(scope=message) + now = clock.now() + headers[UNIVERSE_NOW_HEADER] = iso(now) + if patch and headers.get("content-type", "").startswith("text/html") and "content-encoding" not in headers: + held.update(start=message, body=[], now=now) + return + elif held and message["type"] == "http.response.body": + held["body"].append(message.get("body", b"")) + if message.get("more_body"): + return + body = _with_script(b"".join(held["body"]), clock_patch_script(held["now"])) + MutableHeaders(scope=held["start"])["content-length"] = str(len(body)) + await send(held["start"]) + message = {"type": "http.response.body", "body": body} + await send(message) + + await self.app(scope, receive, stamped) + + +def _with_script(html: bytes, script: bytes) -> bytes: + head = _HEAD.search(html) + return html[:head.end()] + script + html[head.end():] if head else script + html + + +def _unreserved(handler: Any) -> Any: + """``handler``, an ``@api`` route's endpoint, answering 404 at the data plane's and card's paths, so a broad route + (``/{rest:path}``) never serves a page there; the routes agentenv registers at those paths answer before it.""" + async def answer(request: Request) -> Response: + if reserved_path(request.scope["path"]): + return error_response(NotFound("not found")) + return await handler(request) + return answer + + +class _Fallback: + """What no route answers: a file of the static folder, then the mounted app, then for a page's path the folder's + ``index.html``; the data plane's and card's paths answer 404 however they're spelled.""" + + def __init__(self, root: Path | None, app: Any) -> None: + self.root = root.resolve() if root is not None else None + self.app = app + + async def __call__(self, scope: dict, receive: Any, send: Any) -> None: + if scope["type"] != "http": + if self.app is not None: + await self.app(scope, receive, send) + return + path, reading = scope["path"], scope["method"] in ("GET", "HEAD") + if reserved_path(path): + return await error_response(NotFound("not found"))(scope, receive, send) + if reading and (file := self._file(path)) is not None: + return await FileResponse(file)(scope, receive, send) + index = self._file("/") if reading and _page(path) else None + if self.app is not None: + return await self._app_or(index, scope, receive, send) + if index is not None: + return await FileResponse(index)(scope, receive, send) + await error_response(NotFound("not found"))(scope, receive, send) + + def _file(self, path: str) -> Path | None: + if self.root is None: + return None + candidate = (self.root / path.lstrip("/")).resolve() + if candidate.is_dir(): + candidate = (candidate / "index.html").resolve() + if not candidate.is_relative_to(self.root): + return None + return candidate if candidate.is_file() else None + + async def _app_or(self, index: Path | None, scope: dict, receive: Any, send: Any) -> None: + """The mounted app's answer, or ``index`` instead of the app's 404 when there is one.""" + missed = False + + async def unless_missed(message: dict) -> None: + nonlocal missed + if message["type"] == "http.response.start" and message["status"] == 404 and index is not None: + missed = True + if not missed: + await send(message) + + await self.app(scope, receive, unless_missed) + if missed: + await FileResponse(index)(scope, receive, send) + + +def _page(path: str) -> bool: + """Whether ``path`` is a page a single-page app routes itself: not the API's, and not a file's.""" + return not (path == "/api" or path.startswith("/api/")) and "." not in path.rsplit("/", 1)[-1] diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/errors.py b/packages/agentenv-protocol/src/agentenv_protocol/website/errors.py new file mode 100644 index 000000000..13e349b88 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/errors.py @@ -0,0 +1,39 @@ +"""Errors an ``@api`` method raises to answer with an HTTP status: the body is ``{"error": message, "code": code}``.""" +from __future__ import annotations + + +class ApiError(Exception): + """An answer other than success, ``status`` with ``{"error": message, "code": code}``; a subclass sets both + defaults, and either may be given per raise.""" + + status: int = 500 + code: str = "error" + + def __init__(self, message: str, *, code: str | None = None, status: int | None = None) -> None: + super().__init__(message) + self.message = message + self.code = code or type(self).code + self.status = status or type(self).status + + def body(self) -> dict: + return {"error": self.message, "code": self.code} + + +class BadRequest(ApiError): + status, code = 400, "bad_request" + + +class Unauthorized(ApiError): + status, code = 401, "unauthorized" + + +class Forbidden(ApiError): + status, code = 403, "forbidden" + + +class NotFound(ApiError): + status, code = 404, "not_found" + + +class Conflict(ApiError): + status, code = 409, "conflict" diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/readiness.py b/packages/agentenv-protocol/src/agentenv_protocol/website/readiness.py new file mode 100644 index 000000000..958f828e7 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/readiness.py @@ -0,0 +1,73 @@ +"""How a website env's health is determined, the one definition everything that judges it reads: a site says whether +it is usable now through its card's ``urn:agentenv:website/v1`` ``ready`` (``WEBSITE_READY_OP``), answering a +``WebsiteReadiness``. ``website_readiness`` asks one site as its card advertises it; ``env_readiness`` asks every site +of an env, a composed card's children or a single site's own card, and answers for the env as a whole; asking one +site may take ``READY_TIMEOUT_S`` by default.""" +from __future__ import annotations + +import asyncio + +import httpx +from pydantic import BaseModel, ConfigDict + +from ..client import find_extension_method, invoke_extension + +WEBSITE_EXTENSION_URI = "urn:agentenv:website/v1" +WEBSITE_READY_OP = "ready" +READY_TIMEOUT_S = 10.0 + + +class WebsiteReadiness(BaseModel): + """What a site says of its readiness, ``website/v1``'s ``ready``: whether it is ``ready``, and the ``problems`` + that keep it from being so, each a name a person reads, such as a ``@ready_check`` method's. Fields it doesn't + name are ignored rather than refused, so a reader keeps reading a newer site that says more, as ``ready`` and + ``problems`` keep their meaning. Its types are strict, as JSON's are: ``"yes"`` or ``1`` is no ``ready``.""" + + model_config = ConfigDict(extra="ignore", strict=True) + + ready: bool + problems: list[str] = [] + + +def _advertises_ready(card: dict) -> bool: + return find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP) is not None + + +async def website_readiness(base_url: str, card: dict, *, timeout: float = READY_TIMEOUT_S, + headers: dict[str, str] | None = None) -> WebsiteReadiness | None: + """What the site whose ``card`` is read from ``base_url`` says of its readiness, None when the card doesn't + advertise ``website/v1``'s ``ready``. Raises httpx's error when it can't be asked, ``ExtensionCallError`` for a + non-2xx answer, and ``ValueError`` (pydantic's ``ValidationError`` among them) for an answer that isn't a + ``WebsiteReadiness``.""" + if not _advertises_ready(card): + return None + answer = await invoke_extension(base_url, card, WEBSITE_EXTENSION_URI, timeout=timeout, method=WEBSITE_READY_OP, + headers=headers) + return WebsiteReadiness.model_validate(answer) + + +async def env_readiness(base_url: str, card: dict, *, timeout: float = READY_TIMEOUT_S, + headers: dict[str, str] | None = None) -> WebsiteReadiness: + """What the env whose ``card`` is read from ``base_url`` says of its readiness, from every site it has, asked at + once: the card itself when it advertises ``website/v1``'s ``ready``, as a single site's does, its problems as the + site names them, and each child under a composed card's ``children_environments`` that advertises it, its endpoints + rooted at ``base_url`` as a gateway's are, its problems prefixed with its name. A site that can't be asked, or + answers something that isn't a ``WebsiteReadiness``, is a problem of its own, and one not ready that names no + problem is one too. A child, or a card, that doesn't advertise ``ready`` isn't counted, so an env with no site + serving it, as one of MCP servers alone, is ready with no problems.""" + sites = [("", card)] if _advertises_ready(card) else [] + sites += [(f"{child.get('name') or 'child'}: ", child) for child in card.get("children_environments") or [] + if isinstance(child, dict) and _advertises_ready(child)] + answers = await asyncio.gather(*(_problems(base_url, site, timeout, headers) for _prefix, site in sites)) + problems = [prefix + problem for (prefix, _site), found in zip(sites, answers) for problem in found] + return WebsiteReadiness(ready=not problems, problems=problems) + + +async def _problems(base_url: str, card: dict, timeout: float, headers: dict[str, str] | None) -> list[str]: + try: + readiness = await website_readiness(base_url, card, timeout=timeout, headers=headers) + except (httpx.HTTPError, ValueError) as e: + return [f"{WEBSITE_READY_OP} could not be read: {type(e).__name__}: {e}"] + if readiness.ready: + return [] + return readiness.problems or [f"{WEBSITE_READY_OP} says not ready"] diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py b/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py new file mode 100644 index 000000000..221f5e1b2 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py @@ -0,0 +1,369 @@ +"""A site's API: ``@api.get/post/put/patch/delete(path)`` methods, each bound from its type hints. A parameter named in +the path is a path parameter, one annotated with a pydantic model or ``dict`` is the JSON body, one annotated +``ApiContext`` receives the request's context, and every other one is a query parameter; path and query values are +coerced to their annotated types (int, float, bool, datetime, UUID, enum, Literal). What the method returns is the +JSON answer, a Starlette ``Response`` as it is; an ``ApiError`` it raises answers with its status, and a request its +parameters can't be bound from answers 400.""" +from __future__ import annotations + +import dataclasses +import datetime +import hmac +import inspect +import json +import logging +import os +import posixpath +import re +from typing import Any, Callable, Literal, NamedTuple, Optional, get_args, get_origin, get_type_hints + +from pydantic import BaseModel, TypeAdapter, ValidationError +from pydantic_core import to_jsonable_python +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from ..agent_env_environment import _coerce_scalar, _maybe_await, _schema_for_annotation, _unwrap_annotation +from ..tool_context import DEFAULT_ROLE, caller_from_request +from ..types import RPC_PATH, WELL_KNOWN_PATH +from .errors import ApiError, BadRequest, Forbidden +from .session import SESSION_COOKIE, SessionSigner + +logger = logging.getLogger(__name__) + +_ROUTE_ATTR = "_aee_api_route" +_PATH_PARAM = re.compile(r"{(\w+)(?::\w+)?}") +Kind = Literal["path", "query", "body", "context"] + + +def reserved_path(path: str) -> bool: + """Whether ``path`` is the data plane's or the card's, however it's spelled, as the gateway decides it.""" + normalized = posixpath.normpath("/" + path.lstrip("/")) + return normalized == RPC_PATH or normalized.startswith(RPC_PATH + "/") or normalized == WELL_KNOWN_PATH + + +class ApiRoute(NamedTuple): + """One ``@api`` method: its verb, its Starlette path, its name on the card and in the OpenAPI document, the roles + it answers (any when empty), the status it succeeds with, and its function.""" + + method: str + path: str + name: str + roles: tuple[str, ...] + status: int + fn: Callable + + def advertised(self) -> dict: + return {"method": self.method, "path": openapi_path(self.path), "name": self.name, + "roles": list(self.roles) or None} + + +class _Api: + """``api.get(path, name=..., roles=[...])`` and its siblings mark a method as one route of the site's API.""" + + def get(self, path: str, **options: Any) -> Callable: + return self.route("GET", path, **options) + + def post(self, path: str, **options: Any) -> Callable: + return self.route("POST", path, **options) + + def put(self, path: str, **options: Any) -> Callable: + return self.route("PUT", path, **options) + + def patch(self, path: str, **options: Any) -> Callable: + return self.route("PATCH", path, **options) + + def delete(self, path: str, **options: Any) -> Callable: + return self.route("DELETE", path, **options) + + def route(self, method: str, path: str, *, name: str | None = None, roles: list[str] | None = None, + status: int = 200) -> Callable: + if not path.startswith("/"): + raise ValueError(f"an @api path starts with '/', not {path!r}") + if reserved_path(path): + raise ValueError(f"@api path {path!r} is under the data plane's {RPC_PATH} or the card's {WELL_KNOWN_PATH}") + + def mark(fn: Callable) -> Callable: + missing = sorted(set(_PATH_PARAM.findall(path)) - set(inspect.signature(fn).parameters)) + if missing: + raise TypeError(f"{fn.__qualname__} takes no parameter for the path's {missing}") + setattr(fn, _ROUTE_ATTR, ApiRoute(method.upper(), path, name or fn.__name__, tuple(roles or ()), status, fn)) + return fn + + return mark + + +api = _Api() + + +def routes_of(cls: type) -> list[ApiRoute]: + """The class's ``@api`` routes in the order they were first defined, each as the class resolves its name; + a name or a verb and path served twice is refused.""" + names = dict.fromkeys(name for klass in reversed(cls.__mro__) for name in vars(klass)) + routes = [route for name in names if (route := getattr(getattr(cls, name, None), _ROUTE_ATTR, None)) is not None] + for label, keys in (("name", [r.name for r in routes]), ("route", [f"{r.method} {r.path}" for r in routes])): + if twice := sorted({k for k in keys if keys.count(k) > 1}): + raise ValueError(f"{cls.__name__} serves the @api {label} {twice} twice") + return routes + + +def openapi_path(path: str) -> str: + return _PATH_PARAM.sub(r"{\1}", path) + + +@dataclasses.dataclass +class ApiContext: + """The request an ``@api`` method answers: its headers, cookies and query, the user it's signed in as (the + session cookie's, else the site's acting user), the caller's role (``request_role``), and the site's own address as the browser reached it. ``sign_in`` and ``sign_out`` set the + session cookie on the answer.""" + + request: Request + user: Optional[str] + role: str + session: Optional[dict] + _signer: SessionSigner = dataclasses.field(repr=False, default_factory=SessionSigner) + _set_cookie: list = dataclasses.field(repr=False, default_factory=list) + + @property + def headers(self) -> Any: + return self.request.headers + + @property + def cookies(self) -> dict[str, str]: + return self.request.cookies + + @property + def query(self) -> Any: + return self.request.query_params + + @property + def base_url(self) -> str: + return base_url(self.request) + + def sign_in(self, user: str) -> None: + self._set_session(user) + + def sign_out(self) -> None: + self._set_session(None) + + def _set_session(self, user: str | None) -> None: + value, expires = self._signer.encode(user) + self._set_cookie.append((value, expires)) + self.user, self.session = user, {"user": user} + + def apply(self, response: Response) -> Response: + for value, expires in self._set_cookie: + at = datetime.datetime.fromtimestamp(expires, datetime.timezone.utc) + response.set_cookie(SESSION_COOKIE, value, expires=at, path="/", httponly=True, samesite="lax") + return response + + +def base_url(request: Request) -> str: + """The site's address as the browser reached it: ``X-Forwarded-Proto`` and ``X-Forwarded-Host`` when a proxy + sent them, the request's own scheme and ``Host`` otherwise.""" + proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip() or request.url.scheme + host = (request.headers.get("x-forwarded-host", "").split(",")[0].strip() or request.headers.get("host") + or request.url.netloc) + return f"{proto}://{host}" + + +ROLE_KEY_ENV = "WEBSITE_ROLE_KEY" +ROLE_KEY_HEADER = "AgentEnv-Role-Key" + + +def request_role(request: Request) -> str: + """The role ``AgentEnv-Role`` names, believed only from a caller that also sends ``AgentEnv-Role-Key`` equal to + ``WEBSITE_ROLE_KEY``; every other caller, and every caller of a site with no key set, is ``default``. The gateway + forwards whatever role a browser sends, so without the key a page could claim any role.""" + claimed = caller_from_request(None, request.headers).role or DEFAULT_ROLE + key = os.environ.get(ROLE_KEY_ENV, "") + if claimed == DEFAULT_ROLE or not key: + return DEFAULT_ROLE + given = request.headers.get(ROLE_KEY_HEADER, "") + return claimed if hmac.compare_digest(given.encode(), key.encode()) else DEFAULT_ROLE + + +class _Param(NamedTuple): + name: str + kind: Kind + annotation: Any + default: Any + + +def plan(route: ApiRoute) -> list[_Param]: + """How each of the route's parameters is bound; at most one is the JSON body, and never on a GET.""" + hints = _hints(route.fn) + path_params = set(_PATH_PARAM.findall(route.path)) + params = [] + for name, param in list(inspect.signature(route.fn).parameters.items())[1:]: + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + annotation = hints.get(name, param.annotation) + kind: Kind = ("context" if _is_context(annotation) else "path" if name in path_params + else "body" if _body_type(annotation) is not None else "query") + params.append(_Param(name, kind, annotation, param.default)) + bodies = [p.name for p in params if p.kind == "body"] + if len(bodies) > 1 or (bodies and route.method == "GET"): + raise TypeError(f"{route.fn.__qualname__}: a route takes at most one JSON body and a GET route none, " + f"not {bodies}") + return params + + +def _is_context(annotation: Any) -> bool: + if isinstance(annotation, str): + return annotation.strip("'\"").rsplit(".", 1)[-1] in ("ApiContext", "Optional[ApiContext]") + return any(t is ApiContext for t in _unwrap_annotation(annotation)) + + +def _body_type(annotation: Any) -> type | None: + for t in _unwrap_annotation(annotation): + base = get_origin(t) or t + if base is dict or (isinstance(base, type) and issubclass(base, BaseModel)): + return t + return None + + +async def bind(params: list[_Param], request: Request, context: ApiContext) -> dict: + """The method's arguments from ``request``; raises BadRequest for one that is missing or doesn't fit.""" + kwargs: dict = {} + for p in params: + if p.kind == "context": + kwargs[p.name] = context + elif p.kind == "body": + kwargs[p.name] = await _body(p, request) + else: + source = request.path_params if p.kind == "path" else request.query_params + if p.name not in source: + if p.default is inspect.Parameter.empty: + raise BadRequest(f"missing {p.kind} parameter {p.name!r}", code="invalid_params") + kwargs[p.name] = p.default + continue + many = _list_type(p.annotation) if p.kind == "query" else None + try: + kwargs[p.name] = ([_coerce_scalar(v, get_args(many)[0] if get_args(many) else None) + for v in source.getlist(p.name)] if many is not None + else _coerce_scalar(source[p.name], p.annotation)) + except (TypeError, ValueError) as e: + raise BadRequest(f"{p.kind} parameter {p.name!r}: {e}", code="invalid_params") from None + return kwargs + + +def _list_type(annotation: Any) -> Any: + """The ``list[T]`` an annotation names, Optional or Annotated or not; None when it names no list.""" + inner = _unwrap_annotation(annotation) + return inner[0] if len(inner) == 1 and (inner[0] is list or get_origin(inner[0]) is list) else None + + +async def _body(p: _Param, request: Request) -> Any: + raw = await request.body() + if not raw: + if p.default is not inspect.Parameter.empty: + return p.default + raise BadRequest("this route takes a JSON body", code="invalid_params") + try: + value = json.loads(raw) + except ValueError: + raise BadRequest("the request body is not JSON", code="invalid_params") from None + try: + return TypeAdapter(p.annotation).validate_python(value) + except ValidationError as e: + problems = "; ".join(f"{'.'.join(map(str, err['loc'])) or 'body'}: {err['msg']}" for err in e.errors()) + raise BadRequest(f"invalid body: {problems}", code="invalid_params") from None + + +def endpoint(fn: Callable, route: ApiRoute, context_for: Callable[[Request], ApiContext]) -> Callable: + """The Starlette endpoint of one route, calling the bound method ``fn``.""" + params = plan(route) + + async def handle(request: Request) -> Response: + context = None + try: + context = context_for(request) + if route.roles and context.role not in route.roles: + raise Forbidden(f"role {context.role!r} may not call {route.name}") + result = await _maybe_await(fn(**await bind(params, request, context))) + if isinstance(result, Response): + response = result + elif not _has_body(route.status): + response = Response(status_code=route.status) + else: + response = JSONResponse(to_jsonable_python(result), status_code=route.status) + except ApiError as e: + response = error_response(e) + except Exception: + logger.exception("@api %s failed", route.name) + response = error_response(ApiError("internal error", code="internal")) + return context.apply(response) if context is not None else response + + return handle + + +def _has_body(status: int) -> bool: + """Whether an answer with ``status`` may carry a body: not a 1xx, 204 or 304.""" + return status >= 200 and status not in (204, 304) + + +def error_response(error: ApiError) -> Response: + return JSONResponse(error.body(), status_code=error.status) + + +def openapi(title: str, routes: list[ApiRoute]) -> dict: + """The OpenAPI 3.1 document of ``routes``, each operation's id its route's name; the models their bodies and + answers name, nested ones too, are its ``components.schemas``, which their schemas reference. Bodies are + described as they are validated and answers as they are serialized; a model the two modes see differently is in + ``components.schemas`` twice, as ``-Input`` and ``-Output``.""" + paths: dict = {} + schemas: list = [] + for route in routes: + doc = inspect.getdoc(route.fn) or "" + operation: dict = {"operationId": route.name, "parameters": [], + "responses": {str(route.status): {"description": "Success"}}} + if doc: + operation["summary"], operation["description"] = doc.splitlines()[0], doc + if route.roles: + operation["x-agentenv-roles"] = list(route.roles) + for p in plan(route): + if p.kind in ("path", "query"): + schema = _schema_for_annotation(p.annotation) + if p.default is not inspect.Parameter.empty: + schema["default"] = to_jsonable_python(p.default) + operation["parameters"].append({"name": p.name, "in": p.kind, "schema": schema, + "required": p.kind == "path" or p.default is inspect.Parameter.empty}) + elif p.kind == "body": + media: dict = {} + operation["requestBody"] = {"required": p.default is inspect.Parameter.empty, + "content": {"application/json": media}} + schemas.append((media, p.annotation, "validation")) + returns = _hints(route.fn).get("return") + if _has_body(route.status) and _adapter(returns) is not None: + media = {} + operation["responses"][str(route.status)]["content"] = {"application/json": media} + schemas.append((media, returns, "serialization")) + paths.setdefault(openapi_path(route.path), {})[route.method.lower()] = operation + document: dict = {"openapi": "3.1.0", "info": {"title": title, "version": "1.0"}, "paths": paths} + adapters = [(i, mode, adapter) for i, (_media, annotation, mode) in enumerate(schemas) + if (adapter := _adapter(annotation)) is not None] + found, definitions = TypeAdapter.json_schemas(adapters, ref_template="#/components/schemas/{model}") + for i, (media, _annotation, mode) in enumerate(schemas): + media["schema"] = found.get((i, mode), {}) + if definitions.get("$defs"): + document["components"] = {"schemas": definitions["$defs"]} + return document + + +def _hints(fn: Callable) -> dict: + try: + return get_type_hints(fn) + except Exception: + return {} + + +def _adapter(annotation: Any) -> TypeAdapter | None: + """The adapter of a type a JSON schema can describe; None for no type, None itself, or one it can't describe.""" + if annotation is None or annotation is type(None) or annotation is inspect.Parameter.empty: + return None + try: + adapter = TypeAdapter(annotation) + adapter.json_schema() + except Exception: + return None + return adapter diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/session.py b/packages/agentenv-protocol/src/agentenv_protocol/website/session.py new file mode 100644 index 000000000..20e4ad36f --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/session.py @@ -0,0 +1,60 @@ +"""A site's signed session cookie: who the browser is signed in as, or that it signed out, signed with HMAC-SHA256 +under ``WEBSITE_SESSION_SECRET``, or a secret of the process's own when that isn't set. The cookie names no domain, +so it holds under whatever host the site is reached at.""" +from __future__ import annotations + +import base64 +import binascii +import datetime +import hashlib +import hmac +import json +import os +import secrets +import time + +SESSION_COOKIE = "agentenv_session" +SECRET_ENV = "WEBSITE_SESSION_SECRET" +SESSION_TTL_S = 7 * 24 * 3600 +SECRET_BYTES = 32 + + +class SessionSigner: + def __init__(self, secret: str | bytes | None = None) -> None: + secret = secret if secret is not None else os.environ.get(SECRET_ENV) + self._key = (secret.encode() if isinstance(secret, str) else secret) or secrets.token_bytes(SECRET_BYTES) + + def encode(self, user: str | None, ttl_s: int = SESSION_TTL_S) -> tuple[str, int]: + """The cookie value for ``user`` signed in, or signed out when None, and when it expires, in epoch + seconds.""" + expires = int(time.time()) + ttl_s + payload = base64.urlsafe_b64encode(json.dumps({"u": user, "exp": expires}).encode()).decode().rstrip("=") + return f"{payload}.{self._sign(payload)}", expires + + def decode(self, value: str | None) -> dict | None: + """The session a cookie value carries, ``{"user": ...}``; None for one that is absent, forged or expired.""" + payload, _, signature = (value or "").partition(".") + if not payload or not hmac.compare_digest(signature.encode("utf-8", "surrogateescape"), + self._sign(payload).encode()): + return None + try: + data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) + except (ValueError, binascii.Error): + return None + if not isinstance(data, dict) or not isinstance(data.get("exp"), int) or data["exp"] < time.time(): + return None + return {"user": data.get("u")} + + def cookie(self, user: str | None, ttl_s: int = SESSION_TTL_S) -> dict: + """The cookie a browser sets to be signed in as ``user``, as a browser automation adds one; its domain is + left to whoever adds it.""" + value, expires = self.encode(user, ttl_s) + return {"name": SESSION_COOKIE, "value": value, "path": "/", "expires": expires, "httpOnly": True, + "sameSite": "Lax"} + + def _sign(self, payload: str) -> str: + return hmac.new(self._key, payload.encode("utf-8", "surrogateescape"), hashlib.sha256).hexdigest() + + +def expires_at(epoch: int) -> str: + return datetime.datetime.fromtimestamp(epoch, datetime.timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/state.py b/packages/agentenv-protocol/src/agentenv_protocol/website/state.py new file mode 100644 index 000000000..77e0b5558 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/state.py @@ -0,0 +1,56 @@ +"""What a website env's task state is, the one definition everything that grades a site reads: a site gives the state a +task left it in through its card's ``urn:agentenv:website/v1`` ``state`` (``WEBSITE_STATE_OP``), answering a +``WebsiteState``, its state when the task started, its state now and what changed between them. ``website_state`` +asks one site as its card advertises it, of one session when the site keeps several; ``website_sites`` names every +site of an env, a single site's own card or a composed card's website children. The op is one of agentenv's own +routes, under the data plane's path, so a browser on the site's route never reads what grades it.""" +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict + +from ..client import ChildEnv, children, find_extension, find_extension_method, invoke_extension +from .readiness import WEBSITE_EXTENSION_URI + +WEBSITE_STATE_OP = "state" +STATE_TIMEOUT_S = 30.0 + + +class WebsiteState(BaseModel): + """What a site says of its task state, ``website/v1``'s ``state``: ``initial_state``, the state the task started + from, ``current_state``, the state now, and ``state_diff``, what changed between them, each a JSON object or None + when the site gives none. Fields it doesn't name are ignored rather than refused, so a reader keeps reading a newer + site that says more.""" + + model_config = ConfigDict(extra="ignore") + + initial_state: Optional[dict[str, Any]] = None + current_state: Optional[dict[str, Any]] = None + state_diff: Optional[dict[str, Any]] = None + + +def advertises_state(card: dict) -> bool: + """Whether ``card`` advertises ``website/v1``'s ``state``.""" + return find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_STATE_OP) is not None + + +def website_sites(card: dict) -> list[ChildEnv]: + """Every site of the env whose card is ``card``: the card itself when it carries ``website/v1``, as a single site's + does, else each child of a composed card that carries it, in its order; none for an env with no site.""" + if find_extension(card, WEBSITE_EXTENSION_URI) is not None: + return [ChildEnv(name=card.get("name"), url=card.get("url"), card=card)] + return [child for child in children(card) if child.find_extension(WEBSITE_EXTENSION_URI) is not None] + + +async def website_state(base_url: str, card: dict, *, sid: str | None = None, timeout: float = STATE_TIMEOUT_S, + headers: dict[str, str] | None = None) -> WebsiteState | None: + """What the site whose ``card`` is read from ``base_url`` says of its task state, of session ``sid`` when it's + given, for a site that keeps several; None when the card doesn't advertise ``website/v1``'s ``state``. Raises + httpx's error when it can't be asked, ``ExtensionCallError`` for a non-2xx answer, and ``ValueError`` (pydantic's + ``ValidationError`` among them) for an answer that isn't a ``WebsiteState``.""" + if not advertises_state(card): + return None + answer = await invoke_extension(base_url, card, WEBSITE_EXTENSION_URI, {"sid": sid} if sid is not None else None, + timeout=timeout, method=WEBSITE_STATE_OP, headers=headers) + return WebsiteState.model_validate(answer) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/store.py b/packages/agentenv-protocol/src/agentenv_protocol/website/store.py new file mode 100644 index 000000000..ce21ad84c --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/store.py @@ -0,0 +1,134 @@ +"""The default state store of a simple site: named collections of JSON objects, each with an ``id``. Its data is a JSON +object of collections by name, beside which a string ``current_user`` names the user a browser starts signed in as. +It remembers the state it was last loaded with, so a verifier can read the initial state, the current one and the +difference between them.""" +from __future__ import annotations + +import base64 +import copy +import json +from pathlib import Path +from typing import Any, Iterable +from urllib.parse import unquote, urlparse + +from ..types import DataPart, FilePart, Part, TextPart + +CURRENT_USER = "current_user" + + +class Collections: + """Named lists of JSON objects; ``store["orders"]`` is the list itself, created empty on first use.""" + + def __init__(self, data: dict | None = None) -> None: + self._collections: dict[str, list[dict]] = {} + self.current_user: str | None = None + self.initial: dict = {} + if data: + self.load(data) + self.mark_initial() + + def __getitem__(self, name: str) -> list[dict]: + return self._collections.setdefault(name, []) + + def names(self) -> list[str]: + return list(self._collections) + + def get(self, name: str, id: Any) -> dict | None: + return next((doc for doc in self[name] if doc.get("id") == id), None) + + def find(self, name: str, **match: Any) -> list[dict]: + return [doc for doc in self[name] if all(doc.get(k) == v for k, v in match.items())] + + def insert(self, name: str, doc: dict) -> dict: + """Add ``doc``, giving it the next integer ``id`` of its collection when it has none.""" + doc = dict(doc) + if doc.get("id") is None: + doc["id"] = 1 + max((d["id"] for d in self[name] if isinstance(d.get("id"), int)), default=0) + self[name].append(doc) + return doc + + def update(self, name: str, id: Any, **fields: Any) -> dict | None: + doc = self.get(name, id) + if doc is not None: + doc.update(fields) + return doc + + def delete(self, name: str, id: Any) -> bool: + doc = self.get(name, id) + if doc is not None: + self[name].remove(doc) + return doc is not None + + def load(self, data: dict) -> None: + """Add ``data``'s collections to the store's, and take its ``current_user`` when it names one.""" + if not isinstance(data, dict): + raise ValueError("a store's data is a JSON object of collections by name") + for name, docs in data.items(): + if name == CURRENT_USER: + self.current_user = docs + elif isinstance(docs, list) and all(isinstance(d, dict) for d in docs): + self[name].extend(copy.deepcopy(docs)) + else: + raise ValueError(f"collection {name!r} is not a list of JSON objects") + + def load_parts(self, parts: Iterable[Part]) -> None: + for part in parts: + self.load(_part_data(part)) + + def reset(self) -> None: + self._collections.clear() + self.current_user = None + self.initial = {} + + def export(self) -> dict: + data: dict = copy.deepcopy(self._collections) + if self.current_user is not None: + data[CURRENT_USER] = self.current_user + return data + + def mark_initial(self) -> None: + self.initial = self.export() + + def diff(self) -> dict: + """Each collection that changed since the initial state, as its ``added``, ``removed`` and ``changed`` + objects, matched by ``id``; a changed one as its ``id``, ``before`` and ``after``.""" + current = self.export() + changes: dict = {} + for name in dict.fromkeys([*self.initial, *current]): + if name == CURRENT_USER: + continue + before = {_key(d): d for d in self.initial.get(name, [])} + after = {_key(d): d for d in current.get(name, [])} + entry = { + "added": [d for k, d in after.items() if k not in before], + "removed": [d for k, d in before.items() if k not in after], + "changed": [{"id": d.get("id"), "before": before[k], "after": d} + for k, d in after.items() if k in before and before[k] != d], + } + if any(entry.values()): + changes[name] = entry + return changes + + def state(self) -> dict: + return {"initial_state": copy.deepcopy(self.initial), "current_state": self.export(), "state_diff": self.diff()} + + +def _key(doc: dict) -> str: + return json.dumps(doc.get("id")) if doc.get("id") is not None else json.dumps(doc, sort_keys=True) + + +def _part_data(part: Part) -> dict: + """A part's JSON: a data part's data, a text part's text, or a file part's bytes or ``file://`` file.""" + if isinstance(part, DataPart): + return part.data + if isinstance(part, TextPart): + return json.loads(part.text) + if isinstance(part, FilePart): + file = part.file + if getattr(file, "bytes", None) is not None: + return json.loads(base64.b64decode(file.bytes)) + uri = urlparse(file.uri) + if uri.scheme == "file": + return json.loads(Path(unquote(uri.path)).read_text(encoding="utf-8")) + raise ValueError(f"the store reads file parts by their bytes or a file:// URI, not {file.uri!r}") + raise ValueError(f"the store can't read a {type(part).__name__}") diff --git a/packages/agentenv-protocol/tests/test_website_api.py b/packages/agentenv-protocol/tests/test_website_api.py new file mode 100644 index 000000000..21b73530c --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_api.py @@ -0,0 +1,413 @@ +"""The website SDK's @api routes: binding from type hints, JSON answers, errors, roles, and what a class may not +declare.""" + +from __future__ import annotations + +import datetime +import enum +from typing import Optional, Union + +import pytest +from pydantic import BaseModel, computed_field +from starlette.responses import PlainTextResponse +from starlette.testclient import TestClient + +from agentenv_protocol.website import ( + ROLE_KEY_ENV, + ROLE_KEY_HEADER, + ApiContext, + BadRequest, + Conflict, + NotFound, + WebsiteEnvironment, + api, + reserved_path, + website, +) + + +class Color(enum.Enum): + RED = "red" + BLUE = "blue" + + +class NewItem(BaseModel): + title: str + price: float = 0 + + +@website(domains=["shop.example"]) +class Shop(WebsiteEnvironment): + def __init__(self) -> None: + super().__init__() + self.items: dict[int, dict] = {1: {"id": 1, "title": "mug", "price": 3.5}} + + @api.get("/api/items/{item_id}", name="get_item") + async def get_item(self, item_id: int) -> dict: + """Read one item.""" + if item_id not in self.items: + raise NotFound(f"no item {item_id}") + return self.items[item_id] + + @api.get("/api/items") + def list_items(self, limit: int = 10, cheap: bool = False, color: Optional[Color] = None, + since: Optional[datetime.date] = None, tags: list[str] = []) -> dict: + return {"limit": limit, "cheap": cheap, "color": color.value if color else None, + "since": since.isoformat() if since else None, "tags": tags} + + @api.post("/api/items", name="create_item", status=201) + async def create_item(self, item: NewItem) -> NewItem: + if any(i["title"] == item.title for i in self.items.values()): + raise Conflict(f"{item.title} exists") + self.items[len(self.items) + 1] = {"id": len(self.items) + 1, **item.model_dump()} + return item + + @api.patch("/api/items/{item_id}") + async def patch_item(self, item_id: int, changes: dict) -> dict: + self.items[item_id].update(changes) + return self.items[item_id] + + @api.delete("/api/items/{item_id}", roles=["admin"]) + async def delete_item(self, item_id: int) -> None: + self.items.pop(item_id, None) + + @api.put("/api/teapot") + async def teapot(self) -> None: + raise BadRequest("short and stout", code="teapot", status=418) + + @api.get("/api/whoami") + async def whoami(self, ctx: ApiContext) -> dict: + return {"user": ctx.user, "role": ctx.role, "base_url": ctx.base_url, "q": ctx.query.get("q"), + "header": ctx.headers.get("x-probe"), "cookie": ctx.cookies.get("c")} + + @api.get("/api/plain") + async def plain(self) -> PlainTextResponse: + return PlainTextResponse("as is") + + @api.get("/api/boom") + async def boom(self) -> dict: + raise RuntimeError("secret detail") + + @api.get("/api/picked") + def picked(self, ids: Optional[list[int]] = None) -> dict: + return {"ids": ids} + + @api.delete("/api/cart", status=204) + async def empty_cart(self) -> None: + return None + + +@pytest.fixture +def client() -> TestClient: + return TestClient(Shop().create_app()) + + +def test_path_query_and_body_parameters_are_bound_from_type_hints(client: TestClient) -> None: + assert client.get("/api/items/1").json() == {"id": 1, "title": "mug", "price": 3.5} + assert client.get("/api/items").json() == {"limit": 10, "cheap": False, "color": None, "since": None, "tags": []} + listed = client.get("/api/items?limit=3&cheap=yes&color=blue&since=2030-01-02&tags=a&tags=b").json() + assert listed == {"limit": 3, "cheap": True, "color": "blue", "since": "2030-01-02", "tags": ["a", "b"]} + created = client.post("/api/items", json={"title": "pen", "price": 1}) + assert created.status_code == 201 and created.json() == {"title": "pen", "price": 1.0} + assert client.patch("/api/items/1", json={"price": 4}).json()["price"] == 4 + + +def test_a_request_its_parameters_cannot_be_bound_from_answers_400(client: TestClient) -> None: + for response in (client.get("/api/items/abc"), client.get("/api/items?limit=many"), + client.get("/api/items?color=green"), client.post("/api/items", json={"price": 2}), + client.post("/api/items", content=b"not json"), client.post("/api/items"), + client.patch("/api/items/1", json=[1, 2])): + assert response.status_code == 400, response.text + assert response.json()["code"] == "invalid_params" + assert "title" in client.post("/api/items", json={"price": 2}).json()["error"] + + +class Pin(BaseModel): + lat: float + + +class Place(BaseModel): + name: str + pin: Pin + + +class Note(BaseModel): + text: str + + +@website(domains=["map.example"]) +class Map(WebsiteEnvironment): + @api.post("/api/places") + def add_place(self, place: Place) -> list[Place]: + return [place] + + @api.post("/api/marks") + def mark(self, mark: Union[Note, Pin]) -> dict: + return {"kind": type(mark).__name__} + + @api.put("/api/settings") + def settings(self, settings: Optional[dict]) -> dict: + return {"settings": settings} + + +def test_a_body_is_validated_against_its_whole_annotation() -> None: + client = TestClient(Map().create_app()) + assert client.put("/api/settings", content=b"null", headers={"content-type": "application/json"}).json() == { + "settings": None} + assert client.post("/api/marks", json={"lat": 1.5}).json() == {"kind": "Pin"} + assert client.post("/api/marks", json={"text": "hi"}).json() == {"kind": "Note"} + assert client.post("/api/marks", json={"other": 1}).status_code == 400 + + +def test_nested_models_are_the_openapi_document_s_components_its_schemas_reference() -> None: + document = TestClient(Map().create_app()).get("/agentenv/ext/website/openapi").json() + schemas = document["components"]["schemas"] + assert set(schemas) == {"Pin", "Place", "Note"} + assert schemas["Place"]["properties"]["pin"] == {"$ref": "#/components/schemas/Pin"} + body = document["paths"]["/api/places"]["post"]["requestBody"]["content"]["application/json"]["schema"] + answer = document["paths"]["/api/places"]["post"]["responses"]["200"]["content"]["application/json"]["schema"] + assert body == {"$ref": "#/components/schemas/Place"} + assert answer == {"type": "array", "items": {"$ref": "#/components/schemas/Place"}} + refs = [v for v in _walk(document) if isinstance(v, str) and v.startswith("#/")] + assert refs and all(ref.split("/")[-1] in schemas and ref.startswith("#/components/schemas/") for ref in refs) + + +class Cart(BaseModel): + prices: list[float] + + @computed_field + @property + def total(self) -> float: + return sum(self.prices) + + +@website(domains=["till.example"]) +class Till(WebsiteEnvironment): + @api.post("/api/carts") + def price(self, cart: Cart) -> Cart: + return cart + + +def test_an_answer_s_schema_describes_it_as_serialized_and_a_body_s_as_validated() -> None: + client = TestClient(Till().create_app()) + assert client.post("/api/carts", json={"prices": [1, 2]}).json() == {"prices": [1, 2], "total": 3} + document = client.get("/agentenv/ext/website/openapi").json() + schemas = document["components"]["schemas"] + operation = document["paths"]["/api/carts"]["post"] + body = operation["requestBody"]["content"]["application/json"]["schema"] + answer = operation["responses"]["200"]["content"]["application/json"]["schema"] + assert body == {"$ref": "#/components/schemas/Cart-Input"} + assert answer == {"$ref": "#/components/schemas/Cart-Output"} + assert "total" not in schemas["Cart-Input"]["properties"] and "total" in schemas["Cart-Output"]["properties"] + + +def _walk(value: object): + if isinstance(value, dict): + for v in value.values(): + yield from _walk(v) + elif isinstance(value, list): + for v in value: + yield from _walk(v) + else: + yield value + + +def test_api_errors_answer_with_their_status_and_code(client: TestClient) -> None: + missing = client.get("/api/items/9") + assert (missing.status_code, missing.json()) == (404, {"error": "no item 9", "code": "not_found"}) + assert client.post("/api/items", json={"title": "mug"}).json() == {"error": "mug exists", "code": "conflict"} + teapot = client.put("/api/teapot") + assert (teapot.status_code, teapot.json()["code"]) == (418, "teapot") + boom = client.get("/api/boom") + assert (boom.status_code, boom.json()) == (500, {"error": "internal error", "code": "internal"}) + + +def test_responses_pass_through_and_none_is_json_null(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ROLE_KEY_ENV, "k3y") + assert client.get("/api/plain").text == "as is" + deleted = client.delete("/api/items/1", headers={"AgentEnv-Role": "admin", ROLE_KEY_HEADER: "k3y"}) + assert (deleted.status_code, deleted.json()) == (200, None) + + +def test_an_optional_list_query_parameter_is_a_list_of_its_coerced_values(client: TestClient) -> None: + assert client.get("/api/picked?ids=1&ids=2").json() == {"ids": [1, 2]} + assert client.get("/api/picked").json() == {"ids": None} + assert client.get("/api/picked?ids=x").status_code == 400 + + +def test_a_route_declared_204_answers_with_no_body(client: TestClient) -> None: + emptied = client.delete("/api/cart") + assert (emptied.status_code, emptied.content) == (204, b"") + assert "content-length" not in emptied.headers and "content-type" not in emptied.headers + + +def test_a_204_answer_keeps_a_real_server_connection_usable() -> None: + import socket + import threading + import time + + uvicorn = pytest.importorskip("uvicorn") + server = uvicorn.Server(uvicorn.Config(Shop().create_app(), host="127.0.0.1", port=0, log_level="error", + ws="none")) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + while not server.started: + time.sleep(0.01) + port = server.servers[0].sockets[0].getsockname()[1] + try: + with socket.create_connection(("127.0.0.1", port), timeout=5) as conn: + conn.sendall(b"DELETE /api/cart HTTP/1.1\r\nHost: x\r\n\r\nGET /api/items/1 HTTP/1.1\r\nHost: x\r\n\r\n") + received = b"" + while b"mug" not in received: + chunk = conn.recv(65536) + if not chunk: + break + received += chunk + assert received.startswith(b"HTTP/1.1 204") and b"HTTP/1.1 200" in received and b"mug" in received + finally: + server.should_exit = True + thread.join(5) + + +def test_a_session_cookie_that_is_not_ascii_is_no_session(client: TestClient) -> None: + for path in ("/api/whoami", "/api/session"): + response = client.get(path, headers={"Cookie": "agentenv_session=abc.\xe9".encode("latin-1")}) + assert response.status_code == 200, response.text + assert response.json()["user"] is None + + +def test_roles_refuse_other_callers_with_403(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ROLE_KEY_ENV, "k3y") + for headers in ({}, {"AgentEnv-Role": "default"}, {"AgentEnv-Role": "agent", ROLE_KEY_HEADER: "k3y"}): + refused = client.delete("/api/items/1", headers=headers) + assert refused.status_code == 403 and refused.json()["code"] == "forbidden" + assert client.get("/api/items/1").status_code == 200 + assert client.delete("/api/items/1", headers={"AgentEnv-Role": "admin", ROLE_KEY_HEADER: "k3y"}).status_code == 200 + + +def test_a_role_is_believed_only_with_the_site_s_role_key(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(ROLE_KEY_ENV, raising=False) + claim = {"AgentEnv-Role": "admin"} + assert client.delete("/api/items/1", headers=claim).status_code == 403 + assert client.delete("/api/items/1", headers={**claim, ROLE_KEY_HEADER: ""}).status_code == 403 + monkeypatch.setenv(ROLE_KEY_ENV, "k3y") + for key in (None, "", "wrong", "k3y "): + headers = claim if key is None else {**claim, ROLE_KEY_HEADER: key} + assert client.delete("/api/items/1", headers=headers).status_code == 403 + assert client.get("/api/whoami", headers=headers).json()["role"] == "default" + assert client.get("/api/whoami", headers={**claim, ROLE_KEY_HEADER: "k3y"}).json()["role"] == "admin" + + +def test_the_context_carries_the_request_role_and_forwarded_address(client: TestClient, + monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ROLE_KEY_ENV, "k3y") + client.cookies.set("c", "crumb") + seen = client.get("/api/whoami?q=1", headers={"x-probe": "p", "AgentEnv-Role": "agent", ROLE_KEY_HEADER: "k3y"}).json() + assert seen == {"user": None, "role": "agent", "base_url": "http://testserver", "q": "1", "header": "p", + "cookie": "crumb"} + forwarded = client.get("/api/whoami", headers={"X-Forwarded-Host": "shop.example, proxy", + "X-Forwarded-Proto": "https"}).json() + assert (forwarded["base_url"], forwarded["role"]) == ("https://shop.example", "default") + + +def test_a_redirect_names_the_forwarded_address(client: TestClient) -> None: + redirected = client.get("/api/items/", follow_redirects=False, + headers={"Host": "internal-x.invalid", "X-Forwarded-Host": "shop.example", + "X-Forwarded-Proto": "https"}) + assert (redirected.status_code, redirected.headers["location"]) == (307, "https://shop.example/api/items") + assert client.get("/api/items/", follow_redirects=False).headers["location"] == "http://testserver/api/items" + + +def test_parameterized_routes_never_take_the_data_plane_or_card() -> None: + @website(domains=["wiki.example"]) + class Wiki(WebsiteEnvironment): + @api.get("/{slug}") + def page(self, slug: str) -> dict: + return {"slug": slug} + + @api.post("/{collection}") + def add(self, collection: str) -> dict: + return {"captured": collection} + + @api.get("/.well-known/{name}") + def well_known(self, name: str) -> dict: + return {"captured": name} + + @api.get("/files/{rest:path}") + def files(self, rest: str) -> dict: + return {"rest": rest} + + client = TestClient(Wiki().create_app()) + assert client.get("/hello").json() == {"slug": "hello"} + assert client.post("/notes").json() == {"captured": "notes"} + assert client.get("/.well-known/other.json").json() == {"captured": "other.json"} + assert client.get("/files/a/b.txt").json() == {"rest": "a/b.txt"} + reset = client.post("/agentenv", json={"jsonrpc": "2.0", "id": 1, "method": "data/get", "params": {}}).json() + assert reset["id"] == 1 and "result" in reset + assert client.get("/.well-known/agent-env.json").json()["name"] == "Wiki" + assert client.get("/api/health").json() == {"status": "ok"} + + +@pytest.mark.parametrize("path", ["/agentenv", "/agentenv/ext/x", "/.well-known/agent-env.json", + "/api/../agentenv/x", "//agentenv"]) +def test_api_paths_under_the_reserved_ones_are_refused(path: str) -> None: + assert reserved_path(path) + with pytest.raises(ValueError, match="data plane"): + api.get(path) + + +def test_a_class_serving_a_name_or_route_twice_is_refused() -> None: + with pytest.raises(ValueError, match="name"): + class Twice(WebsiteEnvironment): + @api.get("/a", name="same") + def a(self) -> None: ... + + @api.get("/b", name="same") + def b(self) -> None: ... + + with pytest.raises(ValueError, match="route"): + class Again(WebsiteEnvironment): + @api.post("/a") + def a(self) -> None: ... + + @api.post("/a") + def b(self) -> None: ... + + +def test_a_route_must_take_its_path_parameters_and_at_most_one_body() -> None: + with pytest.raises(TypeError, match="item_id"): + api.get("/api/{item_id}")(lambda self: None) + + class Bodies(WebsiteEnvironment): + @api.post("/api/x") + def x(self, a: dict, b: NewItem) -> None: ... + + with pytest.raises(TypeError, match="one JSON body"): + Bodies().create_app() + + +def test_the_health_path_is_not_an_api_route() -> None: + with pytest.raises(ValueError, match="ready_check"): + @website(health="/api/up") + class Clash(WebsiteEnvironment): + @api.get("/api/up") + def up(self) -> dict: + return {} + + with pytest.raises(ValueError, match="health"): + website(health="/agentenv/health") + + +@website(domains=["shelf.example"]) +class Shelf(WebsiteEnvironment): + @api.delete("/api/books/{book_id}", status=204) + def drop(self, book_id: int) -> Pin: + return Pin(lat=1.0) + + +def test_a_bodyless_status_publishes_no_body_and_answers_none() -> None: + client = TestClient(Shelf().create_app()) + answered = client.delete("/api/books/1") + assert answered.status_code == 204 and answered.content == b"" + responses = client.get("/agentenv/ext/website/openapi").json()["paths"]["/api/books/{book_id}"]["delete"]["responses"] + assert "content" not in responses["204"] diff --git a/packages/agentenv-protocol/tests/test_website_conformance.py b/packages/agentenv-protocol/tests/test_website_conformance.py new file mode 100644 index 000000000..50f981b54 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_conformance.py @@ -0,0 +1,265 @@ +"""The website/v1 conformance checker against an SDK site, a hand-written server that meets the contract, and servers +that don't.""" + +from __future__ import annotations + +import datetime +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse, Response +from starlette.routing import Route +from starlette.testclient import TestClient + +from agentenv_protocol.website import WebsiteEnvironment, api, website +from agentenv_protocol.website import conformance +from agentenv_protocol.website.clock import UNIVERSE_NOW_HEADER, iso + +CHECKS = ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"] + + +def statuses(results: list[conformance.Result]) -> dict[str, str]: + return {r.name: r.status for r in results} + + +@pytest.fixture +def notes(tmp_path: Path) -> WebsiteEnvironment: + (tmp_path / "index.html").write_text("hi") + + @website(domains=["notes.example"], static=str(tmp_path)) + class Notes(WebsiteEnvironment): + @api.get("/api/notes") + def notes(self) -> list: + return self.store["notes"] + + env = Notes() + env.store.load({"current_user": "ann", "notes": [{"id": 1, "text": "hello"}]}) + env.store.mark_initial() + return env + + +@pytest.fixture +def sdk_site(notes: WebsiteEnvironment) -> TestClient: + return TestClient(notes.create_app()) + + +def test_an_sdk_site_passes_every_check(sdk_site: TestClient) -> None: + results = conformance.check("http://testserver", client=sdk_site) + assert [r.name for r in results] == CHECKS + assert all(r.status == "passed" for r in results), results + assert sdk_site.post("/agentenv", json={"jsonrpc": "2.0", "id": 1, "method": "data/get"}).json()["result"][ + "parts"][0]["data"] == {"current_user": "ann", "notes": [{"id": 1, "text": "hello"}]} + + +def _handwritten(*, leak_host: bool = False, domain_cookie: bool = False, clock: bool = True, + leak_redirect: bool = False, stamp_rpc: bool = True, reset_keeps: bool = False, + get_fails_after_reset: bool = False, rpc_reply: tuple[int, dict] | None = None, + ready_answer: object = None, rpc_path: str | None = None, state_path: str | None = None, + changed: bool = False) -> Starlette: + """A server written without the SDK: the contract by hand, optionally broken. Its state is its ``state.data``; with + ``ready_answer`` its card advertises ``website/v1``'s ``ready``, which answers it. With ``rpc_path`` its card's + ``url`` names its data plane there, not at ``/agentenv``, and with ``state_path`` its ``params.state.path`` names + where it serves its state, which shows changes since the initial state when ``changed``.""" + state = {"items": ["a"], "url": None, "at": None, "reset": False} + + def now() -> datetime.datetime: + return state["at"] or datetime.datetime.now(datetime.timezone.utc) + + def stamp(response: Response) -> Response: + response.headers[UNIVERSE_NOW_HEADER] = iso(now()) + return response + + async def page(request: Request) -> Response: + host = request.headers["host"] if leak_host else request.headers.get("x-forwarded-host", request.headers["host"]) + response = stamp(HTMLResponse(f'x')) + response.set_cookie("s", "1", domain="example.com" if domain_cookie else None) + return response + + async def health(request: Request) -> Response: + return stamp(JSONResponse({"ok": True})) + + async def ready(request: Request) -> Response: + return stamp(JSONResponse(ready_answer)) + + async def items(request: Request) -> Response: + return stamp(JSONResponse(state["items"])) + + async def items_slash(request: Request) -> Response: + host = request.headers["host"] if leak_redirect else request.headers.get("x-forwarded-host", request.headers["host"]) + return stamp(Response(status_code=307, headers={"Location": f"https://{host}/items"})) + + async def card(request: Request) -> Response: + extensions = [{"uri": "urn:agentenv:website/v1", + "params": {"domains": ["h.example"], "health": "/healthz", + "routes": [{"method": "GET", "path": "/items", "name": "items"}]}}] + if ready_answer is not None: + extensions[0]["params"]["methods"] = {"ready": {"method": "GET", "endpoint": "/ready"}} + if state_path is not None: + extensions[0]["params"]["state"] = {"path": state_path} + if clock: + extensions.append({"uri": "urn:agentenv:clock/v1", "params": { + "endpoint": "/agentenv/ext/sync_time", "methods": {"sync_time": {"method": "POST"}}}}) + return stamp(JSONResponse({"name": "hand", **({"url": rpc_path} if rpc_path else {}), + "capabilities": {"extensions": extensions, + "operations": ["data/reset", "data/add", "data/get"]}})) + + async def site_state(request: Request) -> Response: + return JSONResponse({"state_diff": {"items": {"added": ["b"]}} if changed else {}}) + + async def rpc(request: Request) -> Response: + body = await request.json() + method = body["method"] + if rpc_reply is not None: + return stamp(JSONResponse(rpc_reply[1], status_code=rpc_reply[0])) + if method == "data/reset": + state["reset"] = True + if not reset_keeps: + state["items"] = [] + elif method == "data/add": + state["reset"] = False + added = [item for part in body["params"]["parts"] for item in part["data"]["items"]] + state["items"] = added if reset_keeps else state["items"] + added + if method == "data/get" and get_fails_after_reset and state["reset"]: + answer = {"jsonrpc": "2.0", "id": body["id"], "error": {"code": -32603, "message": "down"}} + else: + result = {"parts": [{"kind": "data", "data": {"items": state["items"]}}]} if method == "data/get" else {} + answer = {"jsonrpc": "2.0", "id": body["id"], "result": result} + return stamp(JSONResponse(answer)) if stamp_rpc else JSONResponse(answer) + + async def sync_time(request: Request) -> Response: + import httpx + + url = (await request.json())["env_get_time_url"] + async with httpx.AsyncClient() as client: + virtual = (await client.get(url)).json()["virtual_time"] + state["at"] = datetime.datetime.fromisoformat(virtual.replace("Z", "+00:00")) + return stamp(JSONResponse({"gateway_virtual_time": virtual})) + + app = Starlette(routes=[Route("/", page), Route("/healthz", health), Route("/ready", ready), Route("/items", items), + Route("/items/", items_slash), + Route("/.well-known/agent-env.json", card), Route(rpc_path or "/agentenv", rpc, methods=["POST"]), + Route("/agentenv/ext/sync_time", sync_time, methods=["POST"]), + *([Route(state_path, site_state)] if state_path else [])]) + app.state.data = state + return app + + +def test_a_handwritten_server_meeting_the_contract_passes() -> None: + results = conformance.check("http://testserver", client=TestClient(_handwritten())) + assert statuses(results) == dict.fromkeys(CHECKS, "passed"), results + + +@pytest.mark.parametrize(("answer", "status", "detail"), [ + ({"ready": True, "problems": [], "since": "boot"}, "passed", "website/v1 ready says it is ready"), + ({"ready": False, "problems": ["seeded", "indexed"]}, "failed", "website/v1 ready says it is not ready: seeded, indexed"), + ({"ready": False}, "failed", "website/v1 ready says it is not ready: no problem named"), +]) +def test_health_reads_the_cards_ready_as_a_website_readiness(answer: dict, status: str, detail: str) -> None: + results = {r.name: r for r in conformance.check("http://testserver", client=TestClient(_handwritten(ready_answer=answer)))} + assert (results["health"].status, results["health"].detail) == (status, detail) + + +@pytest.mark.parametrize(("answer", "why"), [ + ({"ok": True}, "ready: Field required"), + ({"ready": "soon", "problems": []}, "ready: Input should be a valid boolean"), + ({"ready": False, "problems": "seeded"}, "problems: Input should be a valid list"), + (["ready"], "the answer: Input should be a valid dictionary or instance of WebsiteReadiness"), +]) +def test_an_answer_to_ready_that_isnt_a_website_readiness_fails_health_saying_why(answer: object, why: str) -> None: + health = next(r for r in conformance.check("http://testserver", client=TestClient(_handwritten(ready_answer=answer))) + if r.name == "health") + assert health.status == "failed" + assert health.detail.startswith(f"website/v1 ready answered {answer!r}, not a WebsiteReadiness: ") and why in health.detail + + +def test_a_server_breaking_the_contract_fails_those_checks() -> None: + results = statuses(conformance.check("http://testserver", + client=TestClient(_handwritten(leak_host=True, domain_cookie=True, clock=False)))) + assert results["forwarded"] == results["cookies"] == results["clock"] == "failed" + assert results["card"] == results["health"] == results["data_plane"] == "passed" + + +def test_the_forwarded_check_follows_no_redirect_and_reads_its_location(sdk_site: TestClient) -> None: + forwarded = {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["forwarded"] + assert forwarded.status == "passed" and "/api/notes/" in forwarded.detail + leaked = {r.name: r for r in conformance.check("http://testserver", + client=TestClient(_handwritten(leak_redirect=True)))}["forwarded"] + assert leaked.status == "failed" and "Location of /items/" in leaked.detail + + +def test_universe_now_is_checked_on_the_answers_of_every_check() -> None: + result = {r.name: r for r in conformance.check("http://testserver", + client=TestClient(_handwritten(stamp_rpc=False)))}["universe_now"] + assert result.status == "failed" and "/agentenv" in result.detail + + +def test_a_reset_that_clears_nothing_fails_the_data_plane_and_leaves_the_data() -> None: + app = _handwritten(reset_keeps=True) + result = {r.name: r for r in conformance.check("http://testserver", client=TestClient(app))}["data_plane"] + assert result.status == "failed" and "data/reset" in result.detail + assert app.state.data["items"] == ["a"] + + +def test_the_data_is_added_back_when_a_step_after_the_reset_fails() -> None: + app = _handwritten(get_fails_after_reset=True) + result = {r.name: r for r in conformance.check("http://testserver", client=TestClient(app))}["data_plane"] + assert result.status == "failed" and "down" in result.detail + assert app.state.data["items"] == ["a"] + + +@pytest.mark.parametrize("reply", [(500, {}), (200, {}), (500, {"jsonrpc": "2.0", "id": "x", "result": {}})]) +def test_a_data_request_without_a_2xx_json_rpc_result_fails_the_data_plane(reply: tuple[int, dict]) -> None: + app = _handwritten(rpc_reply=reply) + result = {r.name: r for r in conformance.check("http://testserver", client=TestClient(app))}["data_plane"] + assert result.status == "failed" and "no JSON-RPC result" in result.detail + + +def test_an_sdk_site_keeps_its_initial_state_and_one_changed_since_skips_the_round_trip( + notes: WebsiteEnvironment, sdk_site: TestClient) -> None: + before = sdk_site.get("/go").json() + conformance.check("http://testserver", client=sdk_site) + assert sdk_site.get("/go").json() == before + notes.store.insert("notes", {"text": "new"}) + changed = sdk_site.get("/go").json() + assert changed["state_diff"] + result = {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["data_plane"] + assert result.status == "skipped" and "website/v1 state shows changes" in result.detail + assert sdk_site.get("/go").json() == changed + + +def test_the_data_plane_is_reached_at_the_cards_url() -> None: + app = _handwritten(rpc_path="/rpc") + result = {r.name: r for r in conformance.check("http://testserver", client=TestClient(app))}["data_plane"] + assert result.status == "passed", result + assert app.state.data["items"] == ["a"] + + +@pytest.mark.parametrize(("changed", "status"), [(True, "skipped"), (False, "passed")]) +def test_a_card_without_state_is_read_at_the_state_path_it_names(changed: bool, status: str) -> None: + app = _handwritten(state_path="/grading/state", changed=changed) + result = {r.name: r for r in conformance.check("http://testserver", client=TestClient(app))}["data_plane"] + assert result.status == status, result + assert ("/grading/state shows changes" in result.detail) is changed + + +def test_a_server_with_no_card_health_or_header_fails_them() -> None: + bare = Starlette(routes=[Route("/", lambda request: JSONResponse({"not": "html"}))]) + results = statuses(conformance.check("http://testserver", client=TestClient(bare))) + assert results["card"] == results["any_host"] == results["health"] == results["universe_now"] == "failed" + assert results["data_plane"] == results["clock"] == "failed" + assert results["cookies"] == "skipped" + + +def test_main_prints_each_check_and_exits_by_outcome(monkeypatch, capsys) -> None: + monkeypatch.setattr(conformance, "check", lambda url, **options: [conformance.Result("card", "passed"), + conformance.Result("clock", "failed", "late")]) + assert conformance.main(["http://site"]) == 1 + printed = capsys.readouterr().out + assert "PASS card" in printed and "FAIL clock: late" in printed + + +def test_main_exits_2_when_the_server_cannot_be_reached(capsys) -> None: + assert conformance.main(["http://127.0.0.1:9", "--timeout", "2"]) == 2 + assert "couldn't reach" in capsys.readouterr().err diff --git a/packages/agentenv-protocol/tests/test_website_environment.py b/packages/agentenv-protocol/tests/test_website_environment.py new file mode 100644 index 000000000..6f28c4ba1 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_environment.py @@ -0,0 +1,552 @@ +"""WebsiteEnvironment as a site's server: static pages, health, the card and its OpenAPI document, the default store +and its data plane, sessions, the universe's clock, and a mounted app.""" + +from __future__ import annotations + +import asyncio +import base64 +import datetime +import json +import shutil +import subprocess +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.responses import PlainTextResponse +from starlette.routing import Route +from starlette.testclient import TestClient + +from agentenv_protocol import DataPart, get_data +from agentenv_protocol.website import ( + SESSION_COOKIE, + UNIVERSE_NOW_HEADER, + WEBSITE_EXTENSION_URI, + WEBSITE_READY_OP, + ApiContext, + Collections, + WebsiteEnvironment, + WebsiteReadiness, + api, + ready_check, + website, +) +from agentenv_protocol.website import clock as clock_module +from agentenv_protocol.website.clock import parse_time + +RPC = "/agentenv" + + +def rpc(client: TestClient, method: str, params: dict | None = None) -> dict: + body = client.post(RPC, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}).json() + assert "error" not in body, body + return body["result"] + + +@pytest.fixture +def dist(tmp_path: Path) -> Path: + root = tmp_path / "dist" + (root / "assets").mkdir(parents=True) + (root / "index.html").write_text("Shophome") + (root / "assets" / "app.js").write_text("console.log(1)") + (tmp_path / "secret.txt").write_text("outside") + return root + + +def site(dist: Path, **declared) -> type[WebsiteEnvironment]: + @website(domains=["shop.example", "www.shop.example"], static=str(dist), **declared) + class Shop(WebsiteEnvironment): + @api.get("/api/orders", name="list_orders") + def list_orders(self, ctx: ApiContext) -> list: + """List the signed-in user's orders.""" + return self.store.find("orders", user=ctx.user) + + @api.post("/api/orders", name="place_order", roles=["default", "agent"]) + def place_order(self, order: dict, ctx: ApiContext) -> dict: + return self.store.insert("orders", {**order, "user": ctx.user, "at": self.now().isoformat()}) + + @api.post("/api/sign-in") + def sign_in(self, body: dict, ctx: ApiContext) -> dict: + ctx.sign_in(body["user"]) + return {"user": ctx.user} + + return Shop + + +SEED = {"current_user": "alice", "users": [{"id": "alice"}, {"id": "bob"}], "orders": [{"id": 1, "user": "bob"}]} + + +@pytest.fixture +def shop(dist: Path) -> WebsiteEnvironment: + env = site(dist)() + env.store.load(SEED) + env.store.mark_initial() + return env + + +@pytest.fixture +def client(shop: WebsiteEnvironment) -> TestClient: + return TestClient(shop.create_app()) + + +def test_static_files_and_index_fallback_answer_for_any_host(client: TestClient) -> None: + for host in ("testserver", "shop.example", "10.0.0.7:8000", "anything.invalid"): + page = client.get("/", headers={"Host": host}) + assert page.status_code == 200 and "home" in page.text and page.headers["content-type"].startswith("text/html") + assert client.get("/assets/app.js").text == "console.log(1)" + assert "home" in client.get("/orders/42/edit").text + assert client.get("/assets/missing.js").status_code == 404 + assert client.get("/api/nothing-here").json() == {"error": "not found", "code": "not_found"} + assert client.get("/../secret.txt").status_code == 404 + assert client.post("/orders").status_code == 404 + + +def test_a_link_out_of_the_static_folder_is_never_served(dist: Path) -> None: + secret = dist.parent / "secret.txt" + (dist / "leak.txt").symlink_to(secret) + (dist / "linked").mkdir() + (dist / "linked" / "index.html").symlink_to(secret) + client = TestClient(site(dist)().create_app()) + assert "outside" not in client.get("/leak.txt").text + assert "outside" not in client.get("/linked").text + assert "outside" not in client.get("/linked/").text + + +def test_the_fallback_never_shadows_api_health_or_reserved_paths(client: TestClient) -> None: + assert client.get("/api/orders").json() == [] + assert client.put("/api/orders").status_code == 405 + assert client.get("/api/health").json() == {"status": "ok"} + for path in ("/agentenv", "/agentenv/anything", "/agentenv/ext/website/nope", "/agentenv/../agentenv/x"): + assert client.get(path).status_code in (404, 405), path + assert client.get("/.well-known/agent-env.json").json()["name"] == "Shop" + + +def test_health_waits_for_every_ready_check(dist: Path) -> None: + class Slow(site(dist)): + ready = False + + @ready_check + def database_up(self) -> bool: + return self.ready + + @ready_check + async def cache_warm(self) -> bool: + return True + + env = Slow() + client = TestClient(env.create_app()) + waiting = client.get("/api/health") + assert waiting.status_code == 503 and waiting.json() == {"status": "starting", "waiting_for": ["database_up"]} + env.ready = True + assert client.get("/api/health").status_code == 200 + + +def test_the_cards_website_v1_ready_says_whether_the_site_is_usable_by_its_ready_checks(dist: Path) -> None: + class Slow(site(dist)): + ready = False + + @ready_check + def database_up(self) -> bool: + return self.ready + + env = Slow() + client = TestClient(env.create_app()) + card = client.get("/.well-known/agent-env.json").json() + ready = _extension(card, WEBSITE_EXTENSION_URI)["params"]["methods"][WEBSITE_READY_OP] + assert (ready["method"], ready["endpoint"]) == ("GET", "/agentenv/ext/website/ready") + assert client.get(ready["endpoint"]).json() == {"ready": False, "problems": ["database_up"]} + assert WebsiteReadiness.model_validate(client.get(ready["endpoint"]).json()) == WebsiteReadiness( + ready=False, problems=["database_up"]) + env.ready = True + assert client.get(ready["endpoint"]).json() == {"ready": True, "problems": []} + + + +def test_a_declared_health_path_is_served_there(dist: Path) -> None: + client = TestClient(site(dist, health="/healthz")().create_app()) + assert client.get("/healthz").json() == {"status": "ok"} + card = client.get("/.well-known/agent-env.json").json() + assert _extension(card, WEBSITE_EXTENSION_URI)["params"]["health"] == "/healthz" + + +def _extension(card: dict, uri: str) -> dict: + return next(e for e in card["capabilities"]["extensions"] if e["uri"] == uri) + + +def test_the_card_declares_website_v1_its_routes_clock_and_data_plane(client: TestClient) -> None: + card = client.get("/.well-known/agent-env.json").json() + assert card["capabilities"]["operations"] == ["data/reset", "data/add", "data/get"] + params = _extension(card, WEBSITE_EXTENSION_URI)["params"] + assert params["domains"] == ["shop.example", "www.shop.example"] + assert params["health"] == "/api/health" + assert params["routes"] == [ + {"method": "GET", "path": "/api/orders", "name": "list_orders", "roles": None}, + {"method": "POST", "path": "/api/orders", "name": "place_order", "roles": ["default", "agent"]}, + {"method": "POST", "path": "/api/sign-in", "name": "sign_in", "roles": None}, + ] + assert params["methods"]["openapi"] == {"method": "GET", "endpoint": "/agentenv/ext/website/openapi", + "request": {"type": "object", "properties": {}}} + assert params["methods"]["session"]["endpoint"] == "/agentenv/ext/website/session" + clock = _extension(card, "urn:agentenv:clock/v1")["params"] + assert clock["endpoint"] == "/agentenv/ext/sync_time" + + +def test_the_openapi_document_names_each_route(client: TestClient) -> None: + document = client.get("/agentenv/ext/website/openapi").json() + assert document["openapi"].startswith("3.") and document["info"]["title"] == "Shop" + listed = document["paths"]["/api/orders"]["get"] + assert listed["operationId"] == "list_orders" and listed["summary"] == "List the signed-in user's orders." + placed = document["paths"]["/api/orders"]["post"] + assert placed["x-agentenv-roles"] == ["default", "agent"] + assert placed["requestBody"]["content"]["application/json"]["schema"]["type"] == "object" + assert listed["parameters"] == [] + + +def test_the_default_store_round_trips_through_the_data_plane(client: TestClient, shop: WebsiteEnvironment) -> None: + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + rpc(client, "data/reset") + assert rpc(client, "data/get")["parts"][0]["data"] == {} + as_file = base64.b64encode(json.dumps({"orders": [{"id": 7, "user": "carol"}]}).encode()).decode() + rpc(client, "data/add", {"parts": [{"kind": "data", "data": {"current_user": "carol", "users": [{"id": "carol"}]}}, + {"kind": "file", "file": {"bytes": as_file, "name": "orders.json"}}, + {"kind": "text", "text": json.dumps({"notes": [{"id": "n1"}]})}]}) + assert rpc(client, "data/get")["parts"][0]["data"] == { + "current_user": "carol", "users": [{"id": "carol"}], "orders": [{"id": 7, "user": "carol"}], + "notes": [{"id": "n1"}]} + assert shop.acting_user() == "carol" + + +def test_go_reports_initial_current_and_diff_state(client: TestClient) -> None: + client.post("/api/orders", json={"sku": "mug"}) + state = client.get("/go", params={"sid": "default"}).json() + assert state["initial_state"] == SEED + assert state["current_state"]["orders"][-1]["sku"] == "mug" + assert state["state_diff"] == {"orders": {"added": [state["current_state"]["orders"][-1]], "removed": [], + "changed": []}} + + +def test_broad_routes_leave_the_built_in_ones_answering_their_paths(dist: Path) -> None: + class Broad(site(dist)): + @api.get("/{slug}") + def page(self, slug: str) -> dict: + return {"page": slug} + + @api.get("/api/{name}") + def named(self, name: str) -> dict: + return {"named": name} + + env = Broad() + env.store.load(SEED) + env.store.mark_initial() + client = TestClient(env.create_app()) + assert client.get("/go").json()["initial_state"] == SEED + assert client.get("/api/session").json()["user"] == "alice" + assert client.get("/api/health").json() == {"status": "ok"} + assert client.get("/about").json() == {"page": "about"} and client.get("/api/other").json() == {"named": "other"} + + +def test_a_broad_route_never_serves_a_page_at_the_data_planes_or_cards_paths(dist: Path) -> None: + class Catchall(site(dist)): + @api.get("/{rest:path}") + def page(self, rest: str) -> dict: + return {"page": rest} + + client = TestClient(Catchall().create_app()) + for path in ("/agentenv/anything", "/agentenv/", "/x/../agentenv/y", "/.well-known/agent-env.json/"): + assert client.get(path).status_code == 404, path + assert client.get("/.well-known/agent-env.json").status_code == 200 + assert client.get("/docs/a").json() == {"page": "docs/a"} + + +def test_a_site_with_its_own_hooks_keeps_them_and_serves_no_go(dist: Path) -> None: + class Own(site(dist)): + @get_data + def export(self) -> list: + return [DataPart(data={"mine": True})] + + client = TestClient(Own().create_app()) + assert rpc(client, "data/get")["parts"][0]["data"] == {"mine": True} + assert client.get("/.well-known/agent-env.json").json()["capabilities"]["operations"] == ["data/reset", "data/add", "data/get"] + assert "home" in client.get("/go").text + + +def test_a_seed_data_file_is_the_initial_state(tmp_path: Path, dist: Path) -> None: + (tmp_path / "seed.json").write_text(json.dumps(SEED)) + env = site(dist, data=str(tmp_path / "seed.json"))() + assert env.store.export() == SEED and env.store.initial == SEED and env.acting_user() == "alice" + + +def test_the_acting_user_is_signed_in_until_a_session_says_else(client: TestClient) -> None: + assert client.get("/api/orders").json() == [] + first = client.get("/api/session") + assert first.json() == {"user": "alice", "role": "default", "signed_in": True} + cookie = first.headers["set-cookie"] + assert cookie.startswith(f"{SESSION_COOKIE}=") and "domain" not in cookie.lower() and "httponly" in cookie.lower() + client.post("/api/sign-in", json={"user": "bob"}) + assert client.get("/api/orders").json() == [{"id": 1, "user": "bob"}] + assert client.get("/api/session").json()["user"] == "bob" + assert client.delete("/api/session").json() == {"user": None, "role": "default", "signed_in": False} + assert client.get("/api/session").json()["user"] is None + client.cookies.set(SESSION_COOKIE, "forged.signature") + assert client.get("/api/session").json()["user"] == "alice" + + +def test_the_session_cookie_expires_when_the_session_does(client: TestClient) -> None: + from email.utils import parsedate_to_datetime + + cookie = client.get("/api/session").headers["set-cookie"] + expires = next(a.split("=", 1)[1] for a in cookie.split(";") if a.strip().lower().startswith("expires=")) + ahead = parsedate_to_datetime(expires) - datetime.datetime.now(datetime.timezone.utc) + assert datetime.timedelta(days=7) - datetime.timedelta(minutes=1) < ahead <= datetime.timedelta(days=7) + + +def test_the_session_operation_mints_cookies_for_a_user_or_signed_out(shop: WebsiteEnvironment) -> None: + client = TestClient(shop.create_app()) + minted = client.post("/agentenv/ext/website/session", json={"user": "bob"}).json() + assert minted["user"] == "bob" and minted["expires_at"].endswith("Z") + (cookie,) = minted["cookies"] + assert cookie["name"] == SESSION_COOKIE and "domain" not in cookie and cookie["path"] == "/" + browser = TestClient(shop.asgi, cookies={SESSION_COOKIE: cookie["value"]}) + assert browser.get("/api/session").json()["user"] == "bob" + signed_out = client.post("/agentenv/ext/website/session", json={}).json()["cookies"][0]["value"] + assert TestClient(shop.asgi, cookies={SESSION_COOKIE: signed_out}).get("/api/session").json()["user"] is None + + +def test_every_answer_carries_the_universe_time(client: TestClient) -> None: + for response in (client.get("/"), client.get("/api/orders"), client.get("/nope.js"), + client.post(RPC, json={"jsonrpc": "2.0", "id": 1, "method": "data/get"}), + client.get("/.well-known/agent-env.json")): + now = parse_time(response.headers[UNIVERSE_NOW_HEADER]) + assert abs((now - datetime.datetime.now(datetime.timezone.utc)).total_seconds()) < 60 + + +class _Clock: + """A gateway-shaped /clock/time whose time a test sets.""" + + def __init__(self, virtual: str) -> None: + self.virtual = virtual + outer = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + body = json.dumps({"virtual_time": outer.virtual}).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args) -> None: + return None + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.url = f"http://127.0.0.1:{self.server.server_port}/clock/time" + + def close(self) -> None: + self.server.shutdown() + self.server.server_close() + + +@pytest.fixture +def gateway_clock(): + clock = _Clock("2031-01-01T00:00:00Z") + yield clock + clock.close() + + +def test_sync_time_ties_now_and_the_header_to_the_gateway_clock(client: TestClient, shop: WebsiteEnvironment, + gateway_clock: _Clock, monkeypatch) -> None: + synced = client.post("/agentenv/ext/sync_time", json={"env_get_time_url": gateway_clock.url}).json() + assert synced == {"env_get_time_url": gateway_clock.url, "gateway_virtual_time": "2031-01-01T00:00:00.000Z"} + assert shop.now().year == 2031 + assert client.get("/").headers[UNIVERSE_NOW_HEADER].startswith("2031-01-01T00:00:0") + order = client.post("/api/orders", json={"sku": "pen"}).json() + assert order["at"].startswith("2031-01-01") + gateway_clock.virtual = "2031-06-01T12:00:00Z" + monkeypatch.setattr(clock_module, "REFRESH_S", 0.0) + assert client.get("/").headers[UNIVERSE_NOW_HEADER].startswith("2031-06-01T12:00") + + +def test_sync_time_takes_the_universe_s_time_zone_and_a_later_sync_without_one_drops_it( + client: TestClient, shop: WebsiteEnvironment, gateway_clock: _Clock) -> None: + method = _extension(client.get("/.well-known/agent-env.json").json(), "urn:agentenv:clock/v1")["params"]["methods"]["sync_time"] + assert "timezone" in method["request"]["properties"] and method["request"]["required"] == ["env_get_time_url"] + zoned = client.post("/agentenv/ext/sync_time", json={"env_get_time_url": gateway_clock.url, "timezone": " America/Chicago "}) + assert zoned.json()["timezone"] == "America/Chicago" == shop.timezone + assert shop.now().tzinfo == datetime.timezone.utc + unzoned = client.post("/agentenv/ext/sync_time", json={"env_get_time_url": gateway_clock.url}).json() + assert "timezone" not in unzoned and shop.timezone is None + + +def test_a_clock_that_cannot_be_read_is_refused_and_a_lost_one_extrapolates(client: TestClient, + shop: WebsiteEnvironment, + gateway_clock: _Clock, monkeypatch) -> None: + refused = client.post("/agentenv/ext/sync_time", json={"env_get_time_url": "http://127.0.0.1:9/clock/time"}) + assert refused.status_code == 500 and shop.now().year == datetime.datetime.now().year + client.post("/agentenv/ext/sync_time", json={"env_get_time_url": gateway_clock.url}) + gateway_clock.close() + monkeypatch.setattr(clock_module, "REFRESH_S", 0.0) + assert client.get("/").headers[UNIVERSE_NOW_HEADER].startswith("2031-01-01") + + +def test_clock_patch_puts_the_clock_script_in_html_only(dist: Path, gateway_clock: _Clock) -> None: + client = TestClient(site(dist, clock_patch=True)().create_app()) + client.post("/agentenv/ext/sync_time", json={"env_get_time_url": gateway_clock.url}) + page = client.get("/") + head = page.text.index("") + len("") + assert page.text[head:].startswith(" + + diff --git a/packages/agentenv-protocol/examples/website/notes/web/package-lock.json b/packages/agentenv-protocol/examples/website/notes/web/package-lock.json new file mode 100644 index 000000000..938f9c2cd --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/package-lock.json @@ -0,0 +1,1020 @@ +{ + "name": "notes-site-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "notes-site-web", + "version": "0.0.0", + "devDependencies": { + "vite": "5.4.10" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.2.tgz", + "integrity": "sha512-qB7Gzl1QI45zP1zXgqLKzjFmOBGQw2s2MLtdw0eB4xdFGPNE8LhmOnItFgyYJXXNSW8Go9c2ovvXrUUzw5sAMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.2.tgz", + "integrity": "sha512-8+hXNO1J+GsYCGHwyJuBlnhyozivEvIEm/P6NkJeuoVUN+60TDvYYXk38UTm5bKRTLhIpqEQYx4d9CeN6kjh0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.2.tgz", + "integrity": "sha512-/uEZY+tcWDE8DXa/rajU3Bwv6IfvQavvzjmbkn39GgQenT+UYtAxfEMdB7VKr74y8aonncm0eQa1EbiNfg+Cjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.2.tgz", + "integrity": "sha512-3tCLEgakEvb5M3/KyYdhPjoteyWYziMTeznoXfcs/31eeFu+Vgs0VNRlb6Zc+YC07/sLa84OwTDF5zHOMFk92Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.2.tgz", + "integrity": "sha512-z31ODzkdkX7OOJCssvpz++hhtkQKWKw2L0eO2HAxFP7RMSXXedzkrTjesqCUxs4XLckRShjNfxsNxjGY5DQCGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.2.tgz", + "integrity": "sha512-CTvrb0KTAQqcrpcj7rg8FgAI0+jRWnCl+En6exqXDZztCo7vspxlRUX8z9Hp/J/ja2XRnQ1JDbexkBtRyD9jSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.2.tgz", + "integrity": "sha512-SJdblZpBkbOh1e4JwMM5pJJ2PdVmD3WWbMTXvncMSRLA3DdZuwU+P93RaSChOAXN4Mu53tBoFp1ADPzi3dIVAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.2.tgz", + "integrity": "sha512-pUBEd7uI9dAu3TpbPcvi9eU2aJslsjIomormeTaQHmzWNhyqNx3mmq+ekC6blGNZu8F3uTHngTibKp7tbblF0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.2.tgz", + "integrity": "sha512-wW6ARw2Ah0/teOBTgTeSJIpVdPKaxQmEgZ/irkUfr3F9sgimqpApCv/NbQzjm0Nfi5EFilHh1H8kz01uQLaq5Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.2.tgz", + "integrity": "sha512-xcFYMHNL42fRu2MFipvxznr+zd+DRqwL98bhNvDWbMPRcFtMYf34NAz4xVOlnEhzgpNRE5htSemcqAsAxf2eQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.2.tgz", + "integrity": "sha512-U9HrnJ9PFXqA//P/oEGaXBIJraEqzYMS9yuQOdOcPCvHZIC1FFOlnd8ocAaeDDPDpY8xOrhfxMRtr6KqKi3gyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.2.tgz", + "integrity": "sha512-2hZN8CFGfsbDw+DNtImkPRitpLNjVM2XEbi4ZQ5igLxMFD4gI/P6Zo9czGxuvQXtRyZtZyZp8Kr4ERsAipplBw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.2.tgz", + "integrity": "sha512-HZO7UfWEsReAnkdVByyQaFsNXJCtvdfun/LFb2Xd4qnhgF1ZfhikpbgqrTRtNMmB5OgNnSwFLiPobOT8oNT9iw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.2.tgz", + "integrity": "sha512-AdYIihuLkJ2TScy9BpSPiDex3zOMjkNJLsFlk9NeJ0U3WEtOx4W8z0nOkPEhDS2S4gwsRWBa6ZZexJ9e6OxyzQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.2.tgz", + "integrity": "sha512-iZE0NXcAGqLoCWeHRPkBxJe93JT4Q981z1ZqR+y7qAiIksdRlWhw8hmqHFo1B4ZnU66He31NUb52Iyv+vwMEBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.2.tgz", + "integrity": "sha512-vC7EAtJpSqSrrbg8huMfgxtwlR8fUsFCe5rStDk1WA8POky/6EEp52dxENMmqLKjSfdM3TT1WWPcZ01hiYZcdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.2.tgz", + "integrity": "sha512-DdqG6x72MHpQOlkK/cqrru9b2QMJHLkNojCB3bteB5fmkE/7XEAkReJMANp8wgef8eiEC99SvdP/BH7Ho/BpLA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.2.tgz", + "integrity": "sha512-F10mjdnRmGg9K+LGdqQVeUYFr08f3fVBOh7YNE5p1KIpTzhtKKOLqcQEsqZG6yEM2eouXaMo2AZzeDAcseVTrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.2.tgz", + "integrity": "sha512-ChzK9f3iE4N5GInBquiZtzWOqh+CYS5nlLlXGJxeG3d2T9rk8L1Hxg0s+v2Eh6vmAc/1T6IhOVkQU0ORP2eV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.2.tgz", + "integrity": "sha512-95WwzC+KH9hPHtbgwvwwW6MkKME6vMAdAQLS2diCvoi/iUiYdC0Y1RIPYVESx0iWqkN5ZaZq6qD1BflLcyTMEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.2.tgz", + "integrity": "sha512-eKbpwlolQmDKd/dbFjvqB1+2qdYQLrgxi3Mtbijp7/3yBBvO9OoHtQ3ecYIaCdW46ijSjy5/yydyrrxHI416/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.2.tgz", + "integrity": "sha512-is9lnlv6khapl+H9Hjp0gIVsUVtDxQga2nwMHpQbhDXMCMGcNmlel4/q9ihf/3FvvbL4GdGOPOGvWoei2RCggg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.2.tgz", + "integrity": "sha512-yTq+7YjsgbBcMdlk7Wq+VoJilGQtdtRc4PyTvN7vhO958atCO2nQeIjCF/YANSD/Z/fnrCr27fYsGgC6PFTndQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.67.2.tgz", + "integrity": "sha512-nAvZib4Xz1zx8OKhtRuyCsSmCmbfegv1AGbfpG+CowQKxCBW1pLlfYWjcr1+GI3BzRKQbBl7l9640GwWAwOF7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.2.tgz", + "integrity": "sha512-R3CVbEbNa1OXjpldD1tOLarb5rB08xjQiRMpe7PZKtT8eHCZhrV4ClSPfLLbSgRMM1RfLtmAfouBRf14f47lMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.2.tgz", + "integrity": "sha512-DeuVFilvaGtoS9TFIbTIQ0Kt4CBqP3xnjJ8SFqWWbXlFbrpmCdivEW7Rm38trRA8rZzQQ8lhs73nEetWotJpTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.67.2.tgz", + "integrity": "sha512-sFZk5BeRUQzkjmuClrJI41BowIP4yv/40d5gM9vtjxVLEt+8JG9FGlBps6cliTP2KhFiw22oqNGo0G9pVpPZlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.67.2", + "@rollup/rollup-android-arm64": "4.67.2", + "@rollup/rollup-android-x64": "4.67.2", + "@rollup/rollup-darwin-arm64": "4.67.2", + "@rollup/rollup-darwin-x64": "4.67.2", + "@rollup/rollup-freebsd-arm64": "4.67.2", + "@rollup/rollup-freebsd-x64": "4.67.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.67.2", + "@rollup/rollup-linux-arm-musleabihf": "4.67.2", + "@rollup/rollup-linux-arm64-gnu": "4.67.2", + "@rollup/rollup-linux-arm64-musl": "4.67.2", + "@rollup/rollup-linux-loong64-gnu": "4.67.2", + "@rollup/rollup-linux-loong64-musl": "4.67.2", + "@rollup/rollup-linux-ppc64-gnu": "4.67.2", + "@rollup/rollup-linux-ppc64-musl": "4.67.2", + "@rollup/rollup-linux-riscv64-gnu": "4.67.2", + "@rollup/rollup-linux-riscv64-musl": "4.67.2", + "@rollup/rollup-linux-s390x-gnu": "4.67.2", + "@rollup/rollup-linux-x64-gnu": "4.67.2", + "@rollup/rollup-linux-x64-musl": "4.67.2", + "@rollup/rollup-openbsd-x64": "4.67.2", + "@rollup/rollup-openharmony-arm64": "4.67.2", + "@rollup/rollup-win32-arm64-msvc": "4.67.2", + "@rollup/rollup-win32-ia32-msvc": "4.67.2", + "@rollup/rollup-win32-x64-gnu": "4.67.2", + "@rollup/rollup-win32-x64-msvc": "4.67.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/packages/agentenv-protocol/examples/website/notes/web/package.json b/packages/agentenv-protocol/examples/website/notes/web/package.json new file mode 100644 index 000000000..c741095dd --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/package.json @@ -0,0 +1,14 @@ +{ + "name": "notes-site-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "5.4.10" + } +} diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js new file mode 100644 index 000000000..f4318fabf --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -0,0 +1,210 @@ +import "./style.css"; + +let offsetMs = 0; +let filter = "all"; +let renders = 0; +const saving = new Map(); +const lastSent = new Map(); +const landed = new Map(); +const CLOCK_REFRESH_MS = 5000; + +export function universeNow() { + return new Date(Date.now() + offsetMs); +} + +function followUniverse(response) { + const at = Date.parse(response.headers.get("X-Universe-Now") || ""); + if (!Number.isNaN(at)) offsetMs = at - Date.now(); +} + +async function api(path, { method = "GET", body } = {}) { + const response = await fetch(path, { + method, + headers: body === undefined ? {} : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + followUniverse(response); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || `${method} ${path}: ${response.status}`); + return data; +} + +const $ = (id) => document.getElementById(id); + +function queueSave(id, fields) { + lastSent.set(id, fields); + const queued = saving.get(id); + if (queued) { + queued.next = fields; + return; + } + const state = { next: fields }; + saving.set(id, state); + run(async () => { + try { + while (state.next) { + const next = state.next; + state.next = null; + await api(`api/notes/${id}`, { method: "PATCH", body: next }); + } + landed.set(id, renders); + } catch (error) { + lastSent.set(id, {}); + landed.delete(id); + throw error; + } finally { + saving.delete(id); + } + }); +} + +function showError(error) { + $("error").textContent = error ? error.message : ""; + $("error").hidden = !error; +} + +function formatTime(value) { + return new Date(value).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }); +} + +function tick() { + $("universe-now").textContent = `${formatTime(universeNow())} UTC`; + $("universe-now").dateTime = universeNow().toISOString(); +} + +function noteItem(note) { + const item = document.createElement("li"); + item.className = `note card${note.done ? " done" : ""}`; + item.dataset.id = note.id; + + const done = document.createElement("input"); + done.type = "checkbox"; + done.checked = note.done; + done.setAttribute("aria-label", `Mark "${note.title}" ${note.done ? "not done" : "done"}`); + done.addEventListener("change", () => run(() => api(`api/notes/${note.id}/toggle`, { method: "POST" }))); + + const title = document.createElement("input"); + title.className = "title"; + title.defaultValue = note.title; + title.setAttribute("aria-label", "Title"); + + const body = document.createElement("textarea"); + body.className = "body"; + body.rows = 2; + body.defaultValue = note.body; + body.placeholder = "Details"; + body.setAttribute("aria-label", "Details"); + + const save = () => { + const sent = lastSent.get(note.id) ?? { title: note.title, body: note.body }; + if (title.value === sent.title && body.value === sent.body) return; + queueSave(note.id, { title: title.value, body: body.value }); + }; + for (const field of [title, body]) { + field.addEventListener("change", save); + field.addEventListener("blur", save); + } + + const meta = document.createElement("small"); + meta.textContent = `Created ${formatTime(note.created_at)} · updated ${formatTime(note.updated_at)}`; + + const remove = document.createElement("button"); + remove.className = "delete"; + remove.textContent = "Delete"; + remove.addEventListener("click", () => run(() => api(`api/notes/${note.id}`, { method: "DELETE" }))); + + const text = document.createElement("div"); + text.className = "text"; + text.append(title, body, meta); + item.append(done, text, remove); + return item; +} + +function editing() { + const kept = []; + for (const field of document.querySelectorAll("#notes .title, #notes .body")) { + const focused = field === document.activeElement; + if (focused || field.value !== field.defaultValue) { + kept.push({ id: field.closest("li").dataset.id, kind: field.className, value: field.value, focused, + start: field.selectionStart, end: field.selectionEnd }); + } + } + return kept; +} + +function restore(kept) { + for (const { id, kind, value, focused, start, end } of kept) { + const field = document.querySelector(`#notes li[data-id="${id}"] .${kind}`); + if (!field) continue; + field.value = value; + if (focused) { + field.focus(); + field.setSelectionRange(start, end); + } + } +} + +async function render() { + const query = filter === "all" ? "" : `?done=${filter === "done"}`; + const request = ++renders; + const notes = await api(`api/notes${query}`); + if (request !== renders) return; + for (const [id, after] of landed) { + if (request > after && !saving.has(id)) { + lastSent.delete(id); + landed.delete(id); + } + } + const kept = editing(); + $("notes").replaceChildren(...notes.map(noteItem)); + restore(kept); + if (!notes.length) { + const empty = document.createElement("li"); + empty.className = "empty"; + empty.textContent = "No notes here yet."; + $("notes").append(empty); + } +} + +async function run(action) { + try { + await action(); + showError(null); + await render(); + } catch (error) { + showError(error); + } +} + +async function start() { + const [session, users] = await Promise.all([api("api/session"), api("api/users")]); + $("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id))); + $("user").value = session.user ?? ""; + $("user").addEventListener("change", (event) => + run(() => api("api/sign-in", { method: "POST", body: { user: event.target.value } }))); + + $("new-note").addEventListener("submit", (event) => { + event.preventDefault(); + const body = { title: $("new-title").value, body: $("new-body").value }; + run(async () => { + await api("api/notes", { method: "POST", body }); + if ($("new-title").value === body.title) $("new-title").value = ""; + if ($("new-body").value === body.body) $("new-body").value = ""; + }); + }); + + for (const button of document.querySelectorAll("[data-filter]")) { + button.addEventListener("click", () => { + filter = button.dataset.filter; + document.querySelectorAll("[data-filter]").forEach((b) => b.classList.toggle("active", b === button)); + run(async () => {}); + }); + } + + tick(); + setInterval(tick, 1000); + setInterval(() => fetch("healthz").then(followUniverse).catch(() => {}), CLOCK_REFRESH_MS); + await render(); +} + +start().catch(showError); diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/style.css b/packages/agentenv-protocol/examples/website/notes/web/src/style.css new file mode 100644 index 000000000..8ecd48b6a --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/src/style.css @@ -0,0 +1,57 @@ +:root { + --bg: #f6f6f3; + --card: #ffffff; + --text: #1d1d1b; + --muted: #6b6b66; + --line: #deded8; + --accent: #2f5bd3; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + color: var(--text); + background: var(--bg); +} + +* { box-sizing: border-box; } +body { margin: 0; } + +.bar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--line); + background: var(--card); +} +.bar h1 { margin: 0; font-size: 1.25rem; } +.who { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; color: var(--muted); font-size: 0.9rem; } +.who time { font-variant-numeric: tabular-nums; } + +main { max-width: 720px; margin: 0 auto; padding: 16px; } + +.card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px; } + +input, textarea, select, button { font: inherit; color: inherit; } +input, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: transparent; } +textarea { resize: vertical; } +button { border: 1px solid var(--line); border-radius: 6px; padding: 6px 12px; background: var(--card); cursor: pointer; } +button[type="submit"], .filters .active { background: var(--accent); border-color: var(--accent); color: #fff; } + +#new-note { display: grid; gap: 8px; } +#new-note button { justify-self: end; } + +.filters { display: flex; gap: 8px; margin: 16px 0; } + +#error { color: #b3261e; margin: 0 0 12px; } + +#notes { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.note { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: start; } +.note .text { display: grid; gap: 6px; min-width: 0; } +.note .title { font-weight: 600; border-color: transparent; } +.note .body { border-color: transparent; color: var(--muted); } +.note .title:focus, .note .body:focus { border-color: var(--line); } +.note small { color: var(--muted); } +.note.done .title { text-decoration: line-through; color: var(--muted); } +.note input[type="checkbox"] { width: auto; margin-top: 10px; } +.delete { color: #b3261e; } +.empty { color: var(--muted); text-align: center; padding: 24px; } diff --git a/packages/agentenv-protocol/examples/website/notes/web/vite.config.js b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js new file mode 100644 index 000000000..d07ecd2c2 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + base: "./", + server: { + proxy: { + "/api": "http://127.0.0.1:8000", + "/healthz": "http://127.0.0.1:8000", + }, + }, +}); diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py new file mode 100644 index 000000000..e02147b10 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -0,0 +1,237 @@ +"""The Notes example site (examples/website/notes) as website/v1's reference site: it passes the conformance checker, +and its own behavior holds: the acting user's auto sign-in and signing in as another user, notes kept per user, the +admin-only route, timestamps from the universe's clock, and the store's seed loaded, exported and reset through the +data plane.""" + +from __future__ import annotations + +import importlib.util +import json +import re +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urljoin, urlsplit + +import pytest +from starlette.responses import PlainTextResponse +from starlette.testclient import TestClient + +from agentenv_protocol.website import (ROLE_KEY_ENV, ROLE_KEY_HEADER, SESSION_COOKIE, UNIVERSE_NOW_HEADER, WebsiteEnvironment, + conformance, website) + +NOTES_DIR = Path(__file__).resolve().parents[1] / "examples" / "website" / "notes" +SEED = json.loads((NOTES_DIR / "data" / "seed.json").read_text(encoding="utf-8")) +RPC = "/agentenv" + + +def _load_site(): + """The example's ``site.py``, imported under a name of its own: ``site`` is the standard library's.""" + spec = importlib.util.spec_from_file_location("notes_example_site", NOTES_DIR / "site.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +notes_site = _load_site() + + +def rpc(client: TestClient, method: str, params: dict | None = None) -> dict: + body = client.post(RPC, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}).json() + assert "error" not in body, body + return body["result"] + + +@pytest.fixture +def notes_class(tmp_path: Path) -> type[WebsiteEnvironment]: + """The example's class, serving a stand-in page instead of ``web/dist``, which only a Vite build makes.""" + (tmp_path / "index.html").write_text("Notes") + declared = notes_site.Notes.declaration()._replace(static=str(tmp_path), data=str(NOTES_DIR / "data" / "seed.json")) + return website(**declared._asdict())(type("Notes", (notes_site.Notes,), {})) + + +@pytest.fixture +def env(notes_class: type[WebsiteEnvironment]) -> WebsiteEnvironment: + return notes_class() + + +@pytest.fixture +def app(env: WebsiteEnvironment): + return env.create_app() + + +@pytest.fixture +def client(app) -> TestClient: + return TestClient(app) + + +class _Clock: + """A gateway-shaped /clock/time whose time a test sets.""" + + def __init__(self, virtual: str) -> None: + self.virtual = virtual + outer = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + body = json.dumps({"virtual_time": outer.virtual}).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args) -> None: + return None + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.url = f"http://127.0.0.1:{self.server.server_port}/clock/time" + + def close(self) -> None: + self.server.shutdown() + self.server.server_close() + + +@pytest.fixture +def gateway_clock(): + clock = _Clock("2040-02-03T04:05:06Z") + yield clock + clock.close() + + +def test_the_example_passes_every_conformance_check(client: TestClient) -> None: + results = conformance.check("http://testserver", client=client) + assert {r.name: r.status for r in results} == dict.fromkeys( + ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"], "passed"), results + + +def test_the_card_declares_the_site_its_health_path_and_its_routes_by_name(client: TestClient) -> None: + card = client.get("/.well-known/agent-env.json").json() + site = next(e for e in card["capabilities"]["extensions"] if e["uri"] == "urn:agentenv:website/v1")["params"] + assert card["name"] == "notes" + assert (site["domains"], site["health"]) == (["notes.test"], "/healthz") + assert [r["name"] for r in site["routes"]] == ["list_users", "sign_in", "list_notes", "create_note", "get_note", + "edit_note", "toggle_note", "delete_note", "all_notes"] + assert next(r for r in site["routes"] if r["name"] == "all_notes")["roles"] == ["admin"] + assert client.get("/healthz").json() == {"status": "ok"} + + +def test_a_browser_is_signed_in_as_the_acting_user_and_can_sign_in_as_another(client: TestClient) -> None: + session = client.get("/api/session") + assert session.json() == {"user": "ann", "role": "default", "signed_in": True} + assert SESSION_COOKIE in session.cookies + assert [n["title"] for n in client.get("/api/notes").json()] == ["Buy milk", "Book dentist"] + assert client.post("/api/sign-in", json={"user": "ben"}).json() == {"user": "ben"} + assert client.get("/api/session").json()["user"] == "ben" + assert [n["title"] for n in client.get("/api/notes").json()] == ["Draft the quarterly report"] + refused = client.post("/api/sign-in", json={"user": "mallory"}) + assert refused.status_code == 404 and refused.json()["code"] == "not_found" + + +def test_notes_are_created_edited_toggled_and_deleted_by_their_owner_only(app, client: TestClient) -> None: + created = client.post("/api/notes", json={"title": " Call Sam ", "body": "about the trip"}) + assert created.status_code == 201 + note = created.json() + assert (note["owner"], note["title"], note["done"]) == ("ann", "Call Sam", False) + assert note["created_at"] == note["updated_at"] + + ben = TestClient(app) + ben.post("/api/sign-in", json={"user": "ben"}) + assert "Call Sam" not in [n["title"] for n in ben.get("/api/notes").json()] + for response in (ben.get(f"/api/notes/{note['id']}"), ben.patch(f"/api/notes/{note['id']}", json={"done": True}), + ben.post(f"/api/notes/{note['id']}/toggle"), ben.delete(f"/api/notes/{note['id']}")): + assert response.status_code == 404, response.text + + edited = client.patch(f"/api/notes/{note['id']}", json={"body": "about the trip, before Friday"}).json() + assert (edited["title"], edited["body"]) == ("Call Sam", "about the trip, before Friday") + assert client.post(f"/api/notes/{note['id']}/toggle").json()["done"] is True + assert [n["title"] for n in client.get("/api/notes", params={"done": "true"}).json()] == ["Call Sam", "Book dentist"] + assert client.patch(f"/api/notes/{note['id']}", json={"title": " "}).json()["code"] == "empty_title" + assert client.post("/api/notes", json={"body": "no title"}).status_code == 400 + assert client.delete(f"/api/notes/{note['id']}").json() == {"deleted": note["id"]} + assert client.get(f"/api/notes/{note['id']}").status_code == 404 + + +def test_every_users_notes_are_listed_for_an_admin_caller_only(client: TestClient, + monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ROLE_KEY_ENV, "k3y") + for headers in ({}, {"AgentEnv-Role": "agent", ROLE_KEY_HEADER: "k3y"}, {"AgentEnv-Role": "admin"}, + {"AgentEnv-Role": "admin", ROLE_KEY_HEADER: "guess"}): + refused = client.get("/api/admin/notes", headers=headers) + assert refused.status_code == 403, headers + every = client.get("/api/admin/notes", headers={"AgentEnv-Role": "admin", ROLE_KEY_HEADER: "k3y"}).json() + assert {n["owner"] for n in every} == {"ann", "ben"} + + +def test_timestamps_follow_the_universes_clock_once_synced(client: TestClient, gateway_clock: _Clock) -> None: + before = client.post("/api/notes", json={"title": "before the sync"}).json() + assert not before["created_at"].startswith("2040") + client.post("/agentenv/ext/sync_time", json={"env_get_time_url": gateway_clock.url}) + after = client.post("/api/notes", json={"title": "after the sync"}) + assert after.json()["created_at"].startswith("2040-02-03T04:05") + assert after.headers[UNIVERSE_NOW_HEADER].startswith("2040-02-03T04:05") + toggled = client.post(f"/api/notes/{before['id']}/toggle").json() + assert toggled["created_at"] == before["created_at"] and toggled["updated_at"].startswith("2040-02-03T04:05") + + +def test_the_seed_loads_exports_and_resets_through_the_data_plane(client: TestClient) -> None: + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + rpc(client, "data/reset") + assert rpc(client, "data/get")["parts"][0]["data"] == {} + assert client.get("/api/notes").status_code == 401 + assert client.get("/healthz").status_code == 503 + + seed_file = {"kind": "file", "file": {"uri": (NOTES_DIR / "data" / "seed.json").as_uri(), + "mimeType": "application/json"}} + rpc(client, "data/add", {"parts": [seed_file]}) + assert client.get("/healthz").status_code == 200 + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + assert client.get("/api/session").json()["user"] == "ann" + + client.post("/api/notes/1/toggle") + state = client.get("/go").json() + assert state["initial_state"] == SEED + assert state["state_diff"]["notes"]["changed"][0]["after"]["done"] is True + + +GATEWAY_PREFIX = "/website/notes" + + +def _behind_gateway_prefix(app): + """The gateway's ``/website//`` route: a request under the prefix reaches the site with the prefix taken off, + and any other one answers the gateway's own 404.""" + + async def gateway(scope, receive, send): + if scope["type"] == "http" and scope["path"].startswith(GATEWAY_PREFIX + "/"): + path = scope["path"][len(GATEWAY_PREFIX):] + return await app({**scope, "path": path, "raw_path": path.encode()}, receive, send) + if scope["type"] != "http": + return await app(scope, receive, send) + await PlainTextResponse("no gateway route", status_code=404)(scope, receive, send) + + return gateway + + +@pytest.mark.skipif(not (NOTES_DIR / "web" / "dist" / "index.html").is_file(), reason="web/dist needs a Vite build") +def test_the_built_page_loads_its_assets_and_api_under_the_gateways_prefix() -> None: + page_url = f"http://gateway.test{GATEWAY_PREFIX}/" + browser = TestClient(_behind_gateway_prefix(notes_site.Notes().create_app()), base_url="http://gateway.test") + page = browser.get(page_url) + assert page.status_code == 200 and page.headers["content-type"].startswith("text/html") + + assets = [urljoin(page_url, ref) for ref in re.findall(r'(?:src|href)="([^"]+)"', page.text)] + assert assets and all(urlsplit(url).path.startswith(GATEWAY_PREFIX + "/") for url in assets), assets + scripts = [] + for url in assets: + fetched = browser.get(url) + assert fetched.status_code == 200, url + if url.endswith(".js"): + scripts.append(fetched.text) + + called = {path for script in scripts for path in re.findall(r'["`](/?api/[\w-]+)', script)} + assert {"api/session", "api/users", "api/notes", "api/sign-in"} <= called + assert not [path for path in called if path.startswith("/")], called + assert browser.get(urljoin(page_url, "api/session")).json()["user"] == "ann" + assert [u["id"] for u in browser.get(urljoin(page_url, "api/users")).json()] == ["ann", "ben"] From 4ee1be2eda14b9e81161edadb85f902efb737ca1 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 14:22:37 -0800 Subject: [PATCH 09/57] fix(website): the Notes page saves queued edits before switching users, and adds a note once per submit (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/web/src/main.js | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index f4318fabf..d061a6cea 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -40,7 +40,7 @@ function queueSave(id, fields) { } const state = { next: fields }; saving.set(id, state); - run(async () => { + state.done = run(async () => { try { while (state.next) { const next = state.next; @@ -180,14 +180,31 @@ async function start() { const [session, users] = await Promise.all([api("api/session"), api("api/users")]); $("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id))); $("user").value = session.user ?? ""; - $("user").addEventListener("change", (event) => - run(() => api("api/sign-in", { method: "POST", body: { user: event.target.value } }))); + $("user").addEventListener("change", (event) => { + const user = event.target.value; + $("user").disabled = true; + run(async () => { + try { + while (saving.size) await Promise.all([...saving.values()].map((state) => state.done)); + await api("api/sign-in", { method: "POST", body: { user } }); + } finally { + $("user").disabled = false; + } + }); + }); + const add = $("new-note").querySelector("button[type=submit]"); $("new-note").addEventListener("submit", (event) => { event.preventDefault(); + if (add.disabled) return; const body = { title: $("new-title").value, body: $("new-body").value }; + add.disabled = true; run(async () => { - await api("api/notes", { method: "POST", body }); + try { + await api("api/notes", { method: "POST", body }); + } finally { + add.disabled = false; + } if ($("new-title").value === body.title) $("new-title").value = ""; if ($("new-body").value === body.body) $("new-body").value = ""; }); From 687c4a98852671eff1ab3589ac4fde5bb069ba7d Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 15:01:02 -0800 Subject: [PATCH 10/57] fix(website): the Notes page keeps the signed-in user when a save or sign-in fails, and reloads itself at its slash-ending address (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/README.md | 2 +- .../examples/website/notes/web/index.html | 3 +++ .../examples/website/notes/web/src/main.js | 13 +++++++++++-- .../tests/test_website_notes_example.py | 1 + 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index 3311112b0..67bac1ae3 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -8,7 +8,7 @@ them. The page shows the universe's time. | --- | --- | | `site.py` | The server: the `Notes` WebsiteEnvironment, its `@api` routes, and `serve()` on `PORT` (8000). | | `data/seed.json` | The seed data: two users (`ann`, `ben`), their notes, and `current_user`, the acting user. | -| `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike. | +| `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike; opened at a path without its final slash, such as `/website/notes`, the page first reloads itself with it. | | `Dockerfile` | The server image: Node builds `web/`, then Python installs this repo's `packages/agentenv-protocol` with uvicorn. It installs `curl`, which the gateway's compose health check runs. | | `env.toml` | The website env as a bundle entry. | diff --git a/packages/agentenv-protocol/examples/website/notes/web/index.html b/packages/agentenv-protocol/examples/website/notes/web/index.html index 1e498d3c1..468ef6385 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/index.html +++ b/packages/agentenv-protocol/examples/website/notes/web/index.html @@ -4,6 +4,9 @@ Notes +
diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index d061a6cea..fafffe38f 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -49,6 +49,7 @@ function queueSave(id, fields) { } landed.set(id, renders); } catch (error) { + state.error = error; lastSent.set(id, {}); landed.delete(id); throw error; @@ -179,15 +180,23 @@ async function run(action) { async function start() { const [session, users] = await Promise.all([api("api/session"), api("api/users")]); $("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id))); - $("user").value = session.user ?? ""; + let signedInAs = session.user ?? ""; + $("user").value = signedInAs; $("user").addEventListener("change", (event) => { const user = event.target.value; $("user").disabled = true; run(async () => { try { - while (saving.size) await Promise.all([...saving.values()].map((state) => state.done)); + while (saving.size) { + const pending = [...saving.values()]; + await Promise.all(pending.map((state) => state.done)); + const failed = pending.find((state) => state.error); + if (failed) throw failed.error; + } await api("api/sign-in", { method: "POST", body: { user } }); + signedInAs = user; } finally { + $("user").value = signedInAs; $("user").disabled = false; } }); diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index e02147b10..c7ae6212d 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -220,6 +220,7 @@ def test_the_built_page_loads_its_assets_and_api_under_the_gateways_prefix() -> browser = TestClient(_behind_gateway_prefix(notes_site.Notes().create_app()), base_url="http://gateway.test") page = browser.get(page_url) assert page.status_code == 200 and page.headers["content-type"].startswith("text/html") + assert page.text.index('location.pathname.endsWith("/")') < page.text.index(" diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index fafffe38f..61ead9d31 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -6,6 +6,7 @@ let renders = 0; const saving = new Map(); const lastSent = new Map(); const landed = new Map(); +const drafts = new Map(); const CLOCK_REFRESH_MS = 5000; export function universeNow() { @@ -31,6 +32,21 @@ async function api(path, { method = "GET", body } = {}) { const $ = (id) => document.getElementById(id); +function setDraft(id, kind, value, saved) { + const draft = { ...drafts.get(id) }; + if (value === saved) delete draft[kind]; + else draft[kind] = value; + if (Object.keys(draft).length) drafts.set(id, draft); + else drafts.delete(id); +} + +function settleDraft(id, sent) { + const draft = drafts.get(id); + if (!draft) return; + for (const kind of Object.keys(sent)) if (draft[kind] === sent[kind]) delete draft[kind]; + if (!Object.keys(draft).length) drafts.delete(id); +} + function queueSave(id, fields) { lastSent.set(id, fields); const queued = saving.get(id); @@ -46,6 +62,7 @@ function queueSave(id, fields) { const next = state.next; state.next = null; await api(`api/notes/${id}`, { method: "PATCH", body: next }); + settleDraft(id, next); } landed.set(id, renders); } catch (error) { @@ -84,15 +101,18 @@ function noteItem(note) { done.setAttribute("aria-label", `Mark "${note.title}" ${note.done ? "not done" : "done"}`); done.addEventListener("change", () => run(() => api(`api/notes/${note.id}/toggle`, { method: "POST" }))); + const draft = drafts.get(note.id) ?? {}; const title = document.createElement("input"); title.className = "title"; title.defaultValue = note.title; + title.value = draft.title ?? note.title; title.setAttribute("aria-label", "Title"); const body = document.createElement("textarea"); body.className = "body"; body.rows = 2; body.defaultValue = note.body; + body.value = draft.body ?? note.body; body.placeholder = "Details"; body.setAttribute("aria-label", "Details"); @@ -101,7 +121,8 @@ function noteItem(note) { if (title.value === sent.title && body.value === sent.body) return; queueSave(note.id, { title: title.value, body: body.value }); }; - for (const field of [title, body]) { + for (const [kind, field] of [["title", title], ["body", body]]) { + field.addEventListener("input", () => setDraft(note.id, kind, field.value, note[kind])); field.addEventListener("change", save); field.addEventListener("blur", save); } @@ -112,7 +133,10 @@ function noteItem(note) { const remove = document.createElement("button"); remove.className = "delete"; remove.textContent = "Delete"; - remove.addEventListener("click", () => run(() => api(`api/notes/${note.id}`, { method: "DELETE" }))); + remove.addEventListener("click", () => run(async () => { + await api(`api/notes/${note.id}`, { method: "DELETE" }); + drafts.delete(note.id); + })); const text = document.createElement("div"); text.className = "text"; @@ -121,28 +145,19 @@ function noteItem(note) { return item; } -function editing() { - const kept = []; - for (const field of document.querySelectorAll("#notes .title, #notes .body")) { - const focused = field === document.activeElement; - if (focused || field.value !== field.defaultValue) { - kept.push({ id: field.closest("li").dataset.id, kind: field.className, value: field.value, focused, - start: field.selectionStart, end: field.selectionEnd }); - } - } - return kept; +function focused() { + const field = document.activeElement; + if (!field?.matches?.("#notes .title, #notes .body")) return null; + return { id: field.closest("li").dataset.id, kind: field.className, start: field.selectionStart, + end: field.selectionEnd }; } -function restore(kept) { - for (const { id, kind, value, focused, start, end } of kept) { - const field = document.querySelector(`#notes li[data-id="${id}"] .${kind}`); - if (!field) continue; - field.value = value; - if (focused) { - field.focus(); - field.setSelectionRange(start, end); - } - } +function refocus(kept) { + if (!kept) return; + const field = document.querySelector(`#notes li[data-id="${kept.id}"] .${kept.kind}`); + if (!field) return; + field.focus(); + field.setSelectionRange(kept.start, kept.end); } async function render() { @@ -156,9 +171,9 @@ async function render() { landed.delete(id); } } - const kept = editing(); + const kept = focused(); $("notes").replaceChildren(...notes.map(noteItem)); - restore(kept); + refocus(kept); if (!notes.length) { const empty = document.createElement("li"); empty.className = "empty"; @@ -195,6 +210,7 @@ async function start() { } await api("api/sign-in", { method: "POST", body: { user } }); signedInAs = user; + drafts.clear(); } finally { $("user").value = signedInAs; $("user").disabled = false; From 8389f76db9419e5c087ae048ef536b7c2a39a5f7 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 13:34:07 -0800 Subject: [PATCH 13/57] feat(gateway): a keyed deploy's role key, forwarded headers, and triggers by route (FD-4683) This PR's change, rebased as one commit onto #429. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/README.md | 6 +- .../agentenv_protocol/website/environment.py | 21 +- .../src/agentenv_protocol/website/routes.py | 24 +- .../tests/test_website_api.py | 13 ++ .../tests/test_website_environment.py | 12 + src/agent_env/a2a_agent/a2a_agent.py | 12 +- src/agent_env/cli/env/deploy.py | 7 +- src/agent_env/env/env.py | 9 +- src/agent_env/env/envs/mcp_server.py | 4 +- src/agent_env/env/envs/multi_env.py | 7 +- src/agent_env/env/envs/website.py | 4 +- src/agent_env/env/gateway/__init__.py | 2 + src/agent_env/env/gateway/caller.py | 3 +- src/agent_env/env/gateway/constants.py | 6 +- src/agent_env/env/gateway/entrypoint.py | 3 +- src/agent_env/env/gateway/gateway.py | 139 +++++++++--- src/agent_env/env/gateway/rest_proxy.py | 129 ++++++++++- src/agent_env/env/gateway/triggers.py | 36 ++- .../env_providers/env_gateway_provider.py | 39 +++- src/agent_env/task/episode/run.py | 9 +- src/agent_env/task/episode/steps.py | 5 +- .../task_step/task_steps/deploy_agent.py | 13 +- .../task_step/task_steps/deploy_env.py | 11 +- .../task_step/task_steps/load_artifact.py | 3 +- .../task_steps/mcp_cli_builder/codegen.py | 17 +- .../task_step/task_steps/post_turn_hooks.py | 3 +- .../task_steps/register_env_triggers.py | 6 +- tst/integration/env/gateway/gateway_test.py | 44 ++-- .../test_next_env_event_hook_local.py | 3 +- .../test_register_env_triggers_local.py | 25 ++- .../envs/test_mcp_server_plugin_provider.py | 2 +- tst/unit/env/envs/test_multi_env_name.py | 2 +- .../env/gateway/rest_proxy_stream_test.py | 212 ++++++++++++++++++ tst/unit/env/gateway/triggers_website_test.py | 188 +++++++++++++++- tst/unit/env/test_env.py | 10 +- .../env_gateway_provider_test.py | 52 ++++- tst/unit/task_step/deploy_env_test.py | 36 +++ .../task_step/local_container_urls_test.py | 32 ++- .../test_deploy_agent_role_delivery.py | 26 ++- tst/unit/task_step/test_load_artifact_file.py | 16 ++ tst/unit/task_step/test_mcp_cli_builder.py | 10 + .../test_prompt_agent_post_turn_hooks.py | 22 ++ .../task_step/test_register_env_triggers.py | 6 +- 43 files changed, 1074 insertions(+), 155 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index 67d54de43..eade87bde 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -20,8 +20,10 @@ them. The page shows the universe's time. `edit_note`, `toggle_note` and `delete_note`. Errors are raised as `NotFound`, `BadRequest` and `Unauthorized`. - **Roles.** `all_notes` (`GET /api/admin/notes`) is declared with `roles=["admin"]`. It answers only a caller that sends `AgentEnv-Role: admin` with `AgentEnv-Role-Key` equal to the container's `WEBSITE_ROLE_KEY`; any other caller - gets 403. The gateway passes on whatever role a browser sends, so without the key a page could claim `admin`; with no - `WEBSITE_ROLE_KEY` set, every caller is `default` and the route is closed. + gets 403. A deploy with `keyed_roles` gives the site its own role key as `WEBSITE_ROLE_KEY` and its gateway sends it + on only beside a role it verified with the same key, so a page cannot claim `admin` through it. Any other deploy sets + no key, and its gateway passes on whatever role a browser sends; with no `WEBSITE_ROLE_KEY` set, every caller is + `default` and the route is closed. - **Users.** `ApiContext.user` is the user in the signed session cookie. With no cookie, it is the acting user (the store's `current_user`), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded user. A user's notes are invisible to every other user, and another user's note answers 404. diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py index e9eaee9fa..ac05ba6be 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py @@ -16,12 +16,14 @@ import re from pathlib import Path from typing import Any, ClassVar, NamedTuple, Optional +from urllib.parse import unquote from starlette.applications import Starlette from starlette.datastructures import MutableHeaders from starlette.middleware import Middleware from starlette.requests import Request from starlette.responses import FileResponse, JSONResponse, Response +from starlette._utils import get_route_path from starlette.routing import Route from ..agent_env_environment import ( @@ -39,7 +41,8 @@ from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, UniverseClock, clock_patch_script, iso from .errors import NotFound from .readiness import WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness -from .routes import ApiContext, ApiRoute, endpoint, error_response, openapi, openapi_path, request_role, reserved_path, routes_of +from .routes import (ApiContext, ApiRoute, endpoint, error_response, forwarded_prefix, openapi, openapi_path, request_role, + reserved_path, routes_of) from .session import SESSION_COOKIE, SessionSigner, expires_at from .store import Collections @@ -284,9 +287,11 @@ def _check_health(cls: type) -> None: class _Forwarded: - """Puts the address the browser reached the site at in the scope, from ``X-Forwarded-Host`` and - ``X-Forwarded-Proto``, before anything routes it, so redirects, ``request.url`` and a mounted app's ``url_for`` - name that address and not the internal one the proxy reached the server at.""" + """Puts the address the browser reached the site at in the scope, from ``X-Forwarded-Host``, + ``X-Forwarded-Proto`` and ``X-Forwarded-Prefix``, before anything routes it, so redirects, ``request.url`` and a + mounted app's ``url_for`` name that address and not the internal one the proxy reached the server at. A prefix + becomes the scope's ``root_path`` and leads its ``path``, as ASGI has a mounted app's, so routing still sees the + site's own path.""" def __init__(self, app: Any) -> None: self.app = app @@ -301,6 +306,12 @@ async def __call__(self, scope: dict, receive: Any, send: Any) -> None: scope["headers"] = [(k, v) for k, v in scope["headers"] if k != b"host"] + [(b"host", host.encode())] if proto in ("http", "https"): scope["scheme"] = proto if scope["type"] == "http" else {"http": "ws", "https": "wss"}[proto] + prefix = forwarded_prefix(_first(headers.get(b"x-forwarded-prefix"))) + if prefix: + scope["root_path"] = unquote(prefix) + scope.get("root_path", "") + scope["path"] = unquote(prefix) + scope["path"] + if scope.get("raw_path") is not None: + scope["raw_path"] = prefix.encode("latin-1") + scope["raw_path"] await self.app(scope, receive, send) @@ -381,7 +392,7 @@ async def __call__(self, scope: dict, receive: Any, send: Any) -> None: if self.app is not None: await self.app(scope, receive, send) return - path, reading = scope["path"], scope["method"] in ("GET", "HEAD") + path, reading = get_route_path(scope) or "/", scope["method"] in ("GET", "HEAD") if reserved_path(path): return await error_response(NotFound("not found"))(scope, receive, send) if reading and (file := self._file(path)) is not None: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py b/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py index 3059e78bb..939803342 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py @@ -53,8 +53,9 @@ class ApiRoute(NamedTuple): fn: Callable def advertised(self) -> dict: - return {"method": self.method, "path": openapi_path(self.path), "name": self.name, - "roles": list(self.roles) or None} + """The route as the card names it: its path is the Starlette path it is served at, converters and all + (``/files/{rest:path}``), so a gateway matches the requests it answers, nested paths included.""" + return {"method": self.method, "path": self.path, "name": self.name, "roles": list(self.roles) or None} class _Api: @@ -159,11 +160,23 @@ def apply(self, response: Response) -> Response: def base_url(request: Request) -> str: """The site's address as the browser reached it: ``X-Forwarded-Proto`` and ``X-Forwarded-Host`` when a proxy - sent them, the request's own scheme and ``Host`` otherwise.""" + sent them, the request's own scheme and ``Host`` otherwise, under the path ``X-Forwarded-Prefix`` names when the + proxy serves the site below one (the gateway's ``/website/``).""" proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip() or request.url.scheme host = (request.headers.get("x-forwarded-host", "").split(",")[0].strip() or request.headers.get("host") or request.url.netloc) - return f"{proto}://{host}" + return f"{proto}://{host}{forwarded_prefix(request.headers.get('x-forwarded-prefix', ''))}" + + +def forwarded_prefix(value: str) -> str: + """The path an ``X-Forwarded-Prefix`` value names, without its trailing slash; empty for none, and for one that is + not an absolute path or would make the site's URLs name another host (``//host``). + + >>> [forwarded_prefix(v) for v in ("/website/notes/", "/a, /b", "", "/", "website", "//evil.example", "/a//b")] + ['/website/notes', '/a', '', '', '', '', ''] + """ + prefix = value.split(",")[0].strip().rstrip("/") + return prefix if prefix.startswith("/") and "//" not in prefix and prefix.isprintable() else "" ROLE_KEY_ENV = "WEBSITE_ROLE_KEY" @@ -172,7 +185,8 @@ def base_url(request: Request) -> str: def request_role(request: Request) -> str: """The role ``AgentEnv-Role`` names, believed only from a caller that also sends ``AgentEnv-Role-Key`` equal to - ``WEBSITE_ROLE_KEY``; every other caller, and every caller of a site with no key set, is ``default``. The gateway + ``WEBSITE_ROLE_KEY``; every other caller, and every caller of a site with no key set, is ``default``. A deploy with + ``keyed_roles`` sets the key and its gateway sends it on beside a role it verified the same way; any other gateway forwards whatever role a browser sends, so without the key a page could claim any role.""" claimed = caller_from_request(None, request.headers).role or DEFAULT_ROLE key = os.environ.get(ROLE_KEY_ENV, "") diff --git a/packages/agentenv-protocol/tests/test_website_api.py b/packages/agentenv-protocol/tests/test_website_api.py index b7754cf9d..e461ce097 100644 --- a/packages/agentenv-protocol/tests/test_website_api.py +++ b/packages/agentenv-protocol/tests/test_website_api.py @@ -320,6 +320,19 @@ def test_a_redirect_names_the_forwarded_address(client: TestClient) -> None: assert client.get("/api/items/", follow_redirects=False).headers["location"] == "http://testserver/api/items" +def test_a_site_behind_a_forwarded_prefix_names_it_in_its_address_and_redirects_and_routes_its_own_paths( + client: TestClient) -> None: + behind = {"Host": "internal-x.invalid", "X-Forwarded-Host": "gw.example", "X-Forwarded-Proto": "https", + "X-Forwarded-Prefix": "/website/shop/"} + assert client.get("/api/whoami", headers=behind).json()["base_url"] == "https://gw.example/website/shop" + assert client.get("/api/items/1", headers=behind).json()["title"] == "mug" + redirected = client.get("/api/items/", follow_redirects=False, headers=behind) + assert (redirected.status_code, redirected.headers["location"]) == (307, "https://gw.example/website/shop/api/items") + for unusable in ("//evil.example", "website", ""): + seen = client.get("/api/whoami", headers={**behind, "X-Forwarded-Prefix": unusable}).json() + assert seen["base_url"] == "https://gw.example", unusable + + def test_parameterized_routes_never_take_the_data_plane_or_card() -> None: @website(domains=["wiki.example"]) class Wiki(WebsiteEnvironment): diff --git a/packages/agentenv-protocol/tests/test_website_environment.py b/packages/agentenv-protocol/tests/test_website_environment.py index 5920b3995..05c118591 100644 --- a/packages/agentenv-protocol/tests/test_website_environment.py +++ b/packages/agentenv-protocol/tests/test_website_environment.py @@ -102,6 +102,18 @@ def test_static_files_and_index_fallback_answer_for_any_host(client: TestClient) assert client.post("/orders").status_code == 404 +def test_behind_a_forwarded_prefix_the_site_serves_its_own_paths_and_the_fallback_still_guards_reserved_ones( + client: TestClient) -> None: + behind = {"X-Forwarded-Prefix": "/website/shop"} + assert "home" in client.get("/", headers=behind).text + assert client.get("/assets/app.js", headers=behind).text == "console.log(1)" + assert "home" in client.get("/orders/42/edit", headers=behind).text + assert client.get("/api/orders", headers=behind).json() == [] + assert client.get("/.well-known/agent-env.json", headers=behind).json()["name"] == "Shop" + assert client.get("/website/shop/assets/app.js", headers=behind).status_code == 404 + assert client.post("/agentenv/x", headers=behind).status_code in (404, 405) + + def test_a_link_out_of_the_static_folder_is_never_served(dist: Path) -> None: secret = dist.parent / "secret.txt" (dist / "leak.txt").symlink_to(secret) diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index 24c643be7..baa2ccf8f 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -275,21 +275,25 @@ def _skill_extension(deployed: "DeployedA2AAgent") -> dict: @staticmethod async def install_cli( deployed: "DeployedA2AAgent", cli_artifact: "CliArtifact", gateway_url: str, container: str | None = None, - on_host: bool = False, + on_host: bool = False, role_key: str | None = None, ) -> str: """Install a CliArtifact bundle into the agent's filesystem at /opt/cli//, in ``container`` when install_agent put the agent in one, or on the VM host, ``on_host``, when it put it there. Writes a sidecar - bin/.env with the gateway URL and chmod +x's the entrypoint. Idempotent. Returns the path to the entrypoint.""" + bin/.env with the gateway URL, and with ``role_key``, the key of that gateway's deploy, when it has one, so + each installed CLI sends its own deploy's key beside its role; then chmod +x's the entrypoint. Idempotent. + Returns the path to the entrypoint.""" from agent_env.providers.sandbox_providers.sandbox import VmSandbox, into_container, stage_files_into_container + from agent_env.task_step.task_steps.mcp_cli_builder.codegen import GATEWAY_URL_ENV_VAR, ROLE_KEY_ENV_VAR from agent_env.providers.sandbox_providers.sandbox_provider import build_sandbox_provider, get_agent_sandbox_provider provider = build_sandbox_provider(deployed.sandbox_type) if deployed.sandbox_type else get_agent_sandbox_provider() sandbox = await provider.get_sandbox(deployed.sandbox_id) target_root = f"/opt/cli/{cli_artifact.command_name}" entrypoint_path = f"{target_root}/{cli_artifact.entrypoint}" env_dir = posixpath.dirname(entrypoint_path) + sidecar = f"{GATEWAY_URL_ENV_VAR}={gateway_url}\n" + (f"{ROLE_KEY_ENV_VAR}={role_key}\n" if role_key else "") if on_host: - await sandbox.write_host_file(f"AGENT_ENV_GATEWAY_URL={gateway_url}\n".encode(), f"{env_dir}/{GATEWAY_ENV_FILE}") + await sandbox.write_host_file(sidecar.encode(), f"{env_dir}/{GATEWAY_ENV_FILE}") await stage_files_into_container( sandbox, cli_artifact.get_cli_files().get_file_artifacts(), target_root, on_host=True) await sandbox.exec_script(f"chmod +x {shlex.quote(entrypoint_path)}") @@ -297,7 +301,7 @@ async def install_cli( return entrypoint_path # write .env first — write_file_from_text creates the parent dir; write_file_from_object does not. - await sandbox.write_file_from_text(f"AGENT_ENV_GATEWAY_URL={gateway_url}\n", f"{env_dir}/{GATEWAY_ENV_FILE}", + await sandbox.write_file_from_text(sidecar, f"{env_dir}/{GATEWAY_ENV_FILE}", **into_container(container)) for rel_path, file_artifact in cli_artifact.get_cli_files().get_file_artifacts().items(): diff --git a/src/agent_env/cli/env/deploy.py b/src/agent_env/cli/env/deploy.py index fe1984c28..f7afb1040 100644 --- a/src/agent_env/cli/env/deploy.py +++ b/src/agent_env/cli/env/deploy.py @@ -48,7 +48,10 @@ @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, 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): +@click.option("--keyed-roles", is_flag=True, default=False, + help="Draw a role key for the deploy: its gateway then believes a role other than default only from a " + "caller that sends the key beside it.") +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, keyed_roles: bool): """Deploy an MCP server environment.""" from agent_env.providers import build_sandbox_provider from agent_env.config import get_config @@ -89,6 +92,8 @@ def deploy(env_id: str, env_version: int | None, ttl_seconds: int, gateway_mode: if env_state_instance_id: deploy_kwargs["env_state_instance_id"] = env_state_instance_id click.echo(f"Env state instance id: {env_state_instance_id}") + if keyed_roles: + deploy_kwargs["keyed_roles"] = True try: with run_scope(env.id): deployed_env = asyncio.run(env.deploy(**deploy_kwargs)) diff --git a/src/agent_env/env/env.py b/src/agent_env/env/env.py index ef3373e2c..9e50ebabf 100644 --- a/src/agent_env/env/env.py +++ b/src/agent_env/env/env.py @@ -17,7 +17,7 @@ from agent_env.store.routing import namespace_routing_enabled from agent_env.utils.paths import validate_relative_filename -from .gateway import GatewayMode +from .gateway import AGENT_ENV_ROLE_HEADER, AGENT_ENV_ROLE_KEY_HEADER, GatewayMode from .gateway.constants import WELL_KNOWN_PATH from .store import get_env_store @@ -83,6 +83,7 @@ class DeployedEnv: # The provider that made the record, so the class it loads as (see from_dict); capabilities still come from the card. env_provider_type: Optional[str] = None environment_card_url: Optional[str] = None + role_key: Optional[str] = None # The env card as last read, and when (ISO 8601, UTC); written at deploy from the readiness probe. environment_card: Optional[dict] = None environment_card_read_at_utc: Optional[str] = None @@ -122,6 +123,11 @@ def control_headers_for(self, url: str) -> dict[str, str]: """What a call to ``url`` sends of the env's control token: ``control_headers`` when ``url`` is the env's address or under it, as a step's grant gives them, and none anywhere else, an override's address say.""" return protocol_v1.granted_headers(url, {self.environment_url: self.control_headers}) + def role_headers(self, role: str) -> dict[str, str]: + """The headers a request to the env sends to act as `role`: the role, and the deploy's `role_key` beside it, + without which a gateway that has one takes the request for `default`'s. A record from before the key, of a deploy without + `keyed_roles`, or of one with no gateway, has none, and its role header alone is believed.""" + return {AGENT_ENV_ROLE_HEADER: role, **({AGENT_ENV_ROLE_KEY_HEADER: self.role_key} if self.role_key else {})} def supports(self, uri: str, method: str) -> bool: """Whether the stored env card offers `method` on the extension at `uri`; no network.""" @@ -200,6 +206,7 @@ def _fields_from(cls, data: dict) -> dict: created_at_utc=data.get("created_at_utc"), expires_at_utc=data.get("expires_at_utc"), control_token=data.get("control_token"), + role_key=data.get("role_key"), ) diff --git a/src/agent_env/env/envs/mcp_server.py b/src/agent_env/env/envs/mcp_server.py index 2f950f77a..96fad5ec0 100644 --- a/src/agent_env/env/envs/mcp_server.py +++ b/src/agent_env/env/envs/mcp_server.py @@ -107,13 +107,13 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: @environment_card when it isn't set. Raises BundleError listing every problem.""" return ctx.accept_env(data, cls, named_by="image") - async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv: + async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None, keyed_roles: bool = False) -> DeployedEnv: # In container mode the server runs in its own container, so loads stage there, as for a MultiEnv child. return await deploy_through_provider( self, environment_name=self.environment_name, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type, disk_size_gb=disk_size_gb, gateway_mode=gateway_mode, cpu=cpu, memory_mb=memory_mb, env_state_type=env_state_type, env_state_instance_id=env_state_instance_id, attribution=attribution, - artifact_id=artifact_id, artifact_version=artifact_version, + artifact_id=artifact_id, artifact_version=artifact_version, **({"keyed_roles": True} if keyed_roles else {}), ) def deploy_refusal(self, *, sandbox_type: str | None = None, **options) -> str | None: diff --git a/src/agent_env/env/envs/multi_env.py b/src/agent_env/env/envs/multi_env.py index e193771d2..d1da07009 100644 --- a/src/agent_env/env/envs/multi_env.py +++ b/src/agent_env/env/envs/multi_env.py @@ -91,6 +91,7 @@ def __init__(self, id: str, version: Optional[int], mcp_server_envs: list[MCPSer self._gateway_mode = GatewayMode.PERFORMANCE self._env_provider = None self._mcp_server_name: Optional[str] = None + self._role_key: Optional[str] = None self._deployed: Optional[DeployedEnv] = None self._replaced: list[tuple] = [] # the provider and sandbox of each deployment a later deploy replaced, for close() @@ -156,15 +157,16 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: ctx.refuse(problems) return fields - async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv: + async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None, keyed_roles: bool = False) -> DeployedEnv: deployed_env = await deploy_through_provider( self, environment_name=None, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type, disk_size_gb=disk_size_gb, gateway_mode=gateway_mode, cpu=cpu, memory_mb=memory_mb, env_state_type=env_state_type, env_state_instance_id=env_state_instance_id, attribution=attribution, - artifact_id=artifact_id, artifact_version=artifact_version, + artifact_id=artifact_id, artifact_version=artifact_version, **({"keyed_roles": True} if keyed_roles else {}), ) self._gateway_mode = gateway_mode self._mcp_server_name = deployed_env.mcp_server_name + self._role_key = deployed_env.role_key _hand_provider_to_child_envs(self) _hand_record_to_child_envs(self, deployed_env) return deployed_env @@ -190,6 +192,7 @@ async def from_deployed_env(cls, deployed: DeployedEnv) -> MultiEnv: _hand_record_to_child_envs(env, deployed) env._instance_id = deployed.instance_id env._mcp_server_name = deployed.mcp_server_name + env._role_key = deployed.role_key return env async def load_environment_artifact(self, environment_artifact: EnvironmentArtifact) -> None: diff --git a/src/agent_env/env/envs/website.py b/src/agent_env/env/envs/website.py index 64d4b2d5c..702dd4c61 100644 --- a/src/agent_env/env/envs/website.py +++ b/src/agent_env/env/envs/website.py @@ -169,12 +169,12 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: ctx.refuse([ctx.config_problem(problem)]) return ctx.accept_env(data, cls, named_by="backend_image") - async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv: + async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None, keyed_roles: bool = False) -> DeployedEnv: return await deploy_through_provider( self, environment_name=self.environment_name, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type, disk_size_gb=disk_size_gb, gateway_mode=gateway_mode, cpu=cpu, memory_mb=memory_mb, env_state_type=env_state_type, env_state_instance_id=env_state_instance_id, attribution=attribution, - artifact_id=artifact_id, artifact_version=artifact_version, + artifact_id=artifact_id, artifact_version=artifact_version, **({"keyed_roles": True} if keyed_roles else {}), ) def deploy_refusal(self, *, sandbox_type: str | None = None, **options) -> str | None: diff --git a/src/agent_env/env/gateway/__init__.py b/src/agent_env/env/gateway/__init__.py index 6cd59de6f..cb94520d2 100644 --- a/src/agent_env/env/gateway/__init__.py +++ b/src/agent_env/env/gateway/__init__.py @@ -6,6 +6,7 @@ from .constants import ( AGENT_ENV_HIDDEN_BY_DEFAULT_META_KEY, AGENT_ENV_ROLE_HEADER, + AGENT_ENV_ROLE_KEY_HEADER, AGENT_ENV_ROLE_META_KEY, AGENT_ENV_SESSION_META_KEY, DEFAULT_ROLE, @@ -31,6 +32,7 @@ class InternalMCPServer: "AGENT_ENV_GATEWAY_MCP_PORT", "AGENT_ENV_HIDDEN_BY_DEFAULT_META_KEY", "AGENT_ENV_ROLE_HEADER", + "AGENT_ENV_ROLE_KEY_HEADER", "AGENT_ENV_ROLE_META_KEY", "AGENT_ENV_SESSION_META_KEY", "DEFAULT_ROLE", diff --git a/src/agent_env/env/gateway/caller.py b/src/agent_env/env/gateway/caller.py index 4c5c50edb..1546fe2c9 100644 --- a/src/agent_env/env/gateway/caller.py +++ b/src/agent_env/env/gateway/caller.py @@ -1,6 +1,7 @@ """Who the gateway acts for on one inbound request. -A ``Caller`` is the role the request's ``AgentEnv-Role`` header named (``default`` when it named none) and the key of +A ``Caller`` is the role the request's ``AgentEnv-Role`` header named (``default`` when it named none, or named another +without the deploy's ``AgentEnv-Role-Key`` when the deploy has one) and the key of the MCP session the request belongs to (empty on the REST and harness paths, which have no session). It is bound in three scopes, which read in order in ``gateway.py``: per HTTP request by the served app (the role), per inbound MCP session by the session lifespan (the key, kept for every tools/call of that session), and never per process. Every diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index 30bd00031..90f5a609d 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -126,9 +126,10 @@ def sources(names: list[str]) -> str: TRIGGER_EXECUTOR_HOURS_TIME_ZONE = "executor_hours_time_zone" TRIGGER_NEXT_EVENT_AT = "next_event_at" TRIGGER_NOTIFY = "notify" +TRIGGER_WEBSITE_OP = "website_op" TRIGGER_FEATURES = (TRIGGER_NAMED_EXECUTORS, TRIGGER_PROVOKING_ROLES, TRIGGER_ACTION_DELAY, TRIGGER_EXECUTOR_HOURS, TRIGGER_UNLESS, TRIGGER_WEBSITE, TRIGGER_EXECUTOR_HOURS_TIME_ZONE, TRIGGER_NEXT_EVENT_AT, - TRIGGER_NOTIFY) + TRIGGER_NOTIFY, TRIGGER_WEBSITE_OP) # The one `source` a /step call may name instead of step_api, with the id of the hook artifact that made it. STEP_SOURCE_POST_TURN_HOOK = "post_turn_hook" @@ -142,6 +143,9 @@ def sources(names: list[str]) -> str: ) AGENT_ENV_ROLE_HEADER = "AgentEnv-Role" +AGENT_ENV_ROLE_KEY_HEADER = "AgentEnv-Role-Key" +GATEWAY_ROLE_KEY_ENV = "AGENTENV_ROLE_KEY" +WEBSITE_ROLE_KEY_ENV = "WEBSITE_ROLE_KEY" # MCP request `_meta` keys the gateway stamps on every child tools/call: the caller's role, and on the MCP path a # key naming the inbound session, so a child can keep per-caller state behind the gateway's one shared session. AGENT_ENV_ROLE_META_KEY = "agentenv.io/role" diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index b0b936ae9..b6d469a17 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -7,7 +7,7 @@ import httpx from . import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode, InternalMCPServer -from .constants import DEFAULT_MCP_SERVER_NAME +from .constants import DEFAULT_MCP_SERVER_NAME, GATEWAY_ROLE_KEY_ENV from .gateway import Gateway logger = logging.getLogger(__name__) @@ -152,5 +152,6 @@ def parse_name_url_map(env_str: str) -> dict[str, str]: rest_proxy_urls=rest_proxy_urls, gateway_mode=gateway_mode, service_db_url=service_db_url, + role_key=os.environ.get(GATEWAY_ROLE_KEY_ENV) or None, ) gateway.run() diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index b7dd22091..94dfd2366 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -4,6 +4,7 @@ import asyncio import atexit import contextlib +import hmac import json import logging import os @@ -41,6 +42,7 @@ from .constants import ( AGENT_ENV_HIDDEN_BY_DEFAULT_META_KEY, AGENT_ENV_ROLE_HEADER, + AGENT_ENV_ROLE_KEY_HEADER, AGENT_ENV_ROLE_META_KEY, # noqa: F401 (the server module serves the whole vocabulary, used in this file or not) AGENT_ENV_SESSION_META_KEY, # noqa: F401 CARD_FETCH_TIMEOUT_S, @@ -72,7 +74,19 @@ from .child_transport import child_http_client from .clock import Clock, ClockError, _iso from .get_time import GET_TIME_TOOL_NAME, build_get_time_tool -from .rest_proxy import ROLE_HEADER_BYTES, UpstreamResponse, fetch_child_card, forward, operations, rewrite_child_card, upstream_url +from .rest_proxy import ( + ROLE_HEADER_BYTES, + ROLE_KEY_HEADER_BYTES, + UpstreamResponse, + WebsiteRoute, + fetch_child_card, + forward, + operations, + rewrite_child_card, + route_name, + upstream_url, + website_routes, +) from .triggers import TriggerEngine, TriggerError from .notifications import CHECK_TOOL_NAMES, NotificationError, Notifications, build_check_tools @@ -119,7 +133,10 @@ def __init__( rest_proxy_urls: dict[str, str] | None = None, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, service_db_url: str | None = None, + role_key: str | None = None, ): + """`role_key` is the deploy's secret: with one, a request is believed to act as a role other than `default` + only when it carries the key beside the role; without one, as before it, the role header alone is believed.""" self.host = host self.port = port self.server_name = server_name @@ -129,6 +146,8 @@ def __init__( self._site_reserved: dict[str, frozenset[str]] = {} self.gateway_mode = gateway_mode self._service_db_url = service_db_url + self._role_key = role_key or None + self._website_routes: dict[str, list[WebsiteRoute] | None] = {} self._db_conn: psycopg2.extensions.connection | None = None if service_db_url: self._db_conn = psycopg2.connect(service_db_url) @@ -240,13 +259,15 @@ async def get_state(request: Request) -> Response: async def step_endpoint(request: Request) -> Response: return await self._handle_step(request) - async def _trigger_write(request: Request, method) -> Response: - """Shared body for the trigger write routes: parse JSON, invoke the engine method, - map a TriggerError (bad config) to a 400.""" + async def _trigger_write(request: Request, method, prepare=None) -> Response: + """Shared body for the trigger write routes: parse JSON, await `prepare` with it, invoke the engine + method, map a TriggerError (bad config) to a 400.""" try: body = await request.json() except Exception: return self._json_response({"ok": False, "error": "Invalid JSON body"}, 400) + if prepare is not None: + await prepare(body) try: return self._json_response(method(body), 200) except TriggerError as e: @@ -254,7 +275,7 @@ async def _trigger_write(request: Request, method) -> Response: @self._mcp.custom_route("/triggers/register", methods=["POST"]) async def triggers_register(request: Request) -> Response: - return await _trigger_write(request, self._trigger_engine.register) + return await _trigger_write(request, self._trigger_engine.register, prepare=self._read_trigger_website_routes) @self._mcp.custom_route("/triggers/remove", methods=["POST"]) async def triggers_remove(request: Request) -> Response: @@ -408,7 +429,7 @@ def run(self) -> None: anyio.run(server.serve) # The three scopes a Caller is bound in, innermost first. Request: the served app binds the role the - # AgentEnv-Role header named. Session: mcp's Server.run enters the session lifespan once per inbound MCP + # AgentEnv-Role header named, as `_attributed_role` believes it. Session: mcp's Server.run enters the session lifespan once per inbound MCP # session, in the session's server task before any handler task is spawned, so the key bound there is inherited # by every tools/call of that session (and the role is the one its initialize request carried, which that task # inherited). Process: child sessions and the time-trigger driver, outer to the MCP session manager's lifespan. @@ -423,11 +444,13 @@ async def app(scope, receive, send): if scope["type"] != "http": await inner(scope, receive, send) return - role = DEFAULT_ROLE + claimed, given_key = None, None for name, value in scope.get("headers", ()): - if name == ROLE_HEADER_BYTES: - role = value.decode(errors="ignore").strip() or DEFAULT_ROLE - break + if name == ROLE_HEADER_BYTES and claimed is None: + claimed = value.decode(errors="ignore") + elif name == ROLE_KEY_HEADER_BYTES and given_key is None: + given_key = value.decode(errors="ignore") + role = self._attributed_role(claimed or "", given_key or "") token = Caller(role=role, session=Caller.current().session).bind() try: await inner(scope, receive, send) @@ -603,7 +626,8 @@ async def _proxy_rest_request(self, request: Request, url_map: dict[str, str] | role = self._request_role(request) mcp_server = url_map is self.rest_proxy_urls and service_name.startswith("mcp-") if request.method in _SAFE_METHODS: - return await forward(request, base_url, role=role, timeout=self.REST_PROXY_TIMEOUT, mcp_server=mcp_server) + return await forward(request, base_url, role=role, timeout=self.REST_PROXY_TIMEOUT, mcp_server=mcp_server, + role_key=self._role_key) changelog_before = self._query_changelog_id() attribution = self._attribution(role) # at the start: a fire may be gone by the time the response ends @@ -611,12 +635,16 @@ async def log_write(status: int) -> None: changelog_after = self._query_changelog_id() if changelog_after is None or changelog_after <= (changelog_before or 0): return # only a request that wrote changelog rows is a write window + path = "/" + request.path_params.get("path", "") + op = (self._website_op(service_name, request.method, site_request_path(request) or path) + if url_map is self.website_urls else None) await self._log_event({ "event_type": "http_request", "route": "website" if url_map is self.website_urls else "svc", "service": service_name, "method": request.method, - "path": "/" + request.path_params.get("path", ""), + "path": path, + **({"op": op} if op else {}), "status": status, **attribution, "changelog_id_before": changelog_before, @@ -624,7 +652,7 @@ async def log_write(status: int) -> None: }) return await forward(request, base_url, role=role, timeout=self.REST_PROXY_TIMEOUT, mcp_server=mcp_server, - on_done=log_write) + role_key=self._role_key, 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. @@ -640,9 +668,10 @@ async def _proxy_website_request(self, request: Request) -> Response: app = request.path_params["service_name"] engine = self._trigger_engine if app in self.website_urls and (engine.has_website_triggers or engine.has_state_triggers): + path = site_request_path(request) or "/" + request.path_params.get("path", "") if isinstance(response, UpstreamResponse): - if not engine.may_hold_website_request(self._request_role(request), app, request.method, - "/" + request.path_params.get("path", ""), response.status_code): + if not engine.may_hold_website_request(self._request_role(request), app, request.method, path, + response.status_code, self._website_op(app, request.method, path)): response.after_body(lambda status: self._fire_website_triggers(request, app, status)) # answered at its last byte return response await response.read_ahead_if_sized(self.BARRIER_READ_AHEAD_MAX_BYTES) # its window ends before its triggers run @@ -656,16 +685,55 @@ async def _fire_website_triggers(self, request: Request, app: str, try: # Only a website trigger reads the request's args; a state re-check needs just the method and status. args = _request_args(request, await request.body()) if self._trigger_engine.has_website_triggers else {} - return self._trigger_engine.on_website_request(self._request_role(request), app, request.method, - "/" + request.path_params.get("path", ""), status, args) + path = site_request_path(request) or "/" + request.path_params.get("path", "") + return self._trigger_engine.on_website_request(self._request_role(request), app, request.method, path, + status, args, self._website_op(app, request.method, path)) except Exception: logger.exception("Trigger evaluation failed (fail-open)") return [], None - @staticmethod - def _request_role(request: Request) -> str: - """The role the gateway attributes a REST request to: the first AgentEnv-Role header, else the default role.""" - return request.headers.get(AGENT_ENV_ROLE_HEADER, "").strip() or DEFAULT_ROLE + def _request_role(self, request: Request) -> str: + """The role the gateway attributes a REST request to: the first AgentEnv-Role header, as `_attributed_role` + believes it, else the default role.""" + return self._attributed_role(request.headers.get(AGENT_ENV_ROLE_HEADER, ""), + request.headers.get(AGENT_ENV_ROLE_KEY_HEADER, "")) + + def _attributed_role(self, claimed: str, given_key: str) -> str: + """The role a request claiming `claimed` acts as: `default` when it claims none, and when the deploy has a + role key the request did not send. Without a key (a deploy without keyed_roles) the claim alone is believed.""" + role = claimed.strip() or DEFAULT_ROLE + if self._role_key is None or role == DEFAULT_ROLE: + return role + return role if hmac.compare_digest(given_key.encode(), self._role_key.encode()) else DEFAULT_ROLE + + async def _read_trigger_website_routes(self, body: Any) -> None: + """Read the routes of each website a website trigger in a register `body` names an `op` of, so registration + can check the op and requests can be matched by it; one whose card could not be read before is read again.""" + triggers = body.get("triggers") if isinstance(body, dict) else None + apps = {trigger["when"]["app"] for trigger in triggers if isinstance(trigger, dict) + and isinstance(trigger.get("when"), dict) and trigger["when"].get("type") == "website" + and trigger["when"].get("op") is not None and isinstance(trigger["when"].get("app"), str) + and trigger["when"]["app"] in self.website_urls} if isinstance(triggers, list) else set() + await asyncio.gather(*(self._read_website_routes(app, again=True) for app in apps)) + + async def _read_website_routes(self, app: str, *, again: bool = False) -> None: + """Read website `app`'s routes from its card once (`again`: once more, when its card could not be read). The + env card's composition reads them too, so a deploy, which reads that card, leaves them read.""" + if app in self._website_routes and not (again and self._website_routes[app] is None): + return + async with httpx.AsyncClient() as client: + card = await fetch_child_card(client, app, self.rest_proxy_urls.get(app) or self.website_urls[app]) + self._website_routes[app] = website_routes(card) if card is not None else None + + def website_route_names(self, app: str) -> set[str] | None: + """The names of the routes website `app`'s card names, as last read; None when it has not been read.""" + routes = self._website_routes.get(app) + return None if routes is None else {route.name for route in routes if route.name is not None} + + def _website_op(self, app: str, method: str, path: str) -> str | None: + """The name of the route of website `app` a request reached, by the routes last read from its card; never a + read of its own, so answering a request waits on no card.""" + return route_name(self._website_routes.get(app) or [], method, path) async def _site_reserved_paths(self, app: str) -> frozenset[str] | None: """The paths ``app``'s card advertises for agentenv (``site_reserved_paths``), read once from its server, the one @@ -702,8 +770,11 @@ def _rewrite_child_card(self, key: str, card: dict) -> dict: return rewrite_child_card(key, card, self._gateway_extensions()) async def _fetch_child_card(self, client: httpx.AsyncClient, key: str, base_url: str) -> dict | None: - """Fetch + rewrite one backing server's card; None (omit) on 404 / error / timeout / malformed JSON.""" + """Fetch + rewrite one backing server's card; None (omit) on 404 / error / timeout / malformed JSON. A + website's card read here also refreshes the routes its requests are named by.""" child = await fetch_child_card(client, key, base_url) + if child is not None and key in self.website_urls: + self._website_routes[key] = website_routes(child) return None if child is None else self._rewrite_child_card(key, child) def _gateway_extensions(self) -> list[dict]: @@ -1563,16 +1634,22 @@ def site_reserved_paths(card: dict) -> frozenset[str]: return frozenset(_normalized(p) for p in paths if isinstance(p, str) and p.startswith("/") and _normalized(p) != "/") +def site_request_path(request: Request) -> str | None: + """The path a site's server receives for a request on its route: the raw path forwarded, its dot segments removed + by httpx and then decoded. It differs from the path as routed here when an encoded slash sits beside a dot segment + (``public%2Fx/../api`` reaches ``/api``). None for a request-target the proxy can't build a URL for: ``forward()`` + refuses it.""" + try: + return httpx.URL(upstream_url("http://site", request)).path + except (ValueError, httpx.InvalidURL): + return None + + def reserved_site_request(request: Request, reserved: frozenset[str] = frozenset()) -> bool: """Whether a request on a site's route reaches its data plane or env card, or one of the paths ``reserved`` names - (``site_reserved_paths``): by the path as routed here, or by the one its server receives, which is the raw path - forwarded, its dot segments removed by httpx and then decoded. The two differ when an encoded slash sits beside a - dot segment (``public%2Fx/../agentenv``). A request-target the proxy can't build a URL for isn't reserved here: - ``forward()`` refuses it.""" + (``site_reserved_paths``): by the path as routed here, or by the one its server receives (``site_request_path``). + A request-target the proxy can't build a URL for isn't reserved here: ``forward()`` refuses it.""" if reserved_site_path("/" + request.path_params.get("path", ""), reserved): return True - try: - sent = httpx.URL(upstream_url("http://site", request)).path - except (ValueError, httpx.InvalidURL): - return False - return reserved_site_path(sent, reserved) + sent = site_request_path(request) + return sent is not None and reserved_site_path(sent, reserved) diff --git a/src/agent_env/env/gateway/rest_proxy.py b/src/agent_env/env/gateway/rest_proxy.py index 0812440ec..10a3b3ba2 100644 --- a/src/agent_env/env/gateway/rest_proxy.py +++ b/src/agent_env/env/gateway/rest_proxy.py @@ -10,12 +10,17 @@ round-trip through str), minus the hop-by-hop set, in both directions; - the role header is the gateway's to write: the client's copies are dropped and the role the gateway attributed the request to goes in their place, and a POST to a backing MCP server's own endpoint gets the same treatment - inside its JSON-RPC ``params._meta``; + inside its JSON-RPC ``params._meta``; so is the role key, which goes upstream only beside a role other than + ``default`` the key verified, so a site holding the same key believes the role; +- the upstream learns the address the client reached it at: ``X-Forwarded-Host`` and ``X-Forwarded-Proto`` (a front's + own, when one before the gateway sent them, else the request's ``Host`` and scheme) and ``X-Forwarded-Prefix``, the + route prefix (``/svc/{service}`` or ``/website/{service}``) the gateway strips, which a client never sets; - the upstream response and the client it came through are closed when the response ends, iterated or not. A backing server's env card names its endpoints relative to itself; :func:`rewrite_child_card` roots them under ``/svc/{key}`` so they resolve against the env's address, keeping the gateway's own routes where the gateway offers -the same operation. +the same operation. A website's card names its routes on ``urn:agentenv:website/v1``; :func:`website_routes` reads +them so :func:`route_name` can name the route a proxied request reached. """ from __future__ import annotations @@ -23,8 +28,9 @@ import json import logging import posixpath +import re from collections.abc import AsyncIterator, Awaitable, Callable -from typing import Any +from typing import Any, NamedTuple from urllib.parse import unquote import httpx @@ -35,9 +41,12 @@ # Relative import — gateway runs as a standalone container with no agent_env.* from .constants import ( AGENT_ENV_ROLE_HEADER, + AGENT_ENV_ROLE_KEY_HEADER, AGENT_ENV_ROLE_META_KEY, AGENT_ENV_SESSION_META_KEY, CARD_FETCH_TIMEOUT_S, + DEFAULT_ROLE, + EXT_WEBSITE_URI, WELL_KNOWN_PATH, ) @@ -51,11 +60,16 @@ # The role header as it appears in the raw ASGI header list (lower-cased by the server). ROLE_HEADER_BYTES = AGENT_ENV_ROLE_HEADER.lower().encode() +ROLE_KEY_HEADER_BYTES = AGENT_ENV_ROLE_KEY_HEADER.lower().encode() + +FORWARDED_HOST, FORWARDED_PROTO, FORWARDED_PREFIX = "x-forwarded-host", "x-forwarded-proto", "x-forwarded-prefix" +_FORWARDED = frozenset(name.encode() for name in (FORWARDED_HOST, FORWARDED_PROTO, FORWARDED_PREFIX)) async def forward(request: Request, base_url: str, *, role: str, timeout: httpx.Timeout, mcp_server: bool, - on_done: Callable[[int], Awaitable[None]] | None = None) -> Response: - """Forward `request` to the service at `base_url` as `role` and stream the response through. `mcp_server` says + role_key: str | None = None, on_done: Callable[[int], Awaitable[None]] | None = None) -> Response: + """Forward `request` to the service at `base_url` as `role`, vouched for by `role_key` when the deploy has one, + and stream the response through. `mcp_server` says the service is a backing MCP server, whose own endpoint takes the role in its JSON-RPC `_meta` as well as in the header. A 400 names a request-target the proxy cannot route; a 502 names an upstream that never answered. `on_done` is awaited with the status once the response has ended, after its last byte when streamed.""" @@ -64,7 +78,7 @@ async def forward(request: Request, base_url: str, *, role: str, timeout: httpx. except ValueError as e: # an escaped slash inside the route prefix; refuse rather than forward a wrong path return await _done(_json_response({"error": str(e)}, 400), on_done) content = await request.body() # /svc and /website request bodies are small (JSON-RPC, forms) - headers = upstream_headers(request, role) + headers = upstream_headers(request, role, role_key) if mcp_server and request.method == "POST" and is_child_mcp_path(request.path_params.get("path", "")): # The gateway is the trust boundary for identity: a child's proxied MCP endpoint stays reachable, but the # role in its JSON-RPC `_meta` is the gateway's to write, like the header above. @@ -101,18 +115,49 @@ def upstream_url(base_url: str, request: Request) -> str: return url -def upstream_headers(request: Request, role: str) -> httpx.Headers: +def upstream_headers(request: Request, role: str, role_key: str | None = None) -> httpx.Headers: """One entry per raw header, so repeated fields (cookie, x-forwarded-for) keep their multiplicity. The role - header is the gateway's to set: the client's copies are dropped and the role the gateway attributed the - request to goes in their place, so the upstream never reads a value the gateway did not write.""" + and role key headers are the gateway's to set: the client's copies are dropped and the role the gateway + attributed the request to goes in their place, with `role_key` beside any role but `default`, so the upstream + never reads a value the gateway did not write. So are the forwarded-address headers (`forwarded_address`).""" headers = httpx.Headers([(k, v) for k, v in request.headers.raw - if k.lower() not in HOP_BY_HOP and k.lower() != ROLE_HEADER_BYTES]) + if k.lower() not in HOP_BY_HOP and k.lower() not in (ROLE_HEADER_BYTES, ROLE_KEY_HEADER_BYTES) + and k.lower() not in _FORWARDED]) headers[AGENT_ENV_ROLE_HEADER] = role + if role_key and role != DEFAULT_ROLE: + headers[AGENT_ENV_ROLE_KEY_HEADER] = role_key + headers.update(forwarded_address(request)) # Bytes are forwarded undecoded, so only ask upstream for encodings the caller can take. headers.setdefault("accept-encoding", "identity") return headers +def forwarded_address(request: Request) -> dict[str, str]: + """Where the client reached the proxied service: the host and scheme it addressed (as a front before the gateway + names them in its own ``X-Forwarded-Host``/``X-Forwarded-Proto``, else the request's ``Host`` and scheme; the + client could set ``Host`` anyway, so believing its front names nothing it couldn't), and the route prefix as sent. + A header with nothing to say is left out.""" + host = _first(request.headers.get(FORWARDED_HOST, "")) or request.headers.get("host", "") + proto = _first(request.headers.get(FORWARDED_PROTO, "")).lower() + address = {FORWARDED_HOST: host, FORWARDED_PROTO: proto if proto in ("http", "https") else request.url.scheme, + FORWARDED_PREFIX: route_prefix(request)} + return {name: value for name, value in address.items() if value} + + +def _first(value: str) -> str: + return value.split(",")[0].strip() + + +def route_prefix(request: Request) -> str: + """The path the client reached the proxied service under, as it sent it: the request-target up to the `{path}` + remainder, without its trailing slash, e.g. ``/website/notes``, or under a root path ``/env/website/notes``.""" + raw_path = request.scope.get("raw_path") or request.scope["path"].encode() + decoded_rest = request.path_params.get("path", "") + if decoded_rest: + raw_path = raw_path[:-len(raw_remainder(raw_path, request.scope["path"], decoded_rest))] + return raw_path.decode("latin-1").rstrip("/") + + def is_child_mcp_path(decoded_rest: str) -> bool: """Whether a /svc/{service}/{path} remainder reaches the backing server's MCP endpoint (`mcp` or anything under it), judged after dot-segment normalisation, since httpx collapses `x/../mcp` to `/mcp` on the way upstream. @@ -326,5 +371,69 @@ def operations(params: dict) -> list[tuple[str | None, dict | None, dict, str, s if isinstance(holder.get("endpoint"), str) and holder["endpoint"]] +class WebsiteRoute(NamedTuple): + """One route a website's card names: its verb, the pattern its path matches (a Starlette converter's own, one + path segment for any other converter or none), and its name, None for a route the site builds in.""" + + method: str + pattern: re.Pattern + name: str | None + + +_CONVERTERS = {"path": ".*", "int": "[0-9]+", "float": r"[0-9]+(?:\.[0-9]+)?", + "uuid": "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"} +_PATH_PARAM = re.compile(r"{\w+(?::(\w+))?}") + + +def website_routes(card: dict | None) -> list[WebsiteRoute]: + """The routes a website's card names on ``urn:agentenv:website/v1``, in its order: each one's method, its path + (``/api/notes/{note_id}``, with or without a Starlette converter such as ``{note_id:int}``) and its name. An entry + missing any of the three is skipped; a card naming none, or no card, has none. + + Before them come, unnamed, the built-in routes the card names, which answer before any of its routes would: a GET + to its ``health`` path, and a GET or DELETE to its ``session_path`` unless one of its routes is at that very path + (then the site serves its own there). A request one of them answers is named by no route. + + >>> [(r.method, r.pattern.pattern, r.name) for r in website_routes({"capabilities": {"extensions": [ + ... {"uri": "urn:agentenv:website/v1", "params": {"health": "/healthz", "routes": [ + ... {"method": "get", "path": "/api/notes/{note_id:int}", "name": "get_note"}, {"path": "/x"}]}}]}})] + [('GET', '/healthz', None), ('GET', '/api/notes/(?:[0-9]+)', 'get_note')] + """ + found: list[WebsiteRoute] = [] + for ext in ((card or {}).get("capabilities") or {}).get("extensions") or []: + params = ext.get("params") if isinstance(ext, dict) and ext.get("uri") == EXT_WEBSITE_URI else None + routes = params.get("routes") if isinstance(params, dict) else None + named = [route for route in (routes if isinstance(routes, list) else []) + if isinstance(route, dict) and all(isinstance(route.get(k), str) and route[k] + for k in ("method", "path", "name"))] + health, session = (params.get(k) if isinstance(params, dict) else None for k in ("health", "session_path")) + if isinstance(health, str) and health: + found.append(WebsiteRoute("GET", _path_pattern(health), None)) + if isinstance(session, str) and session and all(route["path"] != session for route in named): + found += [WebsiteRoute(verb, _path_pattern(session), None) for verb in ("GET", "DELETE")] + found += [WebsiteRoute(route["method"].upper(), _path_pattern(route["path"]), route["name"]) for route in named] + return found + + +def _path_pattern(path: str) -> re.Pattern: + parts, end = [], 0 + for param in _PATH_PARAM.finditer(path): + parts += [re.escape(path[end:param.start()]), f"(?:{_CONVERTERS.get(param.group(1) or '', '[^/]+')})"] + end = param.end() + return re.compile("".join(parts) + re.escape(path[end:])) + + +def route_name(routes: list[WebsiteRoute], method: str, path: str) -> str | None: + """The name of the first of `routes` a `method` request to `path` reaches (a HEAD reaches a GET route), else None, + as it is when that first one is a built-in route. + + >>> route_name(website_routes({"capabilities": {"extensions": [{"uri": "urn:agentenv:website/v1", "params": { + ... "routes": [{"method": "GET", "path": "/api/notes/{note_id}", "name": "get_note"}]}}]}}), "HEAD", "/api/notes/7") + 'get_note' + """ + verbs = {method.upper(), "GET"} if method.upper() == "HEAD" else {method.upper()} + return next((route.name for route in routes if route.method in verbs and route.pattern.fullmatch(path)), None) + + def _json_response(data: dict | list, status_code: int) -> Response: return Response(content=json.dumps(data), status_code=status_code, media_type="application/json") diff --git a/src/agent_env/env/gateway/triggers.py b/src/agent_env/env/gateway/triggers.py index 1017d3ef3..69dbf0d20 100644 --- a/src/agent_env/env/gateway/triggers.py +++ b/src/agent_env/env/gateway/triggers.py @@ -713,7 +713,8 @@ def _normalize_action_when(when: dict, tid: str) -> None: _normalize_provoked_when(when, tid) def _normalize_website_when(self, when: dict, tid: str) -> None: - """Validate a website `when`: a known app, upper-cased method, `path`/`status` predicates, `where`, `repeat`.""" + """Validate a website `when`: a known app, upper-cased method, the `op` (a route the site's card names), + `path`/`status` predicates, `where`, `repeat`.""" w = f"trigger '{tid}'" app = when.get("app") if not isinstance(app, str) or not app: @@ -726,6 +727,17 @@ def _normalize_website_when(self, when: dict, tid: str) -> None: raise TriggerError(f"{w}: when.method must be a non-empty HTTP method, e.g. 'POST'") else: when["method"] = when["method"].strip().upper() + if when.get("op") is None: + when.pop("op", None) + elif not isinstance(when["op"], str) or not when["op"]: + raise TriggerError(f"{w}: when.op must be a non-empty string naming one of the website's routes") + else: + names = self._gw.website_route_names(app) + if names is None: + raise TriggerError(f"{w}: when.op names a route of website {app!r}, whose card the gateway could not " + "read; match its path instead") + if when["op"] not in names: + raise TriggerError(f"{w}: when.op names no route of website {app!r} (routes: {sorted(names)})") for key in ("path", "status"): if when.get(key) is None: when.pop(key, None) @@ -921,17 +933,18 @@ def on_tool_call(self, role: str, name: str, arguments: dict, return pending, timeout_s def on_website_request(self, role: str, app: str, method: str, path: str, status: int, - args: dict) -> tuple[list[asyncio.Task], float | None]: - """Detection hook for an answered website request, before its response goes out; returns what on_tool_call does.""" + args: dict, op: str | None = None) -> tuple[list[asyncio.Task], float | None]: + """Detection hook for an answered website request, before its response goes out, `op` naming the route of + the site's card it reached; returns what on_tool_call does.""" provokable = [(tid, trig) for tid, trig in self._triggers.items() if self._provoked_by(trig, role)] if not provokable: return [], None if self._has_time_triggers: self._eval_time_triggers() - request = {"app": app, "method": method, "path": path, "status": status} + request = {"app": app, "method": method, "path": path, "status": status, **({"op": op} if op else {})} provoking = {"source": "website", **request, "role": role} matched = [(tid, trig) for tid, trig in provokable - if _website_matches(trig["spec"]["when"], app, method, path, status)] + if _website_matches(trig["spec"]["when"], app, method, path, status, op)] pending, timeout_s = self._detect(matched, provoking, {"args": args, "request": request}) if method not in _SAFE_METHODS and status < 400: self._reevaluate_states(provokable, provoking) @@ -963,10 +976,12 @@ def _reevaluate_states(self, provokable: list[tuple[str, dict]], provoking: dict trig["evaluating"] = True self._schedule(self._eval_state(tid, trig, provoking)) - def may_hold_website_request(self, role: str, app: str, method: str, path: str, status: int) -> bool: + def may_hold_website_request(self, role: str, app: str, method: str, path: str, status: int, + op: str | None = None) -> bool: """Whether a `provoking_call` barrier could hold this website request: a matcher check, `where` and status aside.""" return any((trig["spec"].get("barrier") or {}).get("at") == "provoking_call" and self._provoked_by(trig, role) - and _website_matches(trig["spec"]["when"], app, method, path, status) for trig in self._triggers.values()) + and _website_matches(trig["spec"]["when"], app, method, path, status, op) + for trig in self._triggers.values()) @property def has_website_triggers(self) -> bool: @@ -1608,10 +1623,13 @@ def _normalize_provoked_when(when: dict, tid: str) -> None: raise TriggerError(f"trigger '{tid}': when.repeat must be a boolean") -def _website_matches(when: dict, app: str, method: str, path: str, status: int) -> bool: - """Whether a request matches a website `when` (`where` aside); without `status`, only a response below 400 does.""" +def _website_matches(when: dict, app: str, method: str, path: str, status: int, op: str | None = None) -> bool: + """Whether a request matches a website `when` (`where` aside); with `op`, only one reaching that route does, and + without `status`, only a response below 400 does.""" if when["type"] != "website" or when["app"] != app or when.get("method", method) != method: return False + if "op" in when and when["op"] != op: + return False if "path" in when and not _eval_predicate(when["path"], path): return False return _eval_predicate(when["status"], status) if "status" in when else status < 400 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 03913adac..319329d45 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -7,6 +7,7 @@ import json import logging import re +import secrets import shlex import time from collections import Counter @@ -23,7 +24,9 @@ from agent_env.env.gateway import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode from agent_env.env.gateway.constants import ( DEFAULT_MCP_SERVER_NAME, + GATEWAY_ROLE_KEY_ENV, STATE_TOOL_NAME_CLASHES_KEY, + WEBSITE_ROLE_KEY_ENV, WELL_KNOWN_PATH, random_mcp_server_name, tool_name_clashes_text, @@ -117,6 +120,7 @@ class DeployedGateway: # The env card the readiness probe read, and when (ISO 8601, UTC). environment_card: dict | None = None environment_card_read_at_utc: str | None = None + role_key: str | None = None @dataclass @@ -281,6 +285,7 @@ def create_docker_compose( host_ips: tuple[str, ...] = (), extra_hosts: tuple[str, ...] = (), mcp_server_name: str | None = None, + role_key: str | None = None, ) -> str: """Generate docker-compose.yml content for a gateway deployment onto a VM/laptop/arbitrary machine. @@ -292,6 +297,8 @@ def create_docker_compose( gateway_image: Docker image for the gateway. gateway_port: Port to expose for the gateway. website_configs: Optional list of website configurations (frontend + backend pairs). + role_key: The deploy's role key, given to the gateway as AGENTENV_ROLE_KEY and to each website server as + WEBSITE_ROLE_KEY; without one, both believe the AgentEnv-Role header as before it. Returns: docker-compose.yml content as a string. @@ -489,6 +496,8 @@ def create_docker_compose( gateway_env.append(f" - REST_PROXY_URLS={','.join(rest_proxy_urls)}") gateway_env.append(f" - GATEWAY_MODE={gateway_mode.value}") gateway_env.append(f" - SERVICE_DB_URL={state_provider.base_url(state_instance)}") + if role_key: + gateway_env.append(f" - {GATEWAY_ROLE_KEY_ENV}={role_key}") publish = host_port or (lambda port: port) exposed_gateway_ports = [] if expose_gateway_port: @@ -534,6 +543,7 @@ def create_docker_compose( f" - ENVIRONMENT_NAME={wc.environment_name}", f" - PORT={AGENT_ENV_WEBSITE_BACKEND_PORT}", " - PYTHONUNBUFFERED=1", + *([f" - {WEBSITE_ROLE_KEY_ENV}={role_key}"] if role_key else []), " healthcheck:", f' test: ["CMD-SHELL", "curl -sf -o /dev/null http://localhost:{AGENT_ENV_WEBSITE_BACKEND_PORT}{WELL_KNOWN_PATH}' f' || curl -sf -o /dev/null http://localhost:{AGENT_ENV_WEBSITE_BACKEND_PORT}{wc.health_path}"]', @@ -601,10 +611,12 @@ async def deploy( attribution: Optional[Attribution] = None, artifact_id: str | None = None, artifact_version: int | None = None, + keyed_roles: bool = False, ) -> DeployedGatewayEnv: """A gateway in front of the env, with the state store it acquires (local Postgres by default); returns the record. A MultiEnv's gateway fronts its MCP servers and websites, a WebsiteEnv's its website, and any other env's its one MCP server. + With ``keyed_roles``, the deploy draws a role key (see ``create_gateway``). """ from agent_env.config import get_config, service_db_env_id from agent_env.env.env import Env @@ -623,6 +635,7 @@ async def deploy( mcp_server_images=topology.mcp_server_images, website_configs=topology.website_configs, website_images=topology.website_images, mcp_server_name=topology.mcp_server_name, gateway_mode=gateway_mode, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, attribution=attribution, + keyed_roles=keyed_roles, ) return DeployedGatewayEnv( env_id=env.id, @@ -639,6 +652,7 @@ async def deploy( website_frontend_urls=deployed.website_frontend_urls, gateway_mode=gateway_mode.value, env_state_instance_ids=deployed.env_state_instance_ids, + role_key=deployed.role_key, ) async def create_gateway( @@ -661,6 +675,7 @@ async def create_gateway( mcp_server_name: str | None = None, *, attribution: Optional[Attribution] = None, + keyed_roles: bool = False, ) -> DeployedGateway: """Create and start a gateway from explicit topology, for a caller that builds its own record rather than calling ``deploy()``. @@ -674,20 +689,26 @@ async def create_gateway( ``mcp_server_name`` is the name the gateway presents on its card and as its MCP server; drawn as ``env`` + 4 random digits when the env declares none; the record takes it from the gateway's card. + + ``keyed_roles`` draws a secret role key for the deploy, given to the gateway and each website server and kept on + the record: the gateway then believes a role other than ``default`` only beside it, so every caller acting as one + must send it. Off, as before the key, the role header alone is believed. """ return await self._launch( sandbox_provider, env_id, state_instance=state_instance, mcp_servers=mcp_servers, mcp_server_images=mcp_server_images, gateway_port=gateway_port, website_configs=website_configs, website_images=website_images, gateway_mode=gateway_mode, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, existing_sandbox=existing_sandbox, sidecars=sidecars, mcp_server_name=mcp_server_name, - attribution=attribution, + attribution=attribution, keyed_roles=keyed_roles, ) - async def _launch(self, sandbox_provider: SandboxProvider, env_id: str | None, *, mcp_server_name: str | None = None, **gateway) -> DeployedGateway: + async def _launch(self, sandbox_provider: SandboxProvider, env_id: str | None, *, mcp_server_name: str | None = None, + keyed_roles: bool = False, **gateway) -> DeployedGateway: """One gateway, deployed on the sandbox provider or each chained member in turn, then its tools probed.""" attempt = functools.partial( self._deploy_gateway, **gateway, mcp_server_name=mcp_server_name or random_mcp_server_name(), # drawn once, so every chained attempt presents the same name + role_key=secrets.token_urlsafe(32) if keyed_roles else None, ) earlier_sandbox = self._sandbox result = await self._run(sandbox_provider, env_id, attempt) @@ -739,6 +760,7 @@ async def _deploy_via_vm( existing_sandbox: Sandbox | None = None, sidecars: list[SidecarConfig] | None = None, mcp_server_name: str | None = None, + role_key: str | None = None, ) -> DeployedGateway: from agent_env.env.env import Env from agent_env.config import gateway_env_id, get_config, website_browser_env_id @@ -830,6 +852,7 @@ async def _stand_up(_spec): host_ips=sandbox.host_ips, extra_hosts=sandbox.extra_hosts, mcp_server_name=mcp_server_name, + role_key=role_key, ) logger.info(f"Generated docker-compose.yml:\n{_redact_compose_secrets(compose_content)}") @@ -923,7 +946,7 @@ async def _stand_up(_spec): gateway_url=gateway_url, mcp_url=mcp_url, db_web_url=db_web_url, db_mcp_url=db_mcp_url, website_frontend_urls=website_frontend_urls, mcp_server_name=mcp_server_name, env_state_instance_ids=[state_instance.instance_id], - environment_card=card, environment_card_read_at_utc=card_read_at_utc, + environment_card=card, environment_card_read_at_utc=card_read_at_utc, role_key=role_key, ) async def _deploy_via_containers( @@ -940,6 +963,7 @@ async def _deploy_via_containers( memory_mb: int | None = None, attribution: Optional[Attribution] = None, mcp_server_name: str | None = None, + role_key: str | None = None, ) -> DeployedGateway: from agent_env.env.env import Env from agent_env.config import gateway_env_id, get_config @@ -1036,6 +1060,7 @@ async def _deploy_via_containers( "GATEWAY_MODE": gateway_mode.value, "SERVICE_DB_URL": self._state_provider.base_url(state_instance), "MCP_SERVER_NAME": mcp_server_name or DEFAULT_MCP_SERVER_NAME, + **({GATEWAY_ROLE_KEY_ENV: role_key} if role_key else {}), } gateway_sb = await sandbox_provider.create_container( image_name=gateway_env.docker_image_artifact.image_name, @@ -1069,6 +1094,7 @@ async def _deploy_via_containers( env_state_instance_ids=[state_instance.instance_id], environment_card=card, environment_card_read_at_utc=card_read_at_utc, + role_key=role_key, ) except Exception: await self.close() @@ -1370,6 +1396,7 @@ async def _deploy_gateway( sidecars: list[SidecarConfig] | None = None, mcp_server_name: str | None = None, attribution: Optional[Attribution] = None, + role_key: str | None = None, ) -> DeployedGateway: """One gateway deploy on one (unchained) sandbox provider: a VM on one that creates VMs, else a container per server on its private network.""" @@ -1394,7 +1421,7 @@ async def _deploy_gateway( sandbox_provider, mcp_servers, mcp_server_images, gateway_port=gateway_port, website_configs=website_configs, gateway_mode=gateway_mode, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, - cpu=cpu, memory_mb=memory_mb, mcp_server_name=mcp_server_name, **deploy_kwargs, + cpu=cpu, memory_mb=memory_mb, mcp_server_name=mcp_server_name, role_key=role_key, **deploy_kwargs, ) earlier_sandbox = self._sandbox try: @@ -1403,7 +1430,7 @@ async def _deploy_gateway( gateway_port=gateway_port, website_configs=website_configs, website_images=website_images, gateway_mode=gateway_mode, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, existing_sandbox=existing_sandbox, sidecars=sidecars, - mcp_server_name=mcp_server_name, **deploy_kwargs, + mcp_server_name=mcp_server_name, role_key=role_key, **deploy_kwargs, ) except BaseException: await self._terminate_new_sandbox(earlier_sandbox=earlier_sandbox, existing_sandbox=existing_sandbox) @@ -1566,7 +1593,7 @@ def _compose_name_problems(mcp_servers: list[MCPServerConfig], website_configs: # Compose `environment:` keys whose values are secrets and must be masked before the # rendered compose is logged. Matches the key to the left of the first '=' on an # ` - KEY=VALUE` env line. -_SECRET_ENV_KEY_RE = re.compile(r"^(\s*-\s*)([A-Za-z0-9_]*(?:SECRET|PASSWORD|TOKEN|API_KEY)[A-Za-z0-9_]*)=.*$") +_SECRET_ENV_KEY_RE = re.compile(r"^(\s*-\s*)([A-Za-z0-9_]*(?:SECRET|PASSWORD|TOKEN|API_KEY|ROLE_KEY)[A-Za-z0-9_]*)=.*$") def _compose_ps_rows(text: str) -> list[dict]: diff --git a/src/agent_env/task/episode/run.py b/src/agent_env/task/episode/run.py index 2b082cd69..5ee2a6214 100644 --- a/src/agent_env/task/episode/run.py +++ b/src/agent_env/task/episode/run.py @@ -283,9 +283,11 @@ def _raise_unless_completed(self) -> None: if (error := self._run.exception()) is not None: raise error - async def _serve_in_process(self, agent: str, role: str | None, mcp_urls: dict[str, str]) -> DeployedAgent: + async def _serve_in_process(self, agent: str, role: str | None, mcp_urls: dict[str, str], + role_keys: dict[str, str | None] | None = None) -> DeployedAgent: """A proxy served in this process for ``agent``, which works in no sandbox, with each env server registered as - ``deploy_agent`` registers them with any agent, at the URL this process reaches: played as the others are.""" + ``deploy_agent`` registers them with any agent, at the URL this process reaches, with its deploy's role key + from ``role_keys``: played as the others are.""" server = InProcessProxy() self._in_process.append(server) url = await server.start() @@ -295,7 +297,8 @@ async def _serve_in_process(self, agent: str, role: str | None, mcp_urls: dict[s card = response.json() mcp_config = A2AAgent.find_extension(card, A2AAgent.EXT_MCP_CONFIG) for env_id, mcp_url in mcp_urls.items(): - body = _mcp_add_body(mcp_config, mcp_url, sandbox_request_headers_for_url(mcp_url), env_id, role) + body = _mcp_add_body(mcp_config, mcp_url, sandbox_request_headers_for_url(mcp_url), env_id, role, + (role_keys or {}).get(env_id)) (await client.post(url + mcp_config["params"]["endpoint"], json=body)).raise_for_status() deployed = DeployedAgent(agent_name=agent, api_url=url, a2a_url=url, a2a_card=card, role=role) await self._attach(agent, deployed) diff --git a/src/agent_env/task/episode/steps.py b/src/agent_env/task/episode/steps.py index 9bcc85816..7b8c12e51 100644 --- a/src/agent_env/task/episode/steps.py +++ b/src/agent_env/task/episode/steps.py @@ -45,11 +45,12 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: episode = _episode_of(context) agent = self.step.agent_name if not self.sandboxed: - mcp_urls = {} + mcp_urls, role_keys = {}, {} for env_id in getattr(self.step, "env_ids", None) or []: deployed = bound_deployment(context, env_id, env_step_id=self.step.env_step_id, step_id=self.id) mcp_urls[env_id] = deployed.mcp_url if deployed is not None else live_mcp_url(env_id) - deployed = await episode._serve_in_process(agent, getattr(self.step, "role", None), mcp_urls) + role_keys[env_id] = deployed.role_key if deployed is not None else None + deployed = await episode._serve_in_process(agent, getattr(self.step, "role", None), mcp_urls, role_keys) context.deployed_agents.append(deployed) if system_prompt := getattr(self.step, "system_prompt", None): config = A2AAgent.negotiate_agent_config(deployed.a2a_card, {"system_prompt": system_prompt}) diff --git a/src/agent_env/task_step/task_steps/deploy_agent.py b/src/agent_env/task_step/task_steps/deploy_agent.py index cbacdc804..1f37a3847 100644 --- a/src/agent_env/task_step/task_steps/deploy_agent.py +++ b/src/agent_env/task_step/task_steps/deploy_agent.py @@ -31,7 +31,7 @@ from agent_env.a2a_agent.staging import StagedObjectStore, transfer_store from agent_env.config import get_config from agent_env.env.env import DeployedEnv, DeployedSandboxEnv, Env -from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER +from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER, AGENT_ENV_ROLE_KEY_HEADER from agent_env.providers.sandbox_providers.local_docker.provider import host_url_for from agent_env.a2a_agent.a2a_agent import DEFAULT_A2A_PORT, unwritten_local_agent from agent_env.providers.sandbox_providers.sandbox_provider import ( @@ -70,9 +70,11 @@ def _mcp_add_request(mcp_ext: dict) -> dict: return request if isinstance(request, dict) else {} -def _mcp_add_body(mcp_ext: dict, url: str, headers: dict | None, card_name: str | None, role: str | None = None) -> dict: +def _mcp_add_body(mcp_ext: dict, url: str, headers: dict | None, card_name: str | None, role: str | None = None, + role_key: str | None = None) -> dict: """Body for the agent's mcp-config `add`. The card name and the role header ride along only when the card's `add` - takes them, judged on the exact field set sent; what an env does with the role is the env's business.""" + takes them, judged on the exact field set sent, the deploy's `role_key` beside the role so its gateway believes + it; what an env does with the role is the env's business.""" request = _mcp_add_request(mcp_ext) body: dict = {"url": url} if headers: @@ -81,6 +83,8 @@ def _mcp_add_body(mcp_ext: dict, url: str, headers: dict | None, card_name: str body["name"] = card_name if role and card_request_accepts(request, {*body, "headers"}): body.setdefault("headers", {})[AGENT_ENV_ROLE_HEADER] = role + if role_key: + body["headers"][AGENT_ENV_ROLE_KEY_HEADER] = role_key return body @@ -433,7 +437,8 @@ async def _execute(self, context: TaskStepContext, deployed_agents: list) -> Tas url = host_url_for(live_mcp_url(env_id), deployed.sandbox_type) card_name = None if mcp_ext: - body = _mcp_add_body(mcp_ext, url, sandbox_request_headers_for_url(url), card_name, self.role) + body = _mcp_add_body(mcp_ext, url, sandbox_request_headers_for_url(url), card_name, self.role, + deployed_env.role_key if deployed_env is not None else None) registrations.append((env_id, body)) identity_payload: dict[str, str] = {"name": self.agent_name} diff --git a/src/agent_env/task_step/task_steps/deploy_env.py b/src/agent_env/task_step/task_steps/deploy_env.py index 375a4c515..55d605b0a 100644 --- a/src/agent_env/task_step/task_steps/deploy_env.py +++ b/src/agent_env/task_step/task_steps/deploy_env.py @@ -48,6 +48,7 @@ def __init__( artifact_id: Optional[str] = None, artifact_version: Optional[int] = None, env_params: Optional[dict] = None, + keyed_roles: bool = False, ): super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error) self.env_id = env_id @@ -66,6 +67,7 @@ def __init__( self.metadata = metadata or {} # The env type's own deploy options, which this step passes to its deploy() without reading them. self.env_params = env_params + self.keyed_roles = keyed_roles def run_state(self, user_overrides: Mapping[str, Any]) -> tuple[str | None, str | None]: """The env state type and attached state instance a run with ``user_overrides`` deploys on: each the run's, @@ -89,6 +91,7 @@ def to_dict(self) -> dict: base["env_state_instance_id"] = self.env_state_instance_id base["metadata"] = self.metadata base["env_params"] = self.env_params + base["keyed_roles"] = self.keyed_roles return base @classmethod @@ -109,6 +112,7 @@ def from_dict(cls, data: dict) -> DeployEnvTaskStep: env_state_instance_id=data.get("env_state_instance_id"), metadata=dict(data.get("metadata") or {}), env_params=data.get("env_params"), + keyed_roles=bool(data.get("keyed_roles", False)), ) def preflight(self) -> list[str]: @@ -133,7 +137,8 @@ def preflight(self) -> list[str]: return [f"deploy_env '{self.id}': {refusal}"] if refusal else [] def _env_params_option(self) -> dict: - return {"env_params": self.env_params} if self.env_params else {} + return {**({"env_params": self.env_params} if self.env_params else {}), + **({"keyed_roles": True} if self.keyed_roles else {})} async def execute(self, context: TaskStepContext) -> TaskStepContext: from agent_env.env.env import Env @@ -188,6 +193,10 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: if "env_params" not in params and not accepts_kwargs: raise ValueError(f"deploy_env '{self.id}': {env.type} envs take no env_params") deploy_kwargs["env_params"] = self.env_params + if self.keyed_roles: + if "keyed_roles" not in params and not accepts_kwargs: + raise ValueError(f"deploy_env '{self.id}': {env.type} envs take no keyed_roles") + deploy_kwargs["keyed_roles"] = True deployed_env = await env.deploy(**deploy_kwargs) # create_instance runs inside env.deploy(), so an annotation set after it # returns has to be persisted explicitly to reach the instance record. diff --git a/src/agent_env/task_step/task_steps/load_artifact.py b/src/agent_env/task_step/task_steps/load_artifact.py index fdecb9e75..c79d6153c 100644 --- a/src/agent_env/task_step/task_steps/load_artifact.py +++ b/src/agent_env/task_step/task_steps/load_artifact.py @@ -645,7 +645,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: if not (agent.on_host and agent.sandbox_type == LocalSandbox.type): gateway_url = reachable_url(gateway_url, from_sandbox_type=deployed.sandbox_type, to_sandbox_type=agent.sandbox_type) - install_path = await A2AAgent.install_cli(deployed_agent, artifact, gateway_url, **_agent_files(agent)) + install_path = await A2AAgent.install_cli(deployed_agent, artifact, gateway_url, role_key=deployed.role_key, + **_agent_files(agent)) agent_clis = context.metadata.setdefault("installed_clis", {}).setdefault(self.agent_name, {}) agent_clis[artifact.id] = { "install_path": install_path, diff --git a/src/agent_env/task_step/task_steps/mcp_cli_builder/codegen.py b/src/agent_env/task_step/task_steps/mcp_cli_builder/codegen.py index 4be0384b0..9853400f2 100644 --- a/src/agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +++ b/src/agent_env/task_step/task_steps/mcp_cli_builder/codegen.py @@ -14,10 +14,11 @@ from agentenv_protocol import MANIFEST_VERSION, manifest_compatible -from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER +from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER, AGENT_ENV_ROLE_KEY_HEADER GATEWAY_URL_ENV_VAR = "AGENT_ENV_GATEWAY_URL" ROLE_ENV_VAR = "AGENT_ENV_ROLE" +ROLE_KEY_ENV_VAR = "AGENT_ENV_ROLE_KEY" DEFAULT_CLI_ROLE = "cli" _COMMAND_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$") _WORD_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z]|$)|[A-Z]?[a-z]+|[0-9]+") @@ -166,6 +167,8 @@ def generate_cli_script( role_env_var=ROLE_ENV_VAR, default_role=DEFAULT_CLI_ROLE, role_header=AGENT_ENV_ROLE_HEADER, + role_key_env_var=ROLE_KEY_ENV_VAR, + role_key_header=AGENT_ENV_ROLE_KEY_HEADER, ) @@ -186,6 +189,8 @@ def generate_cli_script( ROLE_ENV = "{role_env_var}" DEFAULT_ROLE = "{default_role}" ROLE_HEADER = "{role_header}" +ROLE_KEY_ENV = "{role_key_env_var}" +ROLE_KEY_HEADER = "{role_key_header}" SERVICE_NAME = {environment_name} INTERFACE_MANIFEST = {interface_manifest_literal} _CONFIG_FILE_NAME = ".env" @@ -226,11 +231,17 @@ def _role(): return os.environ.get(ROLE_ENV, DEFAULT_ROLE) +def _role_headers(): + """The role header, with the deploy's role key beside it when one is set, so the gateway believes the role.""" + key = os.environ.get(ROLE_KEY_ENV) + return {{ROLE_HEADER: _role(), **({{ROLE_KEY_HEADER: key}} if key else {{}})}} + + def _post_step(payload): response = httpx.post( f"{{_gateway_url()}}/step", json=payload, - headers={{ROLE_HEADER: _role()}}, + headers=_role_headers(), timeout=120, ) response.raise_for_status() @@ -248,7 +259,7 @@ def _list_tools(): allowed_names = {{t.get("name") for t in (role_filtered.get("tools") or []) if t.get("name")}} state_resp = httpx.get( f"{{_gateway_url()}}/state", - headers={{ROLE_HEADER: _role()}}, + headers=_role_headers(), timeout=120, ) state_resp.raise_for_status() diff --git a/src/agent_env/task_step/task_steps/post_turn_hooks.py b/src/agent_env/task_step/task_steps/post_turn_hooks.py index 0afb54b71..cca67b1fd 100644 --- a/src/agent_env/task_step/task_steps/post_turn_hooks.py +++ b/src/agent_env/task_step/task_steps/post_turn_hooks.py @@ -22,7 +22,6 @@ from agent_env.artifact import FileArtifact from agent_env.env.env import DeployedEnv, EnvCapabilityUnsupported from agent_env.env.gateway.constants import ( - AGENT_ENV_ROLE_HEADER, EXT_CLOCK_URI, EXT_NOTIFICATIONS_URI, EXT_STEP_URI, @@ -152,7 +151,7 @@ async def call_tool(self, tool: str, args: Optional[dict], role: str) -> dict: "source": STEP_SOURCE_POST_TURN_HOOK, "hook_artifact_id": self._hook_artifact_id} async with httpx.AsyncClient() as client: resp = await client.request(op["method"], f"{self._deployed.environment_url}{op['endpoint']}", json=body, - headers={AGENT_ENV_ROLE_HEADER: role, **self._deployed.control_headers}, + headers={**self._deployed.role_headers(role), **self._deployed.control_headers}, timeout=_TOOL_CALL_TIMEOUT_S) if resp.status_code >= 400: raise RuntimeError(f"env '{self.env_id}': /step call_tool {tool!r} failed (HTTP {resp.status_code}): " diff --git a/src/agent_env/task_step/task_steps/register_env_triggers.py b/src/agent_env/task_step/task_steps/register_env_triggers.py index 156d17fb9..237f77259 100644 --- a/src/agent_env/task_step/task_steps/register_env_triggers.py +++ b/src/agent_env/task_step/task_steps/register_env_triggers.py @@ -21,6 +21,7 @@ TRIGGER_PROVOKING_ROLES, TRIGGER_UNLESS, TRIGGER_WEBSITE, + TRIGGER_WEBSITE_OP, ) from agent_env.task_step.context import TaskStepContext from agent_env.entity.refs import EntityRef @@ -146,12 +147,15 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: f"redeploy it on a gateway image that does.") # An older gateway ignores what it does not declare: named actions would go to the default executor, a # trigger's when.roles would leave it to the watched roles, delays and hours would not wait, a deadline - # would fire even after its `unless` call, and hours in a time zone would be read as UTC. + # would fire even after its `unless` call, hours in a time zone would be read as UTC, and a website trigger + # naming an `op` would fire on every request to the site. used = {TRIGGER_NAMED_EXECUTORS: bool(named), TRIGGER_PROVOKING_ROLES: _when_has(self.triggers, "roles"), TRIGGER_ACTION_DELAY: _delays(self.triggers), TRIGGER_EXECUTOR_HOURS: bool(self.executor_hours), TRIGGER_UNLESS: _when_has(self.triggers, "unless"), TRIGGER_WEBSITE: any(isinstance(t.get("when"), dict) and t["when"].get("type") == "website" for t in self.triggers), + TRIGGER_WEBSITE_OP: any(isinstance(t.get("when"), dict) and t["when"].get("type") == "website" + and t["when"].get("op") is not None for t in self.triggers), TRIGGER_EXECUTOR_HOURS_TIME_ZONE: _hours_in_a_time_zone(self.executor_hours), TRIGGER_NOTIFY: any(a.get("type") == "notify" for t in self.triggers for a in t.get("actions") or [] if isinstance(a, dict))} diff --git a/tst/integration/env/gateway/gateway_test.py b/tst/integration/env/gateway/gateway_test.py index ab4057715..eefe02c96 100644 --- a/tst/integration/env/gateway/gateway_test.py +++ b/tst/integration/env/gateway/gateway_test.py @@ -780,7 +780,7 @@ async def assert_generated_data_via_step(client, gateway_url, tools, tool_names) cli_list = await client.post( f"{result.gateway_url}/step", json={"action": "list_tools"}, - headers={AGENT_ENV_ROLE_HEADER: "cli"}, + headers=result.role_headers("cli"), timeout=30, ) cli_list.raise_for_status() @@ -797,7 +797,7 @@ async def assert_generated_data_via_step(client, gateway_url, tools, tool_names) cli_call = await client.post( f"{result.gateway_url}/step", json={"action": "call_tool", "tool_name": "list_emails", "arguments": {"folder_name": "INBOX"}}, - headers={AGENT_ENV_ROLE_HEADER: "cli"}, + headers=result.role_headers("cli"), timeout=30, ) assert cli_call.status_code == 200, f"cli call failed: {cli_call.status_code} {cli_call.text}" @@ -814,7 +814,7 @@ async def assert_generated_data_via_step(client, gateway_url, tools, tool_names) async with streamable_http_client( result.mcp_url, - http_client=httpx.AsyncClient(headers={AGENT_ENV_ROLE_HEADER: "cli"}), + http_client=httpx.AsyncClient(headers=result.role_headers("cli")), ) as (read_stream, write_stream, _): async with ClientSession(read_stream, write_stream) as session: await session.initialize() @@ -826,7 +826,7 @@ async def assert_generated_data_via_step(client, gateway_url, tools, tool_names) assert "list_emails" not in [t.name for t in await default_session.list_tools()] hidden = await default_session.call_tool("list_emails", {"folder_name": "INBOX"}) assert hidden.isError and "is disabled for role 'default'" in hidden.text, hidden - async with ToolSession(mcp_url=result.mcp_url, role="cli") as cli_session: + async with ToolSession(mcp_url=result.mcp_url, headers=result.role_headers("cli")) as cli_session: assert "list_emails" in [t.name for t in await cli_session.list_tools()] assert "alex.chen@techcorp.com" in (await cli_session.call_tool("list_emails", {"folder_name": "INBOX"})).text @@ -869,7 +869,7 @@ async def assert_generated_data_via_step(client, gateway_url, tools, tool_names) assert state_resp.json()["roles"]["*"] == {"disabled": ["list_emails"], "allowed": []} for role in (None, "cli", "marketing"): - headers = {AGENT_ENV_ROLE_HEADER: role} if role else {} + headers = result.role_headers(role) if role else {} lst = await client.post( f"{result.gateway_url}/step", json={"action": "list_tools"}, headers=headers, timeout=30, ) @@ -879,14 +879,14 @@ async def assert_generated_data_via_step(client, gateway_url, tools, tool_names) blocked = await client.post( f"{result.gateway_url}/step", json={"action": "call_tool", "tool_name": "list_emails", "arguments": {"folder_name": "INBOX"}}, - headers={AGENT_ENV_ROLE_HEADER: "marketing"}, + headers=result.role_headers("marketing"), timeout=30, ) assert blocked.status_code == 403, f"expected 403 for never-seen role under global disable, got {blocked.status_code} {blocked.text}" async with streamable_http_client( result.mcp_url, - http_client=httpx.AsyncClient(headers={AGENT_ENV_ROLE_HEADER: "cli"}), + http_client=httpx.AsyncClient(headers=result.role_headers("cli")), ) as (read_stream, write_stream, _): async with ClientSession(read_stream, write_stream) as session: await session.initialize() @@ -926,7 +926,7 @@ async def assert_generated_data_via_step(client, gateway_url, tools, tool_names) mkt_lst = await client.post( f"{result.gateway_url}/step", json={"action": "list_tools"}, - headers={AGENT_ENV_ROLE_HEADER: "marketing"}, + headers=result.role_headers("marketing"), timeout=30, ) mkt_lst.raise_for_status() @@ -944,7 +944,7 @@ async def assert_generated_data_via_step(client, gateway_url, tools, tool_names) cli_lst = await client.post( f"{result.gateway_url}/step", json={"action": "list_tools"}, - headers={AGENT_ENV_ROLE_HEADER: "cli"}, + headers=result.role_headers("cli"), timeout=30, ) cli_lst.raise_for_status() @@ -955,7 +955,7 @@ async def assert_generated_data_via_step(client, gateway_url, tools, tool_names) mkt_lst = await client.post( f"{result.gateway_url}/step", json={"action": "list_tools"}, - headers={AGENT_ENV_ROLE_HEADER: "marketing"}, + headers=result.role_headers("marketing"), timeout=30, ) mkt_lst.raise_for_status() @@ -970,7 +970,7 @@ async def assert_generated_data_via_step(client, gateway_url, tools, tool_names) cli_lst_after_reset = await client.post( f"{result.gateway_url}/step", json={"action": "list_tools"}, - headers={AGENT_ENV_ROLE_HEADER: "cli"}, + headers=result.role_headers("cli"), timeout=30, ) cli_lst_after_reset.raise_for_status() @@ -1002,7 +1002,7 @@ async def assert_generated_data_via_step(client, gateway_url, tools, tool_names) blocked = await client.post( f"{result.gateway_url}/step", json={"action": "call_tool", "tool_name": "list_emails", "arguments": {"folder_name": "INBOX"}}, - headers={AGENT_ENV_ROLE_HEADER: "step-role"}, + headers=result.role_headers("step-role"), timeout=30, ) assert blocked.status_code == 403, f"expected 403 after step-driven disable, got {blocked.status_code} {blocked.text}" @@ -1020,7 +1020,7 @@ async def assert_generated_data_via_step(client, gateway_url, tools, tool_names) unblocked = await client.post( f"{result.gateway_url}/step", json={"action": "call_tool", "tool_name": "list_emails", "arguments": {"folder_name": "INBOX"}}, - headers={AGENT_ENV_ROLE_HEADER: "step-role"}, + headers=result.role_headers("step-role"), timeout=30, ) assert unblocked.status_code == 200, f"expected 200 after step-driven enable, got {unblocked.status_code} {unblocked.text}" @@ -1816,7 +1816,7 @@ async def test_gateway_changes_name_the_role_behind_each_write(mcp_server_envs, seen = (await client.get(f"{gw}/changes", params={"limit": 10000}, timeout=30)).json()["changes"] after_id = seen[-1]["id"] if seen else 0 for role, subject in (("default", "first"), ("alice@example.com", "second")): - resp = await client.post(f"{gw}/step", headers={AGENT_ENV_ROLE_HEADER: role}, timeout=60, json={ + resp = await client.post(f"{gw}/step", headers=result.role_headers(role), timeout=60, json={ "action": "call_tool", "tool_name": "send_email", "arguments": {"recipients": ["dana@example.com"], "subject": subject, "content": "body"}}) assert resp.status_code == 200, resp.text @@ -1850,7 +1850,7 @@ async def test_gateway_changes_name_the_request_behind_a_write_through_the_rest_ async with httpx.AsyncClient() as client: seen = (await client.get(f"{gw}/changes", params={"limit": 10000}, timeout=30)).json()["changes"] after_id = seen[-1]["id"] if seen else 0 - async with httpx.AsyncClient(headers={AGENT_ENV_ROLE_HEADER: "carol@example.com"}, timeout=60) as as_carol: + async with httpx.AsyncClient(headers=result.role_headers("carol@example.com"), timeout=60) as as_carol: async with streamable_http_client(f"{gw}/svc/mcp-email/mcp", http_client=as_carol) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() @@ -2170,7 +2170,7 @@ async def assert_tool_sees_items(session, tools_result, tool_names): await _verify_list_tools(mcp_url=result.mcp_url, tool_verifier_callback=assert_tool_sees_items) # The role a ToolSession sends reaches the child's tool as its caller, with the gateway's key for each session - async with (ToolSession(mcp_url=result.mcp_url, role="alice@example.com") as alice, + async with (ToolSession(mcp_url=result.mcp_url, headers=result.role_headers("alice@example.com")) as alice, ToolSession(mcp_url=result.mcp_url) as anonymous): seen = [json.loads((await session.call_tool("items_whoami")).text) for session in (alice, alice, anonymous)] assert [caller["role"] for caller in seen] == ["alice@example.com", "alice@example.com", None] @@ -2548,7 +2548,7 @@ async def _poll(client, gw, want_fired, timeout_s=120): hr = await client.post(f"{gw}/step", json={"action": "call_tool", "tool_name": "search_emails", "arguments": {"query": "alex.chen", "folder_name": "INBOX"}}, - headers={AGENT_ENV_ROLE_HEADER: "harness"}, timeout=60) + headers=result.role_headers("harness"), timeout=60) assert hr.status_code == 200, f"harness /step failed: {hr.text}" await asyncio.sleep(4) assert {t["id"]: t["status"] for t in (await _state(client, gw))["triggers"]}["decoy"] == "armed", ( @@ -3064,7 +3064,7 @@ async def test_gateway_triggers_nl_executor(sandbox_provider, mcp_server_envs, e # The one-tool constraint really held (determinism lever, verified live). lt = await client.post(f"{gw}/step", json={"action": "list_tools"}, - headers={AGENT_ENV_ROLE_HEADER: "executor"}, timeout=30) + headers=result.role_headers("executor"), timeout=30) exec_tools = [t["name"] for t in lt.json()["tools"]] assert exec_tools == ["send_email"], f"executor tool surface not locked to one tool: {exec_tools}" @@ -3105,7 +3105,7 @@ async def _state(): se = await client.post(f"{gw}/step", json={"action": "call_tool", "tool_name": "search_emails", "arguments": {"query": marker, "folder_name": "SENT"}}, - headers={AGENT_ENV_ROLE_HEADER: "harness"}, timeout=30) + headers=result.role_headers("harness"), timeout=30) assert marker in se.text, f"marker email not found in SENT: {se.text[:400]}" # Cascade-safety: nlact was detected exactly once (from the provoke), NOT from the @@ -3351,17 +3351,17 @@ async def _rate_check(client, gw, rate, sleep_s=5.0): await client.post(f"{gw}/tools/{TOOL_DISABLE_ACTION}", json={"role": "locked", "tools": ["*"]}, timeout=30) locked = await client.post(f"{gw}/step", json={"action": "list_tools"}, - headers={AGENT_ENV_ROLE_HEADER: "locked"}, timeout=30) + headers=result.role_headers("locked"), timeout=30) assert GET_TIME not in [t["name"] for t in locked.json()["tools"]], "wildcard deny did not hide get_time" denied = await client.post(f"{gw}/step", json={"action": "call_tool", "tool_name": GET_TIME, "arguments": {}}, - headers={AGENT_ENV_ROLE_HEADER: "locked"}, timeout=30) + headers=result.role_headers("locked"), timeout=30) assert denied.status_code == 403, denied.text assert GET_TIME in (await _get_time_surfaces(client, gw, result.mcp_url)).step, "default role lost it" await client.post(f"{gw}/tools/{TOOL_ENABLE_ACTION}", json={"role": "locked", "tools": [GET_TIME]}, timeout=30) relocked = await client.post(f"{gw}/step", json={"action": "list_tools"}, - headers={AGENT_ENV_ROLE_HEADER: "locked"}, timeout=30) + headers=result.role_headers("locked"), timeout=30) assert GET_TIME in [t["name"] for t in relocked.json()["tools"]], "re-enable did not restore it" finally: await env.close() diff --git a/tst/integration/task_step/test_next_env_event_hook_local.py b/tst/integration/task_step/test_next_env_event_hook_local.py index de63dc0d7..103127090 100644 --- a/tst/integration/task_step/test_next_env_event_hook_local.py +++ b/tst/integration/task_step/test_next_env_event_hook_local.py @@ -12,7 +12,6 @@ import agent_env from agent_env.a2a_agent import conversation_store from agent_env.artifact import FileArtifact -from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep from agent_env.task_step.task_steps.register_env_triggers import RegisterEnvTriggersStep @@ -65,7 +64,7 @@ async def _run(label: str, triggers: list[dict], params: dict) -> tuple: state = (await client.get("/triggers/state")).raise_for_status().json() clock = (await client.get("/clock/state")).raise_for_status().json() stored = await client.post("/step", json={"action": "call_tool", "tool_name": "list_items", "arguments": {}}, - headers={AGENT_ENV_ROLE_HEADER: "harness"}) + headers=deployed.role_headers("harness")) finally: await env.close() items = json.loads(stored.json()["content"][0]["text"])["items"] diff --git a/tst/integration/task_step/test_register_env_triggers_local.py b/tst/integration/task_step/test_register_env_triggers_local.py index 0a695d011..7c6964cca 100644 --- a/tst/integration/task_step/test_register_env_triggers_local.py +++ b/tst/integration/task_step/test_register_env_triggers_local.py @@ -17,9 +17,9 @@ import pytest import pytest_asyncio +from agent_env.env.env import DeployedEnv from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.multi_env import MultiEnv -from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER from agent_env.env.gateway.constants import METHOD_RESET, RPC_PATH, TRIGGER_IN_FLIGHT_STATUSES from agent_env.task_step.context import DeployedAgent, TaskStepContext from agent_env.task_step.task_steps.register_env_triggers import RegisterEnvTriggersStep @@ -66,8 +66,8 @@ async def test_each_nl_action_reaches_the_executor_it_names(items_env): {"id": "on-add", "when": {"type": "action", "tool": "items_add_item", "repeat": True}, "actions": []}, ] try: - alice = _Executor("alice", "alice@example.com", deployed.gateway_url, host, started) - bob = _Executor("bob", "bob@example.com", deployed.gateway_url, host, started) + alice = _Executor("alice", "alice@example.com", deployed, host, started) + bob = _Executor("bob", "bob@example.com", deployed, host, started) context = TaskStepContext(deployed_envs=[deployed], deployed_agents=[alice.agent, bob.agent], metadata={}) await RegisterEnvTriggersStep(id="reg", version=None, env_id=env.id, triggers=triggers, watch_roles=["default"], executor_timeout_seconds=60).execute(context) @@ -76,7 +76,7 @@ async def test_each_nl_action_reaches_the_executor_it_names(items_env): assert provoke.status_code == 200, provoke.text state = await _quiet(client, {"to-alice": 1, "to-bob": 1, "on-add": 0}) stored = await client.post("/step", json={"action": "call_tool", "tool_name": "list_items", "arguments": {}}, - headers={AGENT_ENV_ROLE_HEADER: "harness"}) + headers=deployed.role_headers("harness")) finally: for executor in started: executor.stop() @@ -108,8 +108,8 @@ async def test_personas_react_to_each_other_until_one_stays_quiet(items_env): "actions": [{"type": "nl", "executor": "denise", "instruction": "DENISE-TO-BOB"}]}, ] try: - denise = _Executor("denise", "denise@example.com", deployed.gateway_url, host, started) - bob = _Executor("bob", "bob@example.com", deployed.gateway_url, host, started, replies=1) + denise = _Executor("denise", "denise@example.com", deployed, host, started) + bob = _Executor("bob", "bob@example.com", deployed, host, started, replies=1) context = TaskStepContext(deployed_envs=[deployed], deployed_agents=[denise.agent, bob.agent], metadata={}) await RegisterEnvTriggersStep(id="reg", version=None, env_id=env.id, triggers=triggers, watch_roles=["default"], executor_timeout_seconds=60).execute(context) @@ -118,7 +118,7 @@ async def test_personas_react_to_each_other_until_one_stays_quiet(items_env): assert provoke.status_code == 200, provoke.text state = await _quiet(client, {"denise-hears-solver": 1, "bob-hears-denise": 2, "denise-hears-bob": 1}) stored = await client.post("/step", json={"action": "call_tool", "tool_name": "list_items", "arguments": {}}, - headers={AGENT_ENV_ROLE_HEADER: "harness"}) + headers=deployed.role_headers("harness")) finally: for executor in started: executor.stop() @@ -140,7 +140,7 @@ async def test_a_reply_waits_its_delay_and_hours_and_its_writes_name_the_fire(it triggers = [{"id": "bob-hears-solver", "when": {"type": "action", "tool": "list_items"}, "actions": [{"type": "nl", "executor": "bob", "instruction": "BOB-ACTS", "delay": "PT2M"}]}] try: - bob = _Executor("bob", "bob@example.com", deployed.gateway_url, host, started) + bob = _Executor("bob", "bob@example.com", deployed, host, started) context = TaskStepContext(deployed_envs=[deployed], deployed_agents=[bob.agent], metadata={}) async with httpx.AsyncClient(base_url=deployed.gateway_url, timeout=60) as client: # The env is shared, so its trajectory holds the earlier tests' calls: read this test's, after them. @@ -185,7 +185,7 @@ async def test_a_deadline_fires_on_silence_and_a_reply_cancels_it(items_env): "where": {"item": {"regex": f"^REPLY-{n}"}}}}, "actions": [{"type": "nl", "executor": "bob", "instruction": f"ESCALATE-{n}"}]}] try: - bob = _Executor("bob", "bob@example.com", deployed.gateway_url, host, started) + bob = _Executor("bob", "bob@example.com", deployed, host, started) context = TaskStepContext(deployed_envs=[deployed], deployed_agents=[bob.agent], metadata={}) async with httpx.AsyncClient(base_url=deployed.gateway_url, timeout=60) as client: await _arm_clock(client, "2026-10-14T10:00:00Z", 60) @@ -239,9 +239,10 @@ class _Executor: """An A2A executor on this machine that acts on each instruction through the gateway, under its own role; with ``replies``, it stops acting after that many.""" - def __init__(self, name: str, role: str, gateway_url: str, host: str, started: list, + def __init__(self, name: str, role: str, deployed: DeployedEnv, host: str, started: list, replies: int | None = None) -> None: - self.role, self.gateway_url, self.replies = role, gateway_url, replies + self.role, self.gateway_url, self.replies = role, deployed.gateway_url, replies + self.headers = deployed.role_headers(role) self.instructions: list[str] = [] self.acted_as: list[str] = [] self._server = ThreadingHTTPServer((host, 0), functools.partial(_ExecutorHandler, self)) @@ -265,7 +266,7 @@ def stop(self) -> None: def _call(self, tool: str, arguments: dict) -> dict: response = httpx.post(f"{self.gateway_url}/step", json={"action": "call_tool", "tool_name": tool, "arguments": arguments}, - headers={AGENT_ENV_ROLE_HEADER: self.role}, timeout=60) + headers=self.headers, timeout=60) response.raise_for_status() return response.json() diff --git a/tst/unit/env/envs/test_mcp_server_plugin_provider.py b/tst/unit/env/envs/test_mcp_server_plugin_provider.py index b37528070..a1d482d0d 100644 --- a/tst/unit/env/envs/test_mcp_server_plugin_provider.py +++ b/tst/unit/env/envs/test_mcp_server_plugin_provider.py @@ -230,7 +230,7 @@ async def test_a_multi_env_child_of_a_plugin_type_is_still_the_gateways(): multi = MultiEnv(id="multi", version=1, mcp_server_envs=[child]) record = SimpleNamespace(env_id="multi", env_version=1, sandbox_id="gw", sandbox_type="modal", sandbox_ids={}, env_state_instance_ids=[], instance_id="i", mcp_server_name="m", environment_card=None, - environment_card_url=None, gateway_url="https://gw.example") + environment_card_url=None, gateway_url="https://gw.example", role_key=None) gw = MagicMock(sandbox_id="gw", mode="vm", type="modal") with patch("agent_env.env.env.Env.get", return_value=multi), \ patch("agent_env.providers.build_sandbox_provider", return_value=MagicMock(get_sandbox=AsyncMock(return_value=gw))), \ diff --git a/tst/unit/env/envs/test_multi_env_name.py b/tst/unit/env/envs/test_multi_env_name.py index 0055bef90..70a9ea224 100644 --- a/tst/unit/env/envs/test_multi_env_name.py +++ b/tst/unit/env/envs/test_multi_env_name.py @@ -67,7 +67,7 @@ async def test_a_deploy_records_the_mcp_url_and_name_its_gateway_card_gives(): card = {"name": "crm", "additionalInterfaces": [{"url": "/mcp", "transport": "mcp"}]} result = SimpleNamespace(gateway_url="https://gw.example", mcp_url="https://gw.example/mcp", db_web_url=None, db_mcp_url=None, website_frontend_urls=None, environment_card=card, environment_card_read_at_utc="t", - env_state_instance_ids=[], mcp_server_name="crm") + env_state_instance_ids=[], mcp_server_name="crm", role_key=None) async def deploy_gateway(self, sandbox_provider, **kwargs): self._sandbox = SimpleNamespace(sandbox_id="gw", type="modal_vm") diff --git a/tst/unit/env/gateway/rest_proxy_stream_test.py b/tst/unit/env/gateway/rest_proxy_stream_test.py index f2378150f..79e8b5fb6 100644 --- a/tst/unit/env/gateway/rest_proxy_stream_test.py +++ b/tst/unit/env/gateway/rest_proxy_stream_test.py @@ -13,10 +13,13 @@ import httpx import pytest +from starlette.testclient import TestClient +from agentenv_protocol.website import ROLE_KEY_ENV, ApiContext, WebsiteEnvironment, api, website from agent_env.env.gateway import AGENT_ENV_ROLE_META_KEY, AGENT_ENV_SESSION_META_KEY from agent_env.env.gateway import gateway as gateway_module from agent_env.env.gateway import rest_proxy as rest_proxy_module +from agent_env.env.gateway.caller import Caller from agent_env.env.gateway.gateway import Gateway UPSTREAM = "http://upstream" @@ -479,3 +482,212 @@ async def test_bodies_that_are_not_a_child_mcp_request_are_forwarded_untouched(m status, _, _ = await _call(gw, path, method=method, body=body, headers={"AgentEnv-Role": "alice@example.com"}) assert status == 200, path assert [r.content for r in up.requests] == [body for _, _, body in cases] + + +ROLE_KEY = "deploy-secret" + + +def _role_and_key(request: httpx.Request) -> tuple[list[str], list[str]]: + return request.headers.get_list("agentenv-role"), request.headers.get_list("agentenv-role-key") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["/svc/mcp-svc/x", "/website/web/x"]) +async def test_with_a_role_key_a_role_is_believed_only_beside_it_and_the_key_goes_upstream_only_with_it(monkeypatch, path): + up = _Upstream([b"{}"]) + up.install(monkeypatch) + gw = _gateway(website_urls={"web": "http://web"}, role_key=ROLE_KEY) + for headers in ({"AgentEnv-Role": "admin"}, {"AgentEnv-Role": "admin", "AgentEnv-Role-Key": "guess"}, + {"AgentEnv-Role": "admin", "AgentEnv-Role-Key": ROLE_KEY}, {"AgentEnv-Role-Key": ROLE_KEY}): + status, _, _ = await _call(gw, path, headers=headers) + assert status == 200 + assert [_role_and_key(r) for r in up.forwarded] == [ + (["default"], []), (["default"], []), (["admin"], [ROLE_KEY]), (["default"], [])] + + +@pytest.mark.asyncio +async def test_without_a_role_key_the_role_header_alone_is_believed_and_a_client_s_key_never_goes_upstream(monkeypatch): + up = _Upstream([b"{}"]) + up.install(monkeypatch) + gw = _gateway(website_urls={"web": "http://web"}) + await _call(gw, "/website/web/x", headers={"AgentEnv-Role": "admin"}) + await _call(gw, "/svc/mcp-svc/x", headers={"AgentEnv-Role": "admin", "AgentEnv-Role-Key": "forged"}) + assert [_role_and_key(r) for r in up.forwarded] == [(["admin"], []), (["admin"], [])] + + +@pytest.mark.asyncio +async def test_with_a_role_key_step_attributes_a_role_sent_without_it_to_default(monkeypatch): + gw = _gateway(role_key=ROLE_KEY) + seen: list[str] = [] + monkeypatch.setattr(gw, "_step_list_tools", lambda role: (seen.append(role), gw._json_response({"tools": []}, 200))[1]) + try: + for headers in ({"AgentEnv-Role": "admin"}, {"AgentEnv-Role": "admin", "AgentEnv-Role-Key": ROLE_KEY}): + status, _, _ = await _call(gw, "/step", method="POST", body=b'{"action": "list_tools"}', headers=headers) + assert status == 200 + finally: + await gw._close_child_sessions() + assert seen == ["default", "admin"] + + +class _CallerRecorder: + """Stands in for FastMCP's app under the gateway's ASGI wrapper: records the caller bound for each request.""" + + def __init__(self) -> None: + self.router = type("Router", (), {"lifespan_context": None})() + self.roles: list[str] = [] + + async def __call__(self, scope, receive, send) -> None: + self.roles.append(Caller.current().role) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("role_key, expected", [ + (ROLE_KEY, ["default", "default", "admin", "default"]), + (None, ["admin", "admin", "admin", "default"]), +], ids=["keyed", "legacy"]) +async def test_the_mcp_path_binds_the_role_the_key_vouches_for(monkeypatch, role_key, expected): + gw = _gateway(role_key=role_key) + recorder = _CallerRecorder() + monkeypatch.setattr(gw._mcp, "streamable_http_app", lambda: recorder) + app = gw._asgi_app() + for headers in ([(b"agentenv-role", b"admin")], [(b"agentenv-role", b"admin"), (b"agentenv-role-key", b"guess")], + [(b"agentenv-role-key", ROLE_KEY.encode()), (b"agentenv-role", b"admin")], []): + await app({"type": "http", "method": "POST", "path": "/mcp", "headers": headers}, None, None) + assert recorder.roles == expected + + +def _forwarded(request: httpx.Request) -> tuple[list[str], list[str], list[str]]: + return tuple(request.headers.get_list(name) for name in ("x-forwarded-host", "x-forwarded-proto", "x-forwarded-prefix")) + + +@pytest.mark.asyncio +async def test_the_upstream_learns_the_host_scheme_and_route_prefix_the_client_reached_it_at(monkeypatch): + up = _Upstream([b"ok"]) + up.install(monkeypatch) + gw = _gateway(website_urls={"web": "http://web"}) + await _call(gw, "/website/web/api/x/", headers={"host": "gw.example", "x-forwarded-prefix": "/elsewhere"}) + await _call(gw, "/website/web", headers={"host": "gw.example"}) + await _call(gw, "/website/web/", headers={"host": "gw.example"}) + await _call(gw, "/svc/mcp-svc/x%2Fy", headers={"host": "gw.example"}) + await _call(gw, "/gw%20x/svc/mcp-svc/x", headers={"host": "gw.example"}, root_path="/gw x") + assert [_forwarded(r) for r in up.forwarded] == [ + (["gw.example"], ["http"], ["/website/web"]), + (["gw.example"], ["http"], ["/website/web"]), + (["gw.example"], ["http"], ["/website/web"]), + (["gw.example"], ["http"], ["/svc/mcp-svc"]), + (["gw.example"], ["http"], ["/gw%20x/svc/mcp-svc"]), + ] + + +@pytest.mark.asyncio +async def test_a_front_s_own_forwarded_host_and_scheme_win_and_reach_the_upstream_once(monkeypatch): + up = _Upstream([b"ok"]) + up.install(monkeypatch) + gw = _gateway(website_urls={"web": "http://web"}) + await _call(gw, "/website/web/x", raw_headers=[ + (b"host", b"gateway.internal"), (b"x-forwarded-host", b"env.example, proxy"), (b"x-forwarded-proto", b"https"), + (b"x-forwarded-proto", b"http"), (b"x-forwarded-for", b"10.0.0.1")]) + await _call(gw, "/website/web/x", headers={"host": "gw.example", "x-forwarded-proto": "gopher"}) + assert [_forwarded(r) for r in up.forwarded] == [ + (["env.example"], ["https"], ["/website/web"]), + (["gw.example"], ["http"], ["/website/web"]), + ] + assert up.forwarded[0].headers.get_list("x-forwarded-for") == ["10.0.0.1"] + + +@pytest.mark.asyncio +async def test_an_sdk_site_behind_the_gateway_believes_only_a_keyed_role_and_names_the_gateway_s_address(monkeypatch): + @website(domains=["notes.example"]) + class Notes(WebsiteEnvironment): + @api.get("/api/whoami") + def whoami(self, ctx: ApiContext) -> dict: + return {"role": ctx.role, "base_url": ctx.base_url} + + monkeypatch.setenv(ROLE_KEY_ENV, ROLE_KEY) + transport = httpx.ASGITransport(app=Notes().create_app()) + monkeypatch.setattr(rest_proxy_module.httpx, "AsyncClient", lambda **kw: _RealAsyncClient(transport=transport, **kw)) + gw = _gateway(website_urls={"notes": "http://notes"}, role_key=ROLE_KEY) + + async def whoami(path: str, **headers) -> tuple[int, dict, bytes]: + status, answered, chunks = await _call(gw, path, headers={"host": "gw.example", **headers}) + return status, answered, b"".join(chunks) + + _, _, spoofed = await whoami("/website/notes/api/whoami", **{"AgentEnv-Role": "admin"}) + _, _, keyed = await whoami("/website/notes/api/whoami", **{"AgentEnv-Role": "admin", "AgentEnv-Role-Key": ROLE_KEY}) + status, redirect, _ = await whoami("/website/notes/api/whoami/") + assert json.loads(spoofed) == {"role": "default", "base_url": "http://gw.example/website/notes"} + assert json.loads(keyed)["role"] == "admin" + assert (status, redirect["location"]) == (307, "http://gw.example/website/notes/api/whoami") + + +def test_the_route_an_sdk_card_names_reaches_its_nested_and_typed_paths(): + @website(domains=["files.example"]) + class Files(WebsiteEnvironment): + @api.get("/files/{rest:path}") + def files(self, rest: str) -> dict: + return {"rest": rest} + + @api.get("/api/notes/{note_id:int}") + def get_note(self, note_id: int) -> dict: + return {"id": note_id} + + card = TestClient(Files().create_app()).get("/.well-known/agent-env.json").json() + routes = rest_proxy_module.website_routes(card) + assert rest_proxy_module.route_name(routes, "GET", "/files/a/b.txt") == "files" + assert rest_proxy_module.route_name(routes, "GET", "/api/notes/7") == "get_note" + assert rest_proxy_module.route_name(routes, "GET", "/api/notes/seven") is None + + +def test_a_request_a_built_in_route_answers_is_named_by_no_card_route(): + @website(domains=["any.example"]) + class AnyApi(WebsiteEnvironment): + @api.get("/api/{rest:path}") + def any_api(self, rest: str) -> dict: + return {"rest": rest} + + @api.delete("/{rest:path}") + def any_delete(self, rest: str) -> dict: + return {"rest": rest} + + card = TestClient(AnyApi().create_app()).get("/.well-known/agent-env.json").json() + routes = rest_proxy_module.website_routes(card) + assert rest_proxy_module.route_name(routes, "GET", "/api/session") is None + assert rest_proxy_module.route_name(routes, "DELETE", "/api/session") is None + assert rest_proxy_module.route_name(routes, "GET", "/api/health") is None + assert rest_proxy_module.route_name(routes, "GET", "/api/notes") == "any_api" + assert rest_proxy_module.route_name(routes, "DELETE", "/api/health") == "any_delete" + + +def test_a_site_route_at_the_session_path_replaces_the_built_in_one(): + @website(domains=["own.example"]) + class OwnSession(WebsiteEnvironment): + @api.post("/api/session") + def sign_in(self) -> dict: + return {} + + @api.get("/api/{rest:path}") + def any_api(self, rest: str) -> dict: + return {"rest": rest} + + card = TestClient(OwnSession().create_app()).get("/.well-known/agent-env.json").json() + routes = rest_proxy_module.website_routes(card) + assert rest_proxy_module.route_name(routes, "POST", "/api/session") == "sign_in" + assert rest_proxy_module.route_name(routes, "GET", "/api/session") == "any_api" + + +@pytest.mark.parametrize("path, method, target, expected", [ + ("/api/notes/{note_id:int}", "GET", "/api/notes/7", "route"), + ("/api/notes/{note_id:int}", "GET", "/api/notes/seven", None), + ("/api/notes/{note_id}", "GET", "/api/notes/seven", "route"), + ("/api/notes/{note_id}", "GET", "/api/notes/7/extra", None), + ("/files/{rest:path}", "GET", "/files/a/b.txt", "route"), + ("/api/price/{p:float}", "GET", "/api/price/3.50", "route"), + ("/api/x/{id:uuid}", "GET", "/api/x/123e4567-e89b-12d3-a456-426614174000", "route"), + ("/api/a.b", "GET", "/api/aXb", None), + ("/api/notes", "HEAD", "/api/notes", "route"), + ("/api/notes", "POST", "/api/notes", None), +]) +def test_a_request_is_named_by_the_card_route_its_method_and_path_reach(path, method, target, expected): + card = {"capabilities": {"extensions": [{"uri": "urn:agentenv:website/v1", "params": { + "routes": [{"method": "GET", "path": path, "name": "route"}]}}]}} + assert rest_proxy_module.route_name(rest_proxy_module.website_routes(card), method, target) == expected diff --git a/tst/unit/env/gateway/triggers_website_test.py b/tst/unit/env/gateway/triggers_website_test.py index f885d541c..6571b0ed1 100644 --- a/tst/unit/env/gateway/triggers_website_test.py +++ b/tst/unit/env/gateway/triggers_website_test.py @@ -12,7 +12,7 @@ from agent_env.env.gateway import gateway as gateway_module from agent_env.env.gateway import rest_proxy as rest_proxy_module from agent_env.env.gateway.clock import Clock -from agent_env.env.gateway.constants import EXT_TRIGGERS_URI, GATEWAY_EXTENSIONS, TRIGGER_WEBSITE +from agent_env.env.gateway.constants import EXT_TRIGGERS_URI, GATEWAY_EXTENSIONS, TRIGGER_WEBSITE, TRIGGER_WEBSITE_OP from agent_env.env.gateway.gateway import GATEWAY_TRAJECTORY_FILE, Gateway from agent_env.env.gateway.triggers import TriggerEngine, TriggerError @@ -27,12 +27,16 @@ def __init__(self, responses: dict) -> None: self.events: list[dict] = [] self.rules: list[tuple[str, str, bool]] = [] self.website_urls = {"mail": "http://mail-frontend", "shop": "http://shop-frontend"} + self.route_names = {"mail": {"send_message", "list_messages"}} self._role_rules_lock = asyncio.Lock() self._server_tools: dict = {} self._tool_server_urls: dict = {} self._clock = Clock() self.TRIGGER_BARRIER_TIMEOUT_S = 30.0 + def website_route_names(self, app): + return self.route_names.get(app) + def _apply_rule(self, role, tool, value): self.rules.append((role, tool, value)) @@ -98,6 +102,10 @@ def _detected(engine) -> list[tuple[str, dict]]: "a website request carries args, not a result"), ({"type": "website", "app": "mail", "unless": {"tool": "t"}}, [], "when.unless is only valid on time triggers"), ({"type": "website", "app": "mail", "roles": []}, [], "when.roles must be a non-empty list"), + ({"type": "website", "app": "mail", "op": ""}, [], "when.op must be a non-empty string"), + ({"type": "website", "app": "mail", "op": "archive"}, [], + "when.op names no route of website 'mail' (routes: ['list_messages', 'send_message'])"), + ({"type": "website", "app": "shop", "op": "checkout"}, [], "whose card the gateway could not read"), ]) def test_registration_fails_loud(monkeypatch, when, actions, fragment): engine = _make_engine(monkeypatch) @@ -121,7 +129,7 @@ def test_registration_normalizes_the_method_and_state_echoes_the_request_matcher def test_the_card_declares_website_triggers(): triggers = next(e for e in GATEWAY_EXTENSIONS if e["uri"] == EXT_TRIGGERS_URI) - assert TRIGGER_WEBSITE in triggers["params"]["features"] + assert {TRIGGER_WEBSITE, TRIGGER_WEBSITE_OP} <= set(triggers["params"]["features"]) # ---------------------------------------------------------------- detection @@ -158,6 +166,21 @@ async def test_only_a_request_matching_app_method_path_status_and_where_provokes assert len(engine._test_gw.calls) == 1 +@pytest.mark.asyncio +async def test_a_trigger_naming_an_op_is_provoked_only_by_a_request_that_reached_that_route(monkeypatch): + engine = _make_engine(monkeypatch) + by_op = {**REPLY, "when": {"type": "website", "app": "mail", "op": "send_message", "repeat": True}} + engine.register({"triggers": [by_op]}) + assert engine._triggers["reply"]["spec"]["when"]["op"] == "send_message" + engine.on_website_request("default", "mail", "POST", "/api/messages", 201, dict(MESSAGE)) + engine.on_website_request("default", "mail", "POST", "/api/messages", 201, dict(MESSAGE), "list_messages") + engine.on_website_request("default", "mail", "POST", "/api/messages", 201, dict(MESSAGE), "send_message") + await _settle(engine) + request = {"app": "mail", "method": "POST", "path": "/api/messages", "status": 201, "op": "send_message"} + assert _detected(engine) == [("reply", {"source": "website", **request, "role": "default"})] + assert len(engine._test_gw.calls) == 1 + + @pytest.mark.asyncio async def test_a_status_predicate_can_match_a_failed_request(monkeypatch): engine = _make_engine(monkeypatch) @@ -335,12 +358,12 @@ async def test_the_proxy_hands_the_engine_the_role_path_status_and_args_of_each_ assert await _request(gw, "GET", "/website/calendar/") == 404 assert seen == [ - ("default", "mail", "GET", "/", 200, {}), - ("carol@example.com", "mail", "GET", "/inbox", 200, {"folder": "work", "tag": ["a", "b"]}), - ("default", "mail", "POST", "/api/messages", 200, {"draft": "0", "to": "dana@example.com"}), - ("default", "mail", "POST", "/compose", 200, {"subject": "Hi there", "to": "dana@example.com"}), - ("default", "mail", "POST", "/api/batch", 200, {"op": "send"}), - ("default", "mail", "PUT", "/api/drafts/a/b", 200, {}), + ("default", "mail", "GET", "/", 200, {}, None), + ("carol@example.com", "mail", "GET", "/inbox", 200, {"folder": "work", "tag": ["a", "b"]}, None), + ("default", "mail", "POST", "/api/messages", 200, {"draft": "0", "to": "dana@example.com"}, None), + ("default", "mail", "POST", "/compose", 200, {"subject": "Hi there", "to": "dana@example.com"}, None), + ("default", "mail", "POST", "/api/batch", 200, {"op": "send"}, None), + ("default", "mail", "PUT", "/api/drafts/a/b", 200, {}, None), ] assert [r.content for r in website.requests if r.url.path != "/.well-known/agent-env.json"][2:4] == [b'{"to": "dana@example.com", "draft": "0"}', b"subject=Hi+there&to=dana%40example.com"] @@ -419,3 +442,152 @@ def explode(*_): gw._trigger_engine.on_website_request = explode assert await _request(gw, "GET", "/website/mail/inbox") == 200 + + +@pytest.mark.asyncio +async def test_with_a_role_key_the_engine_sees_a_role_only_beside_it_so_a_page_cannot_pose_as_the_executor(monkeypatch): + _Website(status=200).install(monkeypatch) + gw = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], + website_urls={"mail": "http://mail-frontend"}, role_key="deploy-secret") + gw._trigger_engine.register({"triggers": [REPLY]}) + seen: list[str] = [] + gw._trigger_engine.on_website_request = lambda role, *a: seen.append(role) or ([], None) + + await _request(gw, "GET", "/website/mail/inbox", headers={"AgentEnv-Role": "executor"}) + await _request(gw, "GET", "/website/mail/inbox", headers={"AgentEnv-Role": "executor", "AgentEnv-Role-Key": "deploy-secret"}) + + assert seen == ["default", "executor"] + + +NOTES_CARD = {"name": "notes", "capabilities": {"extensions": [{"uri": "urn:agentenv:website/v1", "params": {"routes": [ + {"method": "GET", "path": "/api/notes", "name": "list_notes", "roles": None}, + {"method": "POST", "path": "/api/notes", "name": "create_note", "roles": None}, + {"method": "PATCH", "path": "/api/notes/{note_id}", "name": "edit_note", "roles": None}, +]}}]}} + + +class _NotesSite(_Website): + """A website whose server serves `NOTES_CARD` at the card's path, counting how often the gateway reads it.""" + + def install(self, monkeypatch) -> None: + self.card_reads = 0 + + async def body(): + yield b'{"ok": true}' + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/.well-known/agent-env.json": + self.card_reads += 1 + return httpx.Response(200, json=NOTES_CARD) + self.requests.append(request) + return httpx.Response(self.status, headers={"content-type": "application/json"}, content=body()) + + transport = httpx.MockTransport(handler) + monkeypatch.setattr(rest_proxy_module.httpx, "AsyncClient", lambda **kw: _RealAsyncClient(transport=transport, **kw)) + + +def _notes_gateway() -> Gateway: + return Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], + website_urls={"notes": "http://notes-website-backend:8000"}, + rest_proxy_urls={"notes": "http://notes-website-backend:8000"}) + + +async def _register(gw: Gateway, body: dict) -> tuple[int, dict]: + sent = json.dumps(body).encode() + answered: list[bytes] = [] + statuses: list[int] = [] + + async def receive(): + return {"type": "http.request", "body": sent, "more_body": False} + + async def send(message): + if message["type"] == "http.response.start": + statuses.append(message["status"]) + elif message["type"] == "http.response.body": + answered.append(message.get("body", b"")) + + scope = {"type": "http", "asgi": {"version": "3.0"}, "http_version": "1.1", "method": "POST", "scheme": "http", + "path": "/triggers/register", "raw_path": b"/triggers/register", "query_string": b"", "root_path": "", + "server": ("gateway", 80), "client": ("c", 1), "headers": [(b"content-type", b"application/json")]} + await gw._mcp.streamable_http_app()(scope, receive, send) + return statuses[0], json.loads(b"".join(answered)) + + +EDITED = {"id": "edited", "when": {"type": "website", "app": "notes", "op": "edit_note", "repeat": True}, "actions": []} + + +@pytest.mark.asyncio +async def test_registering_an_op_trigger_reads_the_site_s_routes_and_refuses_a_route_it_does_not_name(monkeypatch): + site = _NotesSite(status=200) + site.install(monkeypatch) + gw = _notes_gateway() + + status, answer = await _register(gw, {"triggers": [{**EDITED, "id": "typo", "when": {**EDITED["when"], "op": "edit"}}]}) + assert status == 400 and "routes: ['create_note', 'edit_note', 'list_notes']" in answer["error"] + status, _ = await _register(gw, {"triggers": [EDITED]}) + assert status == 200 and site.card_reads == 1 + + +@pytest.mark.asyncio +async def test_registering_an_op_trigger_whose_app_is_not_a_name_is_refused_as_bad_config(monkeypatch): + site = _NotesSite(status=200) + site.install(monkeypatch) + gw = _notes_gateway() + + status, answer = await _register(gw, {"triggers": [{**EDITED, "when": {**EDITED["when"], "app": []}}]}) + assert status == 400 and answer["ok"] is False and site.card_reads == 0 + + +@pytest.mark.asyncio +async def test_the_proxy_names_the_route_a_request_reached_to_the_engine_and_the_trajectory(monkeypatch): + site = _NotesSite(status=200) + site.install(monkeypatch) + gw = _notes_gateway() + assert (await _register(gw, {"triggers": [EDITED]}))[0] == 200 + changelog = iter(range(100)) + monkeypatch.setattr(gw, "_query_changelog_id", lambda: next(changelog)) + before = len(_trajectory()) + + await _request(gw, "PATCH", "/website/notes/api/notes/7", headers={"content-type": "application/json"}, body=b"{}") + await _request(gw, "PATCH", "/website/notes/api/notes/7/extra", headers={"content-type": "application/json"}, body=b"{}") + await _request(gw, "POST", "/website/notes/api/notes", headers={"content-type": "application/json"}, body=b"{}") + await asyncio.sleep(0.05) + + detected = [p for _, p in _detected(gw._trigger_engine)] + assert [(p["path"], p.get("op")) for p in detected] == [("/api/notes/7", "edit_note")] + writes = [e for e in _trajectory()[before:] if e["event_type"] == "http_request"] + assert [(w["method"], w["path"], w.get("op")) for w in writes] == [ + ("PATCH", "/api/notes/7", "edit_note"), ("PATCH", "/api/notes/7/extra", None), ("POST", "/api/notes", "create_note")] + + +@pytest.mark.asyncio +async def test_a_request_is_named_by_the_path_the_site_s_server_receives(monkeypatch): + """An encoded slash beside a dot segment routes here as one path and reaches the site's server as another; the + route, the trigger's match and the write's trajectory event all go by the one the server receives.""" + site = _NotesSite(status=200) + site.install(monkeypatch) + gw = _notes_gateway() + assert (await _register(gw, {"triggers": [EDITED]}))[0] == 200 + changelog = iter(range(100)) + monkeypatch.setattr(gw, "_query_changelog_id", lambda: next(changelog)) + before = len(_trajectory()) + + await _request(gw, "PATCH", "/website/notes/public%2Fx/../api/notes/7", headers={"content-type": "application/json"}, + body=b"{}") + await asyncio.sleep(0.05) + + assert [r.url.path for r in site.requests] == ["/api/notes/7"] + assert [(p["path"], p.get("op")) for _, p in _detected(gw._trigger_engine)] == [("/api/notes/7", "edit_note")] + writes = [e for e in _trajectory()[before:] if e["event_type"] == "http_request"] + assert [w.get("op") for w in writes] == ["edit_note"] + + +@pytest.mark.asyncio +async def test_composing_the_env_card_reads_each_site_s_routes(monkeypatch): + site = _NotesSite(status=200) + site.install(monkeypatch) + gw = _notes_gateway() + assert gw.website_route_names("notes") is None + await gw._serve_env_card() + assert gw.website_route_names("notes") == {"list_notes", "create_note", "edit_note"} and site.card_reads == 1 + assert gw._website_op("notes", "GET", "/api/notes") == "list_notes" diff --git a/tst/unit/env/test_env.py b/tst/unit/env/test_env.py index 02947560b..1498888bf 100644 --- a/tst/unit/env/test_env.py +++ b/tst/unit/env/test_env.py @@ -284,7 +284,7 @@ def test_a_gateway_record_is_filled_the_way_it_always_was(): sandbox_type="modal_vm", sandbox_ids={"gateway_server": "gw"}, db_web_url="https://pg/", db_mcp_url="https://dm/mcp", website_frontend_urls={"shop": "https://gw/shop/"}, vnc_url="https://vnc", env_state_instance_ids=["st-1"], environment_card_url=_CARD_URL, environment_card=_CARD, environment_card_read_at_utc="2026-09-28T00:00:00+00:00", - metadata={"k": "v"}, instance_id="i1", created_at_utc="c", expires_at_utc="x"), + metadata={"k": "v"}, instance_id="i1", created_at_utc="c", expires_at_utc="x", role_key="deploy-secret"), ], ids=["base", "sandbox", "gateway"]) def test_a_record_round_trips_through_its_stored_form(record): assert DeployedEnv.from_dict(dataclasses.asdict(record)) == record @@ -295,6 +295,14 @@ def test_each_record_class_loads_every_one_of_its_fields(cls): assert set(cls._fields_from({"env_id": "e", "env_version": 1})) == {f.name for f in dataclasses.fields(cls)} +def test_a_record_s_role_headers_carry_its_role_key_beside_the_role_and_an_older_record_s_only_the_role(): + keyed = DeployedEnv.from_dict({"env_id": "e", "env_version": 1, "gateway_url": "https://gw", "sandbox_id": "sb", + "role_key": "deploy-secret"}) + older = DeployedEnv.from_dict({"env_id": "e", "env_version": 1, "gateway_url": "https://gw", "sandbox_id": "sb"}) + assert keyed.role_headers("admin") == {"AgentEnv-Role": "admin", "AgentEnv-Role-Key": "deploy-secret"} + assert (older.role_key, older.role_headers("admin")) == (None, {"AgentEnv-Role": "admin"}) + + def test_the_gateway_only_fields_are_the_gateway_classs_own(): own = {f.name for f in dataclasses.fields(DeployedGatewayEnv)} - {f.name for f in dataclasses.fields(DeployedSandboxEnv)} assert set(_GATEWAY_ONLY_FIELDS) == own - {"gateway_mode", "env_provider_type"} 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 4345c8fde..7902db84a 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -375,6 +375,7 @@ async def _spy_acquire(self, ctx): ttl_seconds=60, disk_size_gb=10, mcp_server_name="crm", + role_key="deploy-secret", ) # Last create_container call is the gateway. Inspect its env var. @@ -395,6 +396,7 @@ async def _spy_acquire(self, ctx): assert re.search(r"^postgresql://agentenv:agentenv@\[fdaa::fake:[0-9a-f]+\]:5432/agentenv", env["SERVICE_DB_URL"]), env["SERVICE_DB_URL"] assert env["MCP_SERVER_NAME"] == "crm" assert result.mcp_server_name == "crm" + assert env["AGENTENV_ROLE_KEY"] == result.role_key == "deploy-secret" # Each MCP got DATABASE_URL with its scoped search_path mcp_calls = [c for c in create_calls if c["image_name"].startswith("mcp-")] @@ -1191,6 +1193,30 @@ def test_redact_compose_secrets_masks_only_secret_env_lines(): assert "s3cr3t-token-value" in compose +@pytest.mark.parametrize("role_key", ["deploy-secret", None], ids=["keyed", "unkeyed"]) +def test_compose_gives_the_role_key_to_the_gateway_and_every_website_server_and_masks_it_in_the_log(role_key): + """The gateway believes a role only beside the deploy's key, and a site holds the same key to believe the role the + gateway forwards; without a key neither is set, as before it.""" + from agent_env.env.envs.service_db import ServiceDBConfig + + compose = EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=[MCPServerConfig(image="mcp-slack", environment_name="slack")], + gateway_image="agent-gateway", + website_configs=[WebsiteConfig(backend_image="notes-be", frontend_image=None, environment_name="notes")], + state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance(), + role_key=role_key, + ) + + services = yaml.safe_load(compose)["services"] + keyed = {name: [entry for entry in service.get("environment") or [] if "ROLE_KEY" in entry] + for name, service in services.items()} + expected = {GATEWAY_SERVICE_NAME: [f"AGENTENV_ROLE_KEY={role_key}"], "notes-website-backend": [f"WEBSITE_ROLE_KEY={role_key}"]} + assert {name: entries for name, entries in keyed.items() if entries} == (expected if role_key else {}) + if role_key: + assert role_key not in _redact_compose_secrets(compose) and " - AGENTENV_ROLE_KEY=***" in _redact_compose_secrets(compose) + + @pytest.mark.asyncio async def test_acquire_state_for_deploy_local_returns_none(): """Local Postgres cannot be pre-provisioned (its container's host doesn't exist yet), so the @@ -1953,12 +1979,28 @@ async def test_create_gateway_hands_the_deploy_path_every_argument(attribution, [call] = vm.await_args_list assert call.args == (provider, args.pop("mcp_servers"), args.pop("mcp_server_images")) - assert call.kwargs == {**args, "attribution": forwarded} + assert call.kwargs == {**args, "attribution": forwarded, "role_key": None} assert call.kwargs["attribution"] is not attribution @pytest.mark.asyncio -async def test_deploy_returns_the_gateway_record_for_the_env(caplog): +@pytest.mark.parametrize("keyed_roles", [True, False], ids=["keyed", "unkeyed"]) +async def test_create_gateway_draws_a_role_key_only_for_a_deploy_with_keyed_roles(keyed_roles): + """A keyed deploy draws a fresh secret for its gateway; any other deploy draws none, so its callers' role header + alone is believed, as before the key.""" + gp, provider = EnvironmentGatewayProvider(), _vm_provider() + with patch.object(gp, "_deploy_via_vm", AsyncMock(return_value=_probed())) as vm, \ + patch.object(env_gateway_provider, "_tool_names", AsyncMock(return_value=set())): + await gp.create_gateway(sandbox_provider=provider, mcp_servers=[MagicMock()], mcp_server_images=[MagicMock()], + env_id="crm-env", keyed_roles=keyed_roles) + + role_key = vm.await_args.kwargs["role_key"] + assert (isinstance(role_key, str) and len(role_key) >= 32) if keyed_roles else role_key is None, role_key + + +@pytest.mark.asyncio +@pytest.mark.parametrize("keyed_roles", [False, True], ids=["unkeyed", "keyed"]) +async def test_deploy_returns_the_gateway_record_for_the_env(caplog, keyed_roles): """deploy(env) on a scripted VM: the env's image behind the gateway, its state acquired, and main's record plus its provider type.""" from agent_env.env.gateway import GatewayMode from agent_env.providers.env_state import DatabaseStateProvider @@ -1984,7 +2026,7 @@ async def test_deploy_returns_the_gateway_record_for_the_env(caplog): patch.object(env_gateway_provider, "_tool_names", AsyncMock(return_value={"slack_send"})), \ patch("agent_env.providers.env_providers.env_gateway_provider.asyncio.sleep", AsyncMock()): record = await gp.deploy(env, _vm_provider(create_vm=AsyncMock(return_value=vm)), gateway_mode=GatewayMode.CONSISTENT, - ttl_seconds=60, env_state_type="remote") + ttl_seconds=60, env_state_type="remote", keyed_roles=keyed_roles) clashes_checked.assert_awaited_once_with("https://vm.example") @@ -1997,7 +2039,9 @@ async def test_deploy_returns_the_gateway_record_for_the_env(caplog): env_id="crm-env", env_version=3, gateway_url="https://vm.example", mcp_url="https://vm.example/mcp", db_web_url=None, sandbox_id="vm-1", sandbox_type="modal_vm", sandbox_ids={}, db_mcp_url=None, environment_card_url="https://vm.example/.well-known/agent-env.json", environment_card=card, - environment_card_read_at_utc=record.environment_card_read_at_utc, gateway_mode="consistent", env_state_instance_ids=["st-ext"])) + environment_card_read_at_utc=record.environment_card_read_at_utc, gateway_mode="consistent", env_state_instance_ids=["st-ext"], + role_key=compose.call_args.kwargs["role_key"])) + assert (record.role_key and len(record.role_key) >= 32) if keyed_roles else record.role_key is None assert record.environment_card_read_at_utc and record.instance_id is None # unregistered: the env registers it assert (record.mcp_url, record.mcp_server_name, record.env_provider_type) == ("https://vm.example/mcp", "env1234", "gateway") messages = [r.getMessage() for r in caplog.records if r.getMessage().startswith(("env_deploy_provider_attempt ", "env_deploy_tools_probe "))] diff --git a/tst/unit/task_step/deploy_env_test.py b/tst/unit/task_step/deploy_env_test.py index 874832e4a..0fe426a6e 100644 --- a/tst/unit/task_step/deploy_env_test.py +++ b/tst/unit/task_step/deploy_env_test.py @@ -242,6 +242,36 @@ async def test_execute_merges_annotations_into_metadata_deploy_set(): +@pytest.mark.parametrize("keyed_roles", [True, False], ids=["keyed", "unkeyed"]) +def test_keyed_roles_roundtrips_and_an_older_step_is_unkeyed(keyed_roles): + step = DeployEnvTaskStep(id="t-1.deploy_env", version=None, env_id="art-1", keyed_roles=keyed_roles) + assert DeployEnvTaskStep.from_dict(step.to_dict()).keyed_roles is keyed_roles + older = {k: v for k, v in step.to_dict().items() if k != "keyed_roles"} + assert DeployEnvTaskStep.from_dict(older).keyed_roles is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("keyed_roles", [True, False], ids=["keyed", "unkeyed"]) +async def test_execute_asks_for_a_role_key_only_for_a_keyed_step(keyed_roles): + """An unkeyed step passes no keyed_roles, so an env type or plugin provider that doesn't name it isn't broken.""" + env = _fake_env(_deployed_env(None)) + step = DeployEnvTaskStep(id="t-1.deploy_env", version=None, env_id="art-1", keyed_roles=keyed_roles) + with patch("agent_env.env.env.Env") as Env: + Env.get.return_value = env + await step.execute(TaskStepContext()) + assert env.deploy.await_args.kwargs.get("keyed_roles") is (True if keyed_roles else None) + + +@pytest.mark.asyncio +async def test_execute_refuses_keyed_roles_for_an_env_whose_deploy_does_not_take_them(): + env = _env_with_fixed_deploy(_deployed_env(None)) + env.type = "fixed" + step = DeployEnvTaskStep(id="d", version=None, env_id="art-1", keyed_roles=True) + with patch("agent_env.env.env.Env") as Env, pytest.raises(ValueError, match="fixed envs take no keyed_roles"): + Env.get.return_value = env + await step.execute(TaskStepContext()) + + # --- preflight: an option the step's env would refuse at deploy is a save-time problem --- @@ -276,6 +306,12 @@ def test_preflight_refuses_env_params_an_env_doesnt_take(kind, refusal): assert _preflight(env, env_params={"form": "container"}) == [f"deploy_env 'd': {refusal}"] +def test_preflight_passes_keyed_roles_for_a_gateway_env_and_refuses_them_for_a_server_env(): + assert _preflight(_mcp_env("gateway"), keyed_roles=True) == [] + assert _preflight(_mcp_env("server"), keyed_roles=True) == [ + "deploy_env 'd': env 'mcp-email' has env_provider_type 'server', which doesn't take keyed_roles"] + + @pytest.mark.parametrize("kind", ["server", "gateway", "other"]) @pytest.mark.parametrize("mode", ["bogus", "Performance", None]) def test_preflight_reports_an_invalid_gateway_mode_for_every_env(kind, mode): diff --git a/tst/unit/task_step/local_container_urls_test.py b/tst/unit/task_step/local_container_urls_test.py index 8320dfcd3..7af4f11e1 100644 --- a/tst/unit/task_step/local_container_urls_test.py +++ b/tst/unit/task_step/local_container_urls_test.py @@ -346,4 +346,34 @@ async def install_cli(deployed, cli_artifact, gateway_url, **placed): await LoadArtifactTaskStep(id="cli", version=None, env_id="env-x", artifact_id=cli.id, artifact_version=cli.version, agent_name="solver").execute(context) - assert installed == [(expected, {"on_host": True} if on_host else {})] + assert installed == [(expected, {"role_key": None, **({"on_host": True} if on_host else {})})] + + +@pytest.mark.asyncio +async def test_each_cli_is_installed_with_the_role_key_of_the_deploy_it_talks_to(monkeypatch, local_stores, tmp_path): + (tmp_path / "cli").mkdir() + (tmp_path / "cli" / "run.sh").write_text("#!/bin/sh\n") + clis = [CliArtifact.put(f"{name}-cli", command_name=name, entrypoint="run.sh", cli_dir=tmp_path / "cli") + for name in ("slack", "crm")] + installed = [] + + async def install_cli(deployed, cli_artifact, gateway_url, **placed): + installed.append((cli_artifact.command_name, gateway_url, placed["role_key"])) + return f"/opt/cli/{cli_artifact.command_name}/run.sh" + + monkeypatch.setattr(A2AAgent, "install_cli", staticmethod(install_cli)) + monkeypatch.setattr(agent_store, "get_a2a_agent_instance_store", + lambda: types.SimpleNamespace(get=lambda instance_id: object())) + context = TaskStepContext( + deployed_envs=[DeployedGatewayEnv(env_id=f"env-{name}", env_version=1, gateway_url=f"https://{name}.gw", + mcp_url="", sandbox_id=f"sb-{name}", sandbox_type="modal_vm", + role_key=f"key-{name}") for name in ("slack", "crm")], + deployed_agents=[DeployedAgent(agent_name="solver", api_url="http://agent", sandbox_id="sb-agent", + sandbox_type="modal_vm", instance_id="inst-1")], + ) + for cli in clis: + await LoadArtifactTaskStep(id=f"load-{cli.command_name}", version=None, env_id=f"env-{cli.command_name}", + artifact_id=cli.id, artifact_version=cli.version, agent_name="solver" + ).execute(context) + + assert installed == [("slack", "https://slack.gw", "key-slack"), ("crm", "https://crm.gw", "key-crm")] diff --git a/tst/unit/task_step/test_deploy_agent_role_delivery.py b/tst/unit/task_step/test_deploy_agent_role_delivery.py index e642fd0de..47bf80c46 100644 --- a/tst/unit/task_step/test_deploy_agent_role_delivery.py +++ b/tst/unit/task_step/test_deploy_agent_role_delivery.py @@ -14,7 +14,7 @@ from agent_env.a2a_agent.a2a_agent import A2AAgent from agent_env.env.env import DeployedGatewayEnv -from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER +from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER, AGENT_ENV_ROLE_KEY_HEADER from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_steps import deploy_agent from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep @@ -227,6 +227,30 @@ def test_a_selection_takes_exactly_one_of_disable_and_only(): assert DeployAgentTaskStep.from_dict({**step.to_dict(), "version": 1}).builtin_tools == {"disable": ["Bash"]} +@pytest.mark.asyncio +async def test_a_keyed_deploy_s_registration_carries_its_role_key_and_the_agent_s_environment_does_not(wiring): + wiring.context.deployed_envs[0].role_key = "deploy-secret" + + await wiring.deploy(_card(HEADERS_MCP), role="executor", env_ids=["crm", "ext"]) + + assert wiring.bodies("/ext/mcp-config") == [ + {"url": _GATEWAY_MCP_URL, "headers": {AGENT_ENV_ROLE_HEADER: "executor", AGENT_ENV_ROLE_KEY_HEADER: "deploy-secret"}, + "name": "crm"}, + {"url": _LIVE_MCP_URL, "headers": {AGENT_ENV_ROLE_HEADER: "executor"}}, + ] + assert wiring.env_vars == {ROLE_ENV_VAR: "executor"} + + +@pytest.mark.asyncio +async def test_a_keyed_deploy_gives_its_key_to_no_agent_without_a_role(wiring): + wiring.context.deployed_envs[0].role_key = "deploy-secret" + + await wiring.deploy(_card(HEADERS_MCP), role=None) + + assert wiring.bodies("/ext/mcp-config") == [{"url": _GATEWAY_MCP_URL, "name": "crm"}] + assert wiring.env_vars is None + + class _Wiring: """One deploy_agent run: the requests the agent received, the agent double, and the step's context.""" diff --git a/tst/unit/task_step/test_load_artifact_file.py b/tst/unit/task_step/test_load_artifact_file.py index cde1c353e..c736efa94 100644 --- a/tst/unit/task_step/test_load_artifact_file.py +++ b/tst/unit/task_step/test_load_artifact_file.py @@ -3,6 +3,7 @@ from __future__ import annotations +import base64 import re from types import SimpleNamespace @@ -275,3 +276,18 @@ async def test_a_cli_installs_on_the_vm_host_of_an_agent_installed_there(monkeyp "pull s3://bucket/check.py /opt/cli/tool/bin/tool", "chmod +x /opt/cli/tool/bin/tool"] assert [s.split("\n")[0] for s in sandbox.scripts if s.startswith("base64 -d")] == [ "base64 -d <<'ENDB64' > /opt/cli/tool/bin/.env"] + + +@pytest.mark.asyncio +async def test_each_installed_cli_carries_its_own_deploy_s_role_key_beside_its_gateway(monkeypatch): + async def _sidecar(role_key: str | None) -> str: + sandbox = _Vm() + monkeypatch.setattr(sandbox_provider, "get_agent_sandbox_provider", _provider(sandbox)) + await A2AAgent.install_cli(SimpleNamespace(sandbox_type=None, sandbox_id="sb-agent"), TOOL, "https://gateway", + on_host=True, role_key=role_key) + (written,) = [s for s in sandbox.scripts if s.startswith("base64 -d <<'ENDB64' > /opt/cli/tool/bin/.env")] + return base64.b64decode("".join(written.split("\n")[1:-1])).decode() + + assert await _sidecar("key-a") == "AGENT_ENV_GATEWAY_URL=https://gateway\nAGENT_ENV_ROLE_KEY=key-a\n" + assert await _sidecar("key-b") == "AGENT_ENV_GATEWAY_URL=https://gateway\nAGENT_ENV_ROLE_KEY=key-b\n" + assert await _sidecar(None) == "AGENT_ENV_GATEWAY_URL=https://gateway\n" diff --git a/tst/unit/task_step/test_mcp_cli_builder.py b/tst/unit/task_step/test_mcp_cli_builder.py index af7d6eb74..a2513e5b4 100644 --- a/tst/unit/task_step/test_mcp_cli_builder.py +++ b/tst/unit/task_step/test_mcp_cli_builder.py @@ -52,6 +52,16 @@ def test_codegen_compiles_and_renders_help(): assert "Auto-generated CLI for slack" in group_help.stdout +def test_the_generated_cli_sends_the_role_key_beside_its_role_only_when_one_is_set(monkeypatch, tmp_path): + namespace: dict = {"__name__": "generated_cli", "__file__": str(tmp_path / "svc")} + exec(compile(generate_cli_script("svc"), "svc", "exec"), namespace) + monkeypatch.setenv("AGENT_ENV_ROLE", "admin") + monkeypatch.delenv("AGENT_ENV_ROLE_KEY", raising=False) + assert namespace["_role_headers"]() == {"AgentEnv-Role": "admin"} + monkeypatch.setenv("AGENT_ENV_ROLE_KEY", "deploy-secret") + assert namespace["_role_headers"]() == {"AgentEnv-Role": "admin", "AgentEnv-Role-Key": "deploy-secret"} + + def test_codegen_loads_sibling_env_file(): src = generate_cli_script("svc") diff --git a/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py b/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py index f54d8a200..f485117b9 100644 --- a/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py +++ b/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py @@ -47,6 +47,7 @@ def __init__(self, monkeypatch, replies, *, clock: dict | None = None, engine: T self.clock = clock or {"armed": False} self.engine = engine self.steps: list[tuple[str, dict]] = [] + self.step_keys: list[str | None] = [] def _answer(self, request: httpx.Request) -> httpx.Response: origin = f"{request.url.scheme}://{request.url.host}" @@ -62,6 +63,7 @@ def _answer(self, request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"to": body.get("to", "default"), "items": [], "until": None}) if origin == ENV and request.url.path == "/step": self.steps.append((request.headers.get("AgentEnv-Role"), json.loads(request.content))) + self.step_keys.append(request.headers.get("AgentEnv-Role-Key")) return httpx.Response(200, json={"content": [{"type": "text", "text": "ok"}], "changelog_id": 7}) return super()._answer(request) @@ -475,6 +477,26 @@ async def post_turn(turn, params): "arguments": {"to": "a@example.com"}, "source": "post_turn_hook", "hook_artifact_id": "mailer"})] assert _recorded(ctx)[0]["result"]["changelog_id"] == 7 + assert peers.step_keys == [None] + + +@pytest.mark.asyncio +async def test_a_hook_s_tool_call_carries_the_deploy_s_role_key_beside_its_role(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one")]}) + ctx = _context() + env = _env(EXT_STEP_URI) + env.role_key = "deploy-secret" + ctx.deployed_envs.append(env) + hook = _hook("keyed", """ + async def post_turn(turn, params): + [env] = turn.envs + await env.call_tool("send_email", {}, role="school") + return None + """) + + await _step(hook, turns=1).execute(ctx) + + assert [role for role, _ in peers.steps] == ["school"] and peers.step_keys == ["deploy-secret"] @pytest.mark.asyncio diff --git a/tst/unit/task_step/test_register_env_triggers.py b/tst/unit/task_step/test_register_env_triggers.py index 1abba6959..78f370aee 100644 --- a/tst/unit/task_step/test_register_env_triggers.py +++ b/tst/unit/task_step/test_register_env_triggers.py @@ -273,11 +273,13 @@ async def test_executor_hours_for_an_agent_no_trigger_uses_raise(monkeypatch): ([{"id": "x", "when": {"type": "action", "tool": "t"}, "actions": [{"type": "nl", "instruction": "x"}]}], {"exec-1": _HOURS}, "executor_hours"), ([{"id": "x", "when": {"type": "website", "app": "mail", "method": "POST"}, "actions": []}], None, "website"), -], ids=["delay", "unless", "hours", "website"]) + ([{"id": "x", "when": {"type": "website", "app": "mail", "op": "send"}, "actions": []}], None, "website_op"), +], ids=["delay", "unless", "hours", "website", "website-op"]) async def test_a_gateway_without_a_feature_refuses_the_triggers_that_use_it(monkeypatch, triggers, hours, missing): sent = _mock_gateway(monkeypatch, {}) context = _context(with_executor=True) - context.deployed_envs = [_record(_with_features("named_executors", "provoking_roles"))] + features = ("named_executors", "provoking_roles", "website") if missing == "website_op" else ("named_executors", "provoking_roles") + context.deployed_envs = [_record(_with_features(*features))] with pytest.raises(RuntimeError, match=rf"does not declare \['{missing}'\]"): await _step(triggers=triggers, executor_agent_name="exec-1", executor_hours=hours).execute(context) assert sent == [] From f5454b6089f7cf45818028bf765727078f366f90 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 13:34:07 -0800 Subject: [PATCH 14/57] feat(website): a session cookie per site, /go?sid=, the clock's rate, conformance levels and models= (FD-4683) This PR's change, rebased as one commit onto #443. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/README.md | 14 +- packages/agentenv-protocol/pyproject.toml | 7 + .../src/agentenv_protocol/website/clock.py | 26 +- .../agentenv_protocol/website/conformance.py | 128 +++++++--- .../src/agentenv_protocol/website/database.py | 228 ++++++++++++++++++ .../agentenv_protocol/website/environment.py | 173 +++++++++++-- .../src/agentenv_protocol/website/routes.py | 4 +- .../src/agentenv_protocol/website/session.py | 42 +++- .../src/agentenv_protocol/website/store.py | 93 ++++++- .../tests/test_website_conformance.py | 134 ++++++++-- .../tests/test_website_database.py | 213 ++++++++++++++++ .../tests/test_website_environment.py | 150 +++++++++++- .../tests/test_website_notes_example.py | 16 +- .../tests/test_website_state.py | 132 ++++++++++ src/agent_env/env/gateway/clock.py | 12 +- tst/unit/env/gateway/clock_advance_test.py | 19 ++ uv.lock | 92 ++++++- 17 files changed, 1359 insertions(+), 124 deletions(-) create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/database.py create mode 100644 packages/agentenv-protocol/tests/test_website_database.py create mode 100644 packages/agentenv-protocol/tests/test_website_state.py diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index eade87bde..fc5cba314 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -24,12 +24,14 @@ them. The page shows the universe's time. on only beside a role it verified with the same key, so a page cannot claim `admin` through it. Any other deploy sets no key, and its gateway passes on whatever role a browser sends; with no `WEBSITE_ROLE_KEY` set, every caller is `default` and the route is closed. -- **Users.** `ApiContext.user` is the user in the signed session cookie. With no cookie, it is the acting user (the +- **Users.** `ApiContext.user` is the user in the signed session cookie, `agentenv_session_notes` (the site's own, so + it doesn't collide with another site's under the same gateway host; the card names it). With no cookie, it is the acting user (the store's `current_user`), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded user. A user's notes are invisible to every other user, and another user's note answers 404. - **State.** The site writes no data hooks, so it uses the default store. `data/add` loads the seed (as a data part, or - as a file part by bytes or `file://`), `data/get` exports it, and `data/reset` empties it. `data/get` is how - agentenv reads what a task left the site with. + as a file part by bytes or `file://`), `data/get` exports it, `data/reset` empties it, and `/go` returns + `{initial_state, current_state, state_diff}`. A page opened at `?sid=` keeps a state of its own, started + from the seed, which `/go?sid=` reports; without `sid` it is the default session, the data plane's. - **Time.** Notes are stamped with `self.now()`, which follows the gateway's clock once `clock/v1`'s `sync_time` names it. The frontend reads `X-Universe-Now` from every API response into `universeNow()` and shows it in the header. It uses this helper rather than `clock_patch=True`, so the page works the same against a server written without the @@ -66,11 +68,13 @@ The build context is the repo root, because the image installs the protocol pack ```bash docker build -f packages/agentenv-protocol/examples/website/notes/Dockerfile -t notes-site . docker run -d --name notes-site -p 18080:8000 notes-site -python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal +python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal --strict curl -s http://localhost:18080/api/notes ``` -Use `--clock-host` so the container can reach the checker's clock. The clock check leaves the server following a +Use `--clock-host` so the container can reach the checker's clock. `--strict` fails on a recommended check's warning +too (the card, `X-Universe-Now`, the clock, forwarded headers, cookies); without it only the contract's required checks +fail the run, and Notes passes every check either way. The clock check leaves the server following a clock that is gone once the check ends: later timestamps start from 2031 until the server is synced again, so restart the container before using it. diff --git a/packages/agentenv-protocol/pyproject.toml b/packages/agentenv-protocol/pyproject.toml index 73559b1c2..17492f651 100644 --- a/packages/agentenv-protocol/pyproject.toml +++ b/packages/agentenv-protocol/pyproject.toml @@ -49,6 +49,12 @@ computer = [ "Pillow>=10.4", "uvicorn>=0.30", ] +# A website whose state is an MCP server's database: @website(models=) and self.db, on a +# postgresql:// DATABASE_URL through psycopg 3, SQLAlchemy 2.1's driver for it. +db = [ + "sqlalchemy>=2.1", + "psycopg[binary]>=3.1", +] dev = [ "a2a-sdk[http-server]>=0.3.26,<0.4", "python-multipart>=0.0.13", @@ -59,6 +65,7 @@ dev = [ "fastmcp>=3.4,<4", # the standalone-fastmcp mount path "Pillow>=10.4", # the computer extra's frame crops and encodings "av>=17", # the computer extra's encoder, and the tests' decoder + "sqlalchemy>=2.1", # the db extra's models= site, tested on sqlite ] [project.urls] Homepage = "https://www.agentenvframework.com" diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/clock.py b/packages/agentenv-protocol/src/agentenv_protocol/website/clock.py index 0d27d86a9..dec301780 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/clock.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/clock.py @@ -1,8 +1,9 @@ """The universe's clock as a site reads it: ``clock/v1``'s ``sync_time`` hands the server the gateway's ``/clock/time`` URL, as it hands it to MCP servers, and the site's time then follows that clock's ``{"virtual_time": }``, -read at most once a second and extrapolated between reads. The rate it extrapolates at is one two successive intervals -between reads agreed on, so a clock set or advanced between two reads is taken for a jump, not a speed; until two agree -it is real time's. An unsynced site reads wall time. Every answer of a site carries the time it answered at as ``X-Universe-Now``, +read at most once a second and extrapolated between reads. The rate it extrapolates at is the one the clock answers +with, ``virtual_seconds_per_real_second``, as the gateway's does; for a clock that answers none, it is one two +successive intervals between reads agreed on, so a clock set or advanced between two reads is taken for a jump, not a +speed, and until two agree it is real time's. An unsynced site reads wall time. Every answer of a site carries the time it answered at as ``X-Universe-Now``, RFC3339 in UTC. ``sync_time`` may also name the universe's time zone, an IANA name, which the clock keeps for the site to show local times in; the time itself stays UTC.""" from __future__ import annotations @@ -11,6 +12,7 @@ import datetime import json import logging +import math import time import httpx @@ -60,8 +62,8 @@ async def sync(self, url: str, timezone: str | None = None) -> datetime.datetime can't be read, keeping the clock and zone it followed. It waits for a refresh reading the clock it followed, so that read never lands on the new clock.""" async with self._reading(): - virtual = await _read(url) - self.url, self._virtual, self._rate, self._measured = url, virtual, 1.0, None + virtual, rate = await _read(url) + self.url, self._virtual, self._rate, self._measured = url, virtual, 1.0 if rate is None else rate, None self.timezone = timezone self._read_at = self._tried_at = time.monotonic() return virtual @@ -76,12 +78,14 @@ async def refresh(self) -> None: return self._tried_at = started try: - virtual = await _read(self.url) + virtual, rate = await _read(self.url) except Exception as e: logger.debug("reading the universe's clock at %s failed: %r", self.url, e) return read_at = time.monotonic() - if self._virtual is not None and read_at > self._read_at: + if rate is not None: + self._rate = rate + elif self._virtual is not None and read_at > self._read_at: measured = max(0.0, (virtual - self._virtual).total_seconds() / (read_at - self._read_at)) if self._measured is not None and abs(measured - self._measured) <= RATE_AGREEMENT * max(measured, self._measured, 1.0): self._rate = measured @@ -94,11 +98,15 @@ def _reading(self) -> asyncio.Lock: return self._lock -async def _read(url: str) -> datetime.datetime: +async def _read(url: str) -> tuple[datetime.datetime, float | None]: + """The clock's time, and the rate it says it runs at: None when it says none, or none that is a finite rate.""" async with httpx.AsyncClient() as client: response = await client.get(url, timeout=READ_TIMEOUT_S) response.raise_for_status() - return parse_time(response.json()["virtual_time"]) + body = response.json() + rate = body.get("virtual_seconds_per_real_second") + ok = isinstance(rate, (int, float)) and not isinstance(rate, bool) and math.isfinite(rate) and rate >= 0 + return parse_time(body["virtual_time"]), float(rate) if ok else None def clock_patch_script(now: datetime.datetime) -> bytes: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py index 14e62c93b..67a8a114a 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -1,18 +1,27 @@ -"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in: -``python -m agentenv_protocol.website.conformance `` prints each check and exits 1 when one failed, 2 when the -server couldn't be reached. Everything goes over HTTP: the card, ``/`` under a made-up host, readiness by the card's -``website/v1`` ``ready``, whose answer must be a ``WebsiteReadiness`` (by the declared health path, deprecated, for a -card without it), ``X-Universe-Now`` on every answer the other checks got, a data-plane round trip at the card's ``url`` -(data/get, data/reset, data/get, which no longer returns the exported data, data/add of what the first data/get -returned, data/get again), ``clock/v1``'s -``sync_time`` against a clock the checker serves, and the forwarded headers wherever an answer shows them, on ``/`` and -on a GET route with its trailing slash toggled, which a server may redirect. +"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in: ``python -m +agentenv_protocol.website.conformance `` prints each check and exits 1 when a required one failed (or, with +``--strict``, when a recommended one warned), 2 when the server couldn't be reached. Everything goes over HTTP. + +The required checks are the contract, what the gateway and a browser rely on of any server: ``/`` serves HTML under a +made-up host, the site is ready, by the card's ``website/v1`` ``ready``, whose answer must be a +``WebsiteReadiness``, or, for a card without it, by the health path (the card's, else ``/api/health``) answering 200, which is deprecated, the data plane and card's paths are not +the site's pages, and, when the card advertises the data plane, a round trip at the card's ``url`` (data/get, +data/reset, data/get, which no longer returns the exported data, data/add of what the first data/get returned, data/get +again) returns what it was given. A failed one is ``failed``. The recommended checks are what an SDK site does and the +gateway uses when a server offers it: a card declaring the site's domains, health path and routes, ``X-Universe-Now`` +on every answer the other checks got, ``clock/v1``'s ``sync_time`` against a clock the checker serves, the forwarded +headers wherever an answer shows them (on ``/`` and on a GET route with its trailing slash toggled, which a server may +redirect), and cookies tied to no domain, the session path's the one the card names. A failed one is a ``warning``. The round trip leaves the server's data as it found it, adding the export back when a step after the reset fails too. -The clock check leaves the -server following a clock that is gone once the check ends, so a server it ran against keeps extrapolating from that -clock's time until it's synced again. The checker's clock starts at ``CLOCK_START``, far enough from wall time that a -synced server shows it, and a synced server's ``X-Universe-Now`` may be ``CLOCK_SLACK_S`` seconds from it.""" +data/add sets the state a task starts from, so a server whose state shows changes since its initial state, as an SDK +site's does, would have them folded into it, and data/reset drops a site's named sessions, which data/get doesn't +export: the round trip is skipped for a server whose state shows changes or ``changed_sessions``. Its state is its +card's ``website/v1`` ``state``, else, for a card without it, what the path its ``params.state.path`` names answers, +else ``/go``'s, deprecated. The clock check leaves the server following a clock that is gone once the check ends, so a +server it ran against keeps extrapolating from that clock's time until it's synced again. The checker's clock starts at +``CLOCK_START``, far enough from wall time that a synced server shows it, and a synced server's ``X-Universe-Now`` may +be ``CLOCK_SLACK_S`` seconds from it.""" from __future__ import annotations import argparse @@ -33,24 +42,34 @@ from ..client import find_extension, find_extension_method from ..types import METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, iso, parse_time -from .environment import DEFAULT_HEALTH_PATH +from .environment import DEFAULT_HEALTH_PATH, STATE_PATH from .readiness import WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness +from .state import WEBSITE_STATE_OP -Status = Literal["passed", "failed", "skipped"] +Status = Literal["passed", "failed", "warning", "skipped"] CLOCK_START = datetime.datetime(2031, 5, 6, 7, 8, 9, tzinfo=datetime.timezone.utc) CLOCK_SLACK_S = 120 +REQUIRED = ("any_host", "health", "reserved_paths", "data_plane") @dataclasses.dataclass(frozen=True) class Result: + """One check's outcome; ``required`` for a check of the contract, whose failure is ``failed``, and not for a + recommended one, whose failure is a ``warning``.""" + name: str status: Status detail: str = "" + required: bool = True @property def passed(self) -> bool: return self.status != "failed" + def fails(self, strict: bool = False) -> bool: + """Whether the result fails the run: a failed check, or with ``strict`` a warning too.""" + return self.status == "failed" or (strict and self.status == "warning") + def check(base_url: str, *, client: httpx.Client | None = None, clock_host: str = "127.0.0.1", timeout: float = 30) -> list[Result]: @@ -84,20 +103,27 @@ def run(self) -> list[Result]: card = response.json() if response.status_code == 200 else {} except ValueError: card = {} + card = card if isinstance(card, dict) else {} self.rpc_url = _rpc_url(self.base_url, card) site = (find_extension(card, WEBSITE_EXTENSION_URI) or {}).get("params") or {} health = site.get("health") if isinstance(site.get("health"), str) else DEFAULT_HEALTH_PATH - steps = (("any_host", self._any_host), ("health", lambda: self._health(card, health)), - ("universe_now", self._universe_now), ("data_plane", lambda: self._data_plane(card)), + steps = (("card", lambda: self._card(response, site)), ("any_host", self._any_host), + ("health", lambda: self._health(card, health)), ("universe_now", self._universe_now), + ("reserved_paths", self._reserved_paths), ("data_plane", lambda: self._data_plane(card)), ("clock", lambda: self._clock(card, health)), ("forwarded", lambda: self._forwarded(site)), ("cookies", lambda: self._cookies(site))) outcomes = {name: _outcome(step) for name, step in steps if name != "universe_now"} outcomes["universe_now"] = _outcome(self._universe_now) - return [self._card(response, site), *(Result(name, *outcomes[name]) for name, _step in steps)] - - def _card(self, response: httpx.Response, site: dict) -> Result: + results = [] + for name, _step in steps: + status, detail = outcomes[name] + required = name in REQUIRED + results.append(Result(name, "warning" if status == "failed" and not required else status, detail, required)) + return results + + def _card(self, response: httpx.Response, site: dict) -> tuple[Status, str]: if response.status_code != 200: - return Result("card", "failed", f"{WELL_KNOWN_PATH} answered {response.status_code}") + return "failed", f"{WELL_KNOWN_PATH} answered {response.status_code}" problems = [] if not site: problems.append(f"declares no {WEBSITE_EXTENSION_URI} with params") @@ -109,7 +135,7 @@ def _card(self, response: httpx.Response, site: dict) -> Result: if not isinstance(routes, list) or not all(isinstance(r, dict) and {"method", "path", "name"} <= set(r) for r in routes): problems.append("its params' routes aren't a list of {method, path, name}") - return Result("card", "failed" if problems else "passed", "; ".join(problems)) + return ("failed" if problems else "passed"), "; ".join(problems) def _any_host(self) -> tuple[Status, str]: host = f"conformance-{uuid.uuid4().hex[:8]}.invalid" @@ -151,6 +177,14 @@ def _universe_now(self) -> tuple[Status, str]: parse_time(response.headers[UNIVERSE_NOW_HEADER]) return "passed", f"on all {len(self.responses)} answers" + def _reserved_paths(self) -> tuple[Status, str]: + paths = [RPC_PATH, WELL_KNOWN_PATH, f"{RPC_PATH}/conformance-{uuid.uuid4().hex[:8]}", f"{RPC_PATH}/", f"{WELL_KNOWN_PATH}/"] + for path in paths: + response = self.get(path) + if response.is_success and response.headers.get("content-type", "").startswith("text/html"): + return "failed", f"{path} answered a page, but the gateway keeps {RPC_PATH} and {WELL_KNOWN_PATH}" + return "passed", f"no page at {', '.join(paths)}" + def _rpc(self, method: str, params: dict | None = None) -> dict: response = self._seen(self.client.post(self.rpc_url, json={"jsonrpc": "2.0", "id": method, "method": method, "params": params or {}})) @@ -163,10 +197,17 @@ def _rpc(self, method: str, params: dict | None = None) -> dict: def _data_plane(self, card: dict) -> tuple[Status, str]: operations = (card.get("capabilities") or {}).get("operations") - missing = [m for m in (METHOD_GET, METHOD_RESET, METHOD_ADD) if operations is not None and m not in operations] + operations = operations if isinstance(operations, list) else [] + missing = [m for m in (METHOD_GET, METHOD_RESET, METHOD_ADD) if m not in operations] + if len(missing) == 3: + return "skipped", "the card advertises no data plane" if missing: return "failed", f"the card's operations lack {', '.join(missing)}" exported = self._rpc(METHOD_GET).get("parts") or [] + if where := self._changed_since_initial(card): + return "skipped", (f"data/get answered; {where} shows changes since the initial state, or named sessions " + "with changes, which data/reset and data/add would fold in or drop, so the round trip " + "was not run") self._rpc(METHOD_RESET) added_back = False try: @@ -188,6 +229,26 @@ def _data_plane(self, card: dict) -> tuple[Status, str]: return "failed", "data/get after data/reset and data/add of its own export returned other data" return "passed", f"{len(exported)} part(s) round-tripped through reset and add" + def _changed_since_initial(self, card: dict) -> str | None: + """Where the server's state was read when its ``state_diff`` names changes or it names ``changed_sessions``, + None when it names neither or can't be read: the card's ``website/v1`` ``state``, else the path its + ``params.state.path`` names, else ``/go``. Read aside, as no check's answer, since a server without one needn't + stamp its 404.""" + op = find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_STATE_OP) + if op is not None and op.get("endpoint"): + where, request = f"website/v1 {WEBSITE_STATE_OP}", (op["method"], self.base_url + op["endpoint"]) + else: + declared = ((find_extension(card, WEBSITE_EXTENSION_URI) or {}).get("params") or {}).get("state") + path = declared.get("path") if isinstance(declared, dict) else None + where = path if isinstance(path, str) and path.startswith("/") else STATE_PATH + request = ("GET", self.base_url + where) + try: + state = self.client.request(*request).json() + except Exception: + return None + changed = isinstance(state, dict) and bool(state.get("state_diff") or state.get("changed_sessions")) + return where if changed else None + def _clock(self, card: dict, health: str) -> tuple[Status, str]: if find_extension(card, CLOCK_EXTENSION_URI) is None: return "failed", f"the card declares no {CLOCK_EXTENSION_URI}" @@ -224,14 +285,18 @@ def _forwarded(self, site: dict) -> tuple[Status, str]: return "passed", f"no answer to {', '.join(paths)} named the internal Host" def _cookies(self, site: dict) -> tuple[Status, str]: + session = [] if isinstance(site.get("session_path"), str): - self.get(site["session_path"]) + session = self.get(site["session_path"]).headers.get_list("set-cookie") cookies = [c for r in self.responses for c in r.headers.get_list("set-cookie")] if not cookies: return "skipped", "no answer set a cookie" scoped = [c.split("=", 1)[0] for c in cookies if any(a.strip().lower().startswith("domain=") for a in c.split(";"))] if scoped: return "failed", f"cookies tied to a domain: {', '.join(sorted(set(scoped)))}" + named = site.get("session_cookie") + if isinstance(named, str) and session and named not in {c.split("=", 1)[0].strip() for c in session}: + return "failed", f"{site['session_path']} set {', '.join(c.split('=', 1)[0] for c in session)}, not the card's {named}" return "passed", f"{len(cookies)} cookie(s), none tied to a domain" @@ -286,7 +351,7 @@ def virtual_now() -> datetime.datetime: class Clock(BaseHTTPRequestHandler): def do_GET(self) -> None: - body = json.dumps({"virtual_time": iso(virtual_now())}).encode() + body = json.dumps({"virtual_time": iso(virtual_now()), "virtual_seconds_per_real_second": 1.0}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) @@ -306,7 +371,7 @@ def log_message(self, *args) -> None: server.server_close() -_MARK = {"passed": "PASS", "failed": "FAIL", "skipped": "----"} +_MARK = {"passed": "PASS", "failed": "FAIL", "warning": "WARN", "skipped": "----"} def main(argv: list[str] | None = None) -> int: @@ -315,6 +380,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("url", help="where the server answers, such as http://localhost:8000") parser.add_argument("--clock-host", default="127.0.0.1", help="the address the server reaches this checker at") parser.add_argument("--timeout", type=float, default=30, help="seconds for each request") + parser.add_argument("--strict", action="store_true", help="fail on a recommended check's warning too") args = parser.parse_args(argv) try: results = check(args.url, clock_host=args.clock_host, timeout=args.timeout) @@ -322,10 +388,12 @@ def main(argv: list[str] | None = None) -> int: print(f"couldn't reach {args.url}: {type(e).__name__}: {e}", file=sys.stderr) return 2 for result in results: - print(f"{_MARK[result.status]} {result.name}{': ' + result.detail if result.detail else ''}") - failed = sum(not r.passed for r in results) - print(f"{args.url}: {len(results) - failed} passed or skipped, {failed} failed") - return 1 if failed else 0 + kind = "required" if result.required else "recommended" + print(f"{_MARK[result.status]} {result.name} ({kind}){': ' + result.detail if result.detail else ''}") + counts = {status: sum(r.status == status for r in results) for status in _MARK} + print(f"{args.url}: {counts['passed']} passed, {counts['failed']} failed, {counts['warning']} warning(s), " + f"{counts['skipped']} skipped{' (strict)' if args.strict else ''}") + return 1 if any(r.fails(args.strict) for r in results) else 0 if __name__ == "__main__": diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/database.py b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py new file mode 100644 index 000000000..34373ea47 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py @@ -0,0 +1,228 @@ +"""A site whose state is a database's: ``@website(models=...)`` names the data an MCP server keeps, so the site's pages +and the server's tools read and write the same rows under the same constraints (and the same rules, where the site +calls the server's own service code). ``models`` is either an object with +``load(data)``, ``export()`` and ``reset()`` (each may be async), the data hooks themselves, or SQLAlchemy models: a +declarative ``Base``, a ``MetaData``, or a module holding one (its ``Base``, else its one metadata), as the +synthetic_servers kit's ``database.py`` declares them. For models, the site's ``self.db`` is a SQLAlchemy session +factory on ``DATABASE_URL``, which agent-env points at the env's own schema of the service database (its +``search_path``), creating the models' tables there on first use; its data is a JSON object of each table's rows by +table name, which data/add inserts, data/get exports, and data/reset deletes. SQLAlchemy is imported only for models, +from the ``db`` extra, which carries SQLAlchemy 2.1 and its ``postgresql://`` driver, ``psycopg``; a site whose +``DATABASE_URL`` names another driver installs that one, and an engine whose driver isn't installed raises ImportError +naming it. + +Beside the tables, the data's string ``current_user`` names the user a browser starts signed in as, as the default +store's does, and the state data/add last loaded is the initial one ``/go`` reports. A models site keeps one state, +the database's, so ``/go`` answers it for every ``sid``.""" +from __future__ import annotations + +import copy +import datetime +import decimal +import itertools +import json +import types +import uuid +from typing import Any, Callable, Iterable + +from pydantic_core import to_jsonable_python + +from ..agent_env_environment import _maybe_await +from .store import CURRENT_USER, part_data, verifier_state + +HOOKS = ("load", "export", "reset") + + +def is_hooks(models: Any) -> bool: + """Whether ``models`` is data hooks of its own: an object with callable ``load``, ``export`` and ``reset``.""" + return all(callable(getattr(models, name, None)) for name in HOOKS) + + +def metadata_of(models: Any) -> Any: + """The SQLAlchemy ``MetaData`` ``models`` names: itself, its ``metadata``, or a module's ``Base``'s or one + metadata; raises TypeError for anything else.""" + sqlalchemy = _sqlalchemy() + if isinstance(models, sqlalchemy.MetaData): + return models + if isinstance(getattr(models, "metadata", None), sqlalchemy.MetaData): + return models.metadata + if isinstance(models, types.ModuleType): + if isinstance(getattr(getattr(models, "Base", None), "metadata", None), sqlalchemy.MetaData): + return models.Base.metadata + found = {id(m): m for value in vars(models).values() if isinstance( + m := value if isinstance(value, sqlalchemy.MetaData) else getattr(value, "metadata", None), + sqlalchemy.MetaData)} + if len(found) == 1: + return next(iter(found.values())) + if found: + raise TypeError(f"module {models.__name__} holds {len(found)} MetaData; pass models= its Base") + raise TypeError(f"models= is an object with load/export/reset, or SQLAlchemy models (a declarative Base, a " + f"MetaData, or a module holding one), not {models!r}") + + +def _sqlalchemy() -> Any: + try: + import sqlalchemy + except ImportError as e: + raise ImportError("@website(models=...) of SQLAlchemy models needs sqlalchemy: " + "pip install 'agentenv-framework-protocol[db]'") from e + return sqlalchemy + + +class Tables: + """The data hooks of SQLAlchemy ``metadata`` on the database at ``url()``: each table's rows by table name.""" + + def __init__(self, metadata: Any, url: Callable[[], str | None]) -> None: + self.metadata, self._url = metadata, url + self._engine: Any = None + self._sessions: Any = None + + @property + def engine(self) -> Any: + """The engine on ``DATABASE_URL``, made on first use with the models' tables created there; raises + ImportError naming the driver when the URL's isn't installed.""" + if self._engine is None: + url = self._url() + if not url: + raise RuntimeError("a site of SQLAlchemy models keeps its rows in DATABASE_URL, which isn't set") + sqlalchemy = _sqlalchemy() + try: + engine = sqlalchemy.create_engine(url) + except ImportError as e: + scheme = sqlalchemy.engine.make_url(url).drivername + raise ImportError(f"DATABASE_URL's {scheme}:// needs the database driver {e.name or 'it names'}, which " + f"isn't installed; the db extra carries psycopg for postgresql://, and a site installs any " + f"other driver itself (pip install {e.name or 'the driver'})") from e + self.metadata.create_all(engine) + self._engine = engine + return self._engine + + @property + def sessions(self) -> Any: + if self._sessions is None: + from sqlalchemy.orm import sessionmaker + + self._sessions = sessionmaker(bind=self.engine) + return self._sessions + + def export(self) -> dict: + sqlalchemy = _sqlalchemy() + with self.engine.connect() as connection: + return {table.name: [to_jsonable_python(dict(row._mapping)) for row in connection.execute( + sqlalchemy.select(table).order_by(*table.primary_key.columns))] for table in self.metadata.sorted_tables} + + def load(self, data: dict) -> None: + """Insert ``data``'s rows, each table's after the tables it refers to, in their order, each run of rows that name + the same columns as one insert (one insert takes its columns from its first row, so a row that leaves an + optional column out goes in its own); refuses a name that is no table.""" + tables = {table.name: table for table in self.metadata.sorted_tables} + unknown = sorted(set(data) - set(tables)) + if unknown: + raise ValueError(f"no table {', '.join(map(repr, unknown))}; the tables are {sorted(tables)}") + with self.engine.begin() as connection: + for name, table in tables.items(): + rows = data.get(name) or [] + if not (isinstance(rows, list) and all(isinstance(row, dict) for row in rows)): + raise ValueError(f"table {name!r}'s data is not a list of rows") + for _, run in itertools.groupby(rows, key=lambda row: sorted(row)): + connection.execute(table.insert(), [_row(table, row) for row in run]) + if connection.dialect.name == "postgresql": + _advance_sequences(connection, tables.values()) + + def reset(self) -> None: + with self.engine.begin() as connection: + for table in reversed(self.metadata.sorted_tables): + connection.execute(table.delete()) + + def key(self, name: str, row: dict) -> str: + """A row's identity, its primary key's values, which ``/go``'s diff matches rows by.""" + table = next((t for t in self.metadata.sorted_tables if t.name == name), None) + columns = [c.name for c in table.primary_key.columns] if table is not None else [] + return json.dumps([row.get(c) for c in columns] if columns else row, sort_keys=True, default=str) + + +_PARSERS: dict = {datetime.datetime: datetime.datetime.fromisoformat, datetime.date: datetime.date.fromisoformat, + datetime.time: datetime.time.fromisoformat, decimal.Decimal: decimal.Decimal, uuid.UUID: uuid.UUID} + + +def _row(table: Any, row: dict) -> dict: + """``row`` with each JSON string a date, time, decimal or UUID column holds parsed to that type.""" + parsed = dict(row) + for column in table.columns: + value = parsed.get(column.name) + if isinstance(value, str): + kind = _python_type(column) + if kind in _PARSERS: + parsed[column.name] = _PARSERS[kind](value.replace("Z", "+00:00") if kind is datetime.datetime else value) + return parsed + + +def _python_type(column: Any) -> type | None: + try: + return column.type.python_type + except NotImplementedError: + return None + + +def _advance_sequences(connection: Any, tables: Iterable[Any]) -> None: + """Move each serial key's sequence past the rows just loaded, so the next row the site or server adds takes a key + of its own.""" + sqlalchemy = _sqlalchemy() + for table in tables: + keys = list(table.primary_key.columns) + if len(keys) == 1 and keys[0].autoincrement in (True, "auto") and _python_type(keys[0]) is int: + quote = connection.dialect.identifier_preparer + name = quote.format_table(table) + connection.execute(sqlalchemy.text( + f"SELECT setval(pg_get_serial_sequence(:table, :column), COALESCE(MAX({quote.quote(keys[0].name)}), 0) + 1, " + f"false) FROM {name}"), {"table": name, "column": keys[0].name}) + + +class Sessions: + """A site's ``self.db``: ``self.db()`` is a SQLAlchemy ``Session`` on the site's database, and ``self.db.begin()`` + one in a transaction that commits as its ``with`` ends; the engine is made on first use, not at mount.""" + + def __init__(self, tables: Tables) -> None: + self._tables = tables + + def __call__(self, **options: Any) -> Any: + return self._tables.sessions(**options) + + def begin(self) -> Any: + return self._tables.sessions.begin() + + @property + def engine(self) -> Any: + return self._tables.engine + + +class ModelState: + """A models site's data plane and ``/go``: ``hooks`` loads, exports and resets its data, the state data/add last + loaded is the initial one, and the data's ``current_user`` is kept beside it.""" + + def __init__(self, hooks: Any) -> None: + self.hooks = hooks + self.initial: dict = {} + self.current_user: str | None = None + + async def reset(self) -> None: + await _maybe_await(self.hooks.reset()) + self.initial, self.current_user = {}, None + + async def load_parts(self, parts: list) -> None: + for part in parts: + data = dict(part_data(part)) + if CURRENT_USER in data: + self.current_user = data.pop(CURRENT_USER) + await _maybe_await(self.hooks.load(data)) + self.initial = copy.deepcopy(await self.export()) + + async def export(self) -> dict: + data = dict(await _maybe_await(self.hooks.export())) + if self.current_user is not None: + data[CURRENT_USER] = self.current_user + return data + + async def state(self) -> dict: + key = getattr(self.hooks, "key", None) + return verifier_state(copy.deepcopy(self.initial), await self.export(), key) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py index ac05ba6be..9a7a27059 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py @@ -2,12 +2,23 @@ plain HTTP on ``PORT`` for any host name: its ``@api`` routes, its static folder at ``/`` with ``index.html`` for the pages a single-page app routes itself, its health path, and the data plane and card the base class serves. Every answer carries ``X-Universe-Now``, the universe's time, which ``self.now()`` reads and ``clock/v1``'s ``sync_time`` ties to -the gateway's virtual clock. Without data hooks of its own a site keeps its data in ``self.store``, which the data -plane loads, exports and resets: ``data/get`` is how agentenv reads what a task left a site with. - -One route is built in, replaced by a site's own ``@api`` route at its very path and answering before one whose path -parameters would also match it: ``/api/session``, which a frontend calls to learn who is signed in, signing the acting -user in when nobody is (GET), or to sign out (DELETE).""" +the gateway's virtual clock. Without data hooks of its own a site keeps its state in ``self.store``, or with +``@website(models=...)`` in an MCP server's database, which ``self.db`` opens sessions of (``website.database``). + +A store-backed site keeps a state per session, the CUA-Gym convention the website state verifier reads: a request's +session is its ``?sid=``, else the one its browser last named, which the site keeps in the ``agentenv_sid_`` +cookie, else the default one. ``self.store`` is the request's session's store, each session started as a copy of the +initial state; the default session, ``sid=default``, is the state the data plane loads, exports and resets, and the +one a browser that names no session uses, so a site browsed without ``sid`` works as it did before sessions. + +Two routes are built in, each replaced by a site's own ``@api`` route at its very path and answering before one whose +path parameters would also match it: ``/api/session``, which a frontend calls to learn who is signed in, signing the +acting user in when nobody is (GET), or to sign out (DELETE); and, for a store-backed site, ``/go?sid=``, that +session's ``{initial_state, current_state, state_diff}`` at the path ``website/v1``'s ``params.state.path`` names, +which a reader of a card without ``state`` falls back to, with the site's ``changed_sessions``, the named sessions whose +state changed, which data/reset would drop; a session no request has used yet answers the initial state, unchanged. A +store-backed site answers ``website/v1``'s ``state``, of the session its ``sid`` names, with the same ``WebsiteState``, +among agentenv's own routes, which is how the website state verifier reads it.""" from __future__ import annotations import inspect @@ -27,6 +38,7 @@ from starlette.routing import Route from ..agent_env_environment import ( + _CARD_CONFIG_ATTR, _OP_ATTR, AgentEnvEnvironment, AgentEnvStarletteApplication, @@ -37,17 +49,20 @@ get_data, reset_data, ) -from ..types import DataPart +from ..types import DataPart, EnvironmentExtension from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, UniverseClock, clock_patch_script, iso +from .database import ModelState, Sessions, Tables, is_hooks, metadata_of from .errors import NotFound from .readiness import WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness from .routes import (ApiContext, ApiRoute, endpoint, error_response, forwarded_prefix, openapi, openapi_path, request_role, reserved_path, routes_of) -from .session import SESSION_COOKIE, SessionSigner, expires_at -from .store import Collections +from .session import SessionSigner, expires_at, session_cookie_name, sid_cookie_name +from .state import WEBSITE_STATE_OP, WebsiteState +from .store import DEFAULT_SID, Collections, active_sid, session_id DEFAULT_HEALTH_PATH = "/api/health" SESSION_PATH = "/api/session" +STATE_PATH = "/go" _WEBSITE_ATTR = "_aee_website" _READY_ATTR = "_aee_website_ready" _DEFAULT_HOOK_ATTR = "_aee_default_store_hook" @@ -57,27 +72,36 @@ class WebsiteDeclaration(NamedTuple): """What ``@website`` declares: the site's domains, its static folder and seed data file (relative to the class's - file), its health path, and whether its HTML pages read the universe's time.""" + file), its health path, whether its HTML pages read the universe's time, and the models whose database keeps its + state instead of the default store.""" domains: tuple[str, ...] = () static: Optional[str] = None health: str = DEFAULT_HEALTH_PATH clock_patch: bool = False data: Optional[str] = None + models: Any = None def website(_cls: type | None = None, *, domains: list[str] | tuple[str, ...] = (), static: str | None = None, - health: str = DEFAULT_HEALTH_PATH, clock_patch: bool = False, data: str | None = None) -> Any: - """Declare a WebsiteEnvironment's site. ``data`` names a JSON file the default store starts with.""" + health: str = DEFAULT_HEALTH_PATH, clock_patch: bool = False, data: str | None = None, + models: Any = None) -> Any: + """Declare a WebsiteEnvironment's site. ``data`` names a JSON file the default store starts with; ``models`` + keeps the site's state in a database instead, an MCP server's (see ``website.database``), and the site's + data plane and ``/go`` are that database's.""" if _cls is not None: raise TypeError("use @website(...) with parentheses") if not health.startswith("/") or health == "/" or reserved_path(health): raise ValueError(f"health is a path of its own under '/', not {health!r}") + if models is not None and data is not None: + raise ValueError("data= seeds the default store; a site of models= is seeded through data/add") + if models is not None and not is_hooks(models): + metadata_of(models) def deco(cls: type) -> type: if not (isinstance(cls, type) and issubclass(cls, WebsiteEnvironment)): raise TypeError(f"@website declares a WebsiteEnvironment subclass, not {cls!r}") - setattr(cls, _WEBSITE_ATTR, WebsiteDeclaration(tuple(domains), static, health, clock_patch, data)) + setattr(cls, _WEBSITE_ATTR, WebsiteDeclaration(tuple(domains), static, health, clock_patch, data, models)) _check_health(cls) return cls @@ -98,6 +122,17 @@ class WebsiteEnvironment(AgentEnvEnvironment): app: ClassVar[Any] = None asgi: Any = None + db: Any = None + _store: Collections + + @property + def store(self) -> Collections: + """The state store of the request's session: the default session's outside a request.""" + return self._store.session(active_sid.get()) + + @store.setter + def store(self, value: Collections) -> None: + self._store = value def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) @@ -113,8 +148,14 @@ def __init__(self) -> None: self.store = Collections() if declaration.data is not None: self.store.load(json.loads(self._site_path(declaration.data).read_text(encoding="utf-8"))) + self.store.mark_initial() self.clock = UniverseClock() - self._signer = SessionSigner() + self._signer = SessionSigner(cookie_name=session_cookie_name(self.environment_name())) + models = declaration.models + self._tables = (None if models is None or is_hooks(models) + else Tables(metadata_of(models), lambda: self.database_url)) + self._models = None if models is None else ModelState(models if self._tables is None else self._tables) + self.db = Sessions(self._tables) if self._tables is not None else None @classmethod def declaration(cls) -> WebsiteDeclaration: @@ -124,6 +165,17 @@ def declaration(cls) -> WebsiteDeclaration: def routes(cls) -> list[ApiRoute]: return routes_of(cls) + def environment_name(self) -> str: + """The site's name, as its card names it: ``ENVIRONMENT_NAME``, else the card's declared name, else the + class's.""" + declared = (getattr(self, _CARD_CONFIG_ATTR, None) or {}).get("name") + return os.environ.get("ENVIRONMENT_NAME") or declared or type(self).__name__ + + @property + def session_cookie(self) -> str: + """The name of the site's session cookie, ``agentenv_session_``.""" + return self._session_signer().cookie_name + @property def database_url(self) -> str | None: """Where the site keeps its task state: its own schema of the env's database.""" @@ -145,12 +197,15 @@ def timezone(self) -> str | None: return self._clock().timezone def acting_user(self) -> str | None: - """Who a browser with no session is signed in as: the store's ``current_user`` unless a site says else.""" + """Who a browser with no session is signed in as: the store's ``current_user``, or the one a models site's data + named, unless a site says else.""" + if getattr(self, "_models", None) is not None: + return self._models.current_user return self.store.current_user if hasattr(self, "store") else None def context(self, request: Request) -> ApiContext: """The ``ApiContext`` of ``request``, for a mounted ``app``'s routes as for ``@api`` ones.""" - session = self._session_signer().decode(request.cookies.get(SESSION_COOKIE)) + session = self._session_signer().read(request.cookies) user = session["user"] if session is not None else self.acting_user() return ApiContext(request, user, request_role(request), session, self._session_signer()) @@ -162,11 +217,14 @@ def create_app(self) -> Starlette: api_routes = self.routes() taken = {openapi_path(r.path) for r in api_routes} root = self._site_path(declaration.static) if declaration.static is not None else None - asgi = Starlette(middleware=[Middleware(_Forwarded), Middleware(_Universe, env=self)]) + asgi = Starlette(middleware=[Middleware(_Forwarded), Middleware(_Universe, env=self), + Middleware(_Session, cookie=sid_cookie_name(self.environment_name()))]) AgentEnvStarletteApplication(self._build_card(), self).add_routes_to_app(asgi) asgi.router.routes.append(Route(declaration.health, self._health, methods=["GET"])) if SESSION_PATH not in taken: asgi.router.routes.append(Route(SESSION_PATH, self._session, methods=["GET", "DELETE"])) + if self._serves_state_path(): + asgi.router.routes.append(Route(STATE_PATH, self._state, methods=["GET"])) asgi.router.routes.extend(Route(r.path, _unreserved(endpoint(getattr(self, r.fn.__name__), r, self.context)), methods=[r.method]) for r in api_routes) asgi.router.default = _Fallback(root, type(self).app) @@ -190,10 +248,18 @@ def extension_params(self, uri: str) -> dict: if uri == WEBSITE_EXTENSION_URI: declaration = self.declaration() return {"domains": list(declaration.domains), "health": declaration.health, - "routes": [r.advertised() for r in self.routes()], "session_cookie": SESSION_COOKIE, - "session_path": SESSION_PATH} + "routes": [r.advertised() for r in self.routes()], "session_cookie": self.session_cookie, + "session_path": SESSION_PATH, **({"state": {"path": STATE_PATH}} if self._serves_state_path() else {})} return super().extension_params(uri) + def served_extensions(self, extensions: list[EnvironmentExtension]) -> list[EnvironmentExtension]: + """The extensions the base class serves, ``website/v1``'s ``state`` only for a store-backed site: one with data + hooks of its own keeps its state where ``self.store`` can't see it.""" + served = super().served_extensions(extensions) + if self.uses_store: + return served + return [_without_method(e, WEBSITE_STATE_OP) if e.uri == WEBSITE_EXTENSION_URI else e for e in served] + @extension(CLOCK_EXTENSION_URI, description="Sync this site to the gateway's virtual clock.") async def sync_time(self, env_get_time_url: str, timezone: Optional[str] = None) -> dict: """Follow the gateway's virtual time at ``env_get_time_url`` from now on, in ``timezone`` when the task names @@ -204,8 +270,9 @@ async def sync_time(self, env_get_time_url: str, timezone: Optional[str] = None) @extension(WEBSITE_EXTENSION_URI, op="openapi", method="GET", description=( "A site's server: domains, its health path and its @api routes by name; openapi returns the OpenAPI document " - "of its routes, ready whether the site is usable now and why not, and session the cookies a browser adds to " - "start signed in as user, or signed out without one.")) + "of its routes, ready whether the site is usable now and why not, state the task state it is in (its " + "initial and current state and their diff), and session the cookies a browser adds to start signed in as user, " + "or signed out without one.")) async def _website_openapi(self) -> dict: name = self._application.environment_card.name if self._application is not None else type(self).__name__ return openapi(name, self.routes()) @@ -217,6 +284,16 @@ async def _website_ready(self) -> dict: problems = await self._not_ready() return WebsiteReadiness(ready=not problems, problems=problems).model_dump() + @extension(WEBSITE_EXTENSION_URI, op=WEBSITE_STATE_OP, method="GET") + async def _website_state(self, sid: Optional[str] = None) -> dict: + """The site's task state, a ``WebsiteState``: its state when the task started, now, and what changed between + them, of session ``sid``'s store (the default session's without one), with the site's ``changed_sessions`` as + ``/go`` names them, or of a models site's database, which keeps one.""" + if self._models is not None: + return WebsiteState.model_validate(await self._models.state()).model_dump() + return {**WebsiteState.model_validate(self._store.session(sid).state()).model_dump(), + "changed_sessions": self._store.changed_sessions()} + @extension(WEBSITE_EXTENSION_URI, op="session") async def _website_session(self, user: Optional[str] = None) -> dict: cookie = self._session_signer().cookie(user) @@ -224,15 +301,20 @@ async def _website_session(self, user: Optional[str] = None) -> dict: @reset_data async def _store_reset(self) -> None: - self.store.reset() + if self._models is not None: + return await self._models.reset() + self._store.reset() @add_data async def _store_add(self, parts: list) -> None: - self.store.load_parts(parts) + if self._models is not None: + return await self._models.load_parts(parts) + self._store.load_parts(parts) + self._store.mark_initial() @get_data async def _store_get(self) -> list: - return [DataPart(data=self.store.export())] + return [DataPart(data=await self._models.export() if self._models is not None else self._store.export())] async def _not_ready(self) -> list[str]: return [check.__name__ for check, _marker in _marked_members(self, _READY_ATTR) if not await _ready(check)] @@ -252,6 +334,15 @@ async def _session(self, request: Request) -> Response: return context.apply(JSONResponse({"user": context.user, "role": context.role, "signed_in": context.user is not None})) + def _serves_state_path(self) -> bool: + """Whether the site serves its state at ``STATE_PATH``: it is store-backed and no ``@api`` route takes the path.""" + return self.uses_store and STATE_PATH not in {openapi_path(r.path) for r in self.routes()} + + async def _state(self, request: Request) -> Response: + if self._models is not None: + return JSONResponse(await self._models.state()) + return JSONResponse({**self.store.state(), "changed_sessions": self._store.changed_sessions()}) + def _clock(self) -> UniverseClock: if not hasattr(self, "clock"): raise TypeError(f"{type(self).__qualname__}.__init__ must call super().__init__()") @@ -272,6 +363,12 @@ def _site_path(cls, path: str) -> Path: setattr(getattr(WebsiteEnvironment, _hook), _DEFAULT_HOOK_ATTR, True) +def _without_method(extension: EnvironmentExtension, method: str) -> EnvironmentExtension: + params = dict(extension.params or {}) + params["methods"] = {name: entry for name, entry in (params.get("methods") or {}).items() if name != method} + return extension.model_copy(update={"params": params}) + + async def _ready(check: Any) -> bool: try: return bool(await _maybe_await(check())) @@ -319,6 +416,34 @@ def _first(value: bytes | None) -> str: return value.decode("latin-1").split(",")[0].strip() if value else "" +class _Session: + """Runs the request in its session (``active_sid``): its ``sid`` query parameter's, else its ``cookie``'s; an + answer to a request naming a session other than its cookie's sets the cookie, so a page opened at ``?sid=`` keeps + its session for the requests it makes after.""" + + def __init__(self, app: Any, cookie: str) -> None: + self.app, self.cookie = app, cookie + + async def __call__(self, scope: dict, receive: Any, send: Any) -> None: + if scope["type"] != "http": + return await self.app(scope, receive, send) + request = Request(scope) + named, kept = request.query_params.get("sid"), request.cookies.get(self.cookie) + sid = session_id(named if named is not None else kept) + + async def remembered(message: dict) -> None: + if message["type"] == "http.response.start" and named is not None and session_id(kept) != sid: + MutableHeaders(scope=message).append( + "set-cookie", f"{self.cookie}={sid or DEFAULT_SID}; Path=/; HttpOnly; SameSite=Lax") + await send(message) + + token = active_sid.set(sid) + try: + await self.app(scope, receive, remembered) + finally: + active_sid.reset(token) + + class _Universe: """Stamps ``X-Universe-Now`` on every answer, and with ``clock_patch`` puts the clock script in its whole HTML (never a range of it), holding only the HTML up to its ```` (at most ``HEAD_SCAN_BYTES``), so a page streamed in chunks reaches the browser diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py b/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py index 939803342..15158ddc8 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py @@ -26,7 +26,7 @@ from ..tool_context import DEFAULT_ROLE, caller_from_request from ..types import RPC_PATH, WELL_KNOWN_PATH from .errors import ApiError, BadRequest, Forbidden -from .session import SESSION_COOKIE, SessionSigner +from .session import SessionSigner logger = logging.getLogger(__name__) @@ -154,7 +154,7 @@ def _set_session(self, user: str | None) -> None: def apply(self, response: Response) -> Response: for value, expires in self._set_cookie: at = datetime.datetime.fromtimestamp(expires, datetime.timezone.utc) - response.set_cookie(SESSION_COOKIE, value, expires=at, path="/", httponly=True, samesite="lax") + response.set_cookie(self._signer.cookie_name, value, expires=at, path="/", httponly=True, samesite="lax") return response diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/session.py b/packages/agentenv-protocol/src/agentenv_protocol/website/session.py index 20e4ad36f..b798afcd4 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/session.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/session.py @@ -1,6 +1,8 @@ """A site's signed session cookie: who the browser is signed in as, or that it signed out, signed with HMAC-SHA256 under ``WEBSITE_SESSION_SECRET``, or a secret of the process's own when that isn't set. The cookie names no domain, -so it holds under whatever host the site is reached at.""" +so it holds under whatever host the site is reached at, and its name is the site's own, ``agentenv_session_``, so +two sites a browser reaches under one gateway host (``/website/a/``, ``/website/b/``) keep a session each; a site still +reads the shared ``agentenv_session`` of older sites, for one release.""" from __future__ import annotations import base64 @@ -10,19 +12,46 @@ import hmac import json import os +import re import secrets import time SESSION_COOKIE = "agentenv_session" +SID_COOKIE = "agentenv_sid" SECRET_ENV = "WEBSITE_SESSION_SECRET" SESSION_TTL_S = 7 * 24 * 3600 SECRET_BYTES = 32 +_NOT_TOKEN = re.compile(r"[^A-Za-z0-9_.-]") + + +def _cookie_suffix(environment_name: str) -> str: + """The part of a site's cookie names that is its own: the name itself when a cookie name can hold each of its + characters, else the name with each such character made ``_`` and ``~`` and a hash of the whole name appended. No + name kept as itself holds a ``~``, so two distinct names never make one suffix.""" + if not _NOT_TOKEN.search(environment_name): + return environment_name + digest = hashlib.sha256(environment_name.encode("utf-8", "surrogatepass")).hexdigest()[:12] + return f"{_NOT_TOKEN.sub('_', environment_name)}~{digest}" + + +def session_cookie_name(environment_name: str | None) -> str: + """The session cookie of the site named ``environment_name``: ``agentenv_session_``, a name a cookie name + can't hold spelled as ``_cookie_suffix`` spells it; the shared ``agentenv_session`` for a site with no name.""" + return f"{SESSION_COOKIE}_{_cookie_suffix(environment_name)}" if environment_name else SESSION_COOKIE + + +def sid_cookie_name(environment_name: str | None) -> str: + """The cookie in which the site named ``environment_name`` keeps the store session its browser last named: + ``agentenv_sid_``, made as ``session_cookie_name`` makes its name, ``agentenv_sid`` for a site with no name. + Its prefix is no session cookie's, so no site's store-selection cookie is another site's session cookie.""" + return f"{SID_COOKIE}_{_cookie_suffix(environment_name)}" if environment_name else SID_COOKIE class SessionSigner: - def __init__(self, secret: str | bytes | None = None) -> None: + def __init__(self, secret: str | bytes | None = None, cookie_name: str = SESSION_COOKIE) -> None: secret = secret if secret is not None else os.environ.get(SECRET_ENV) self._key = (secret.encode() if isinstance(secret, str) else secret) or secrets.token_bytes(SECRET_BYTES) + self.cookie_name = cookie_name def encode(self, user: str | None, ttl_s: int = SESSION_TTL_S) -> tuple[str, int]: """The cookie value for ``user`` signed in, or signed out when None, and when it expires, in epoch @@ -45,11 +74,18 @@ def decode(self, value: str | None) -> dict | None: return None return {"user": data.get("u")} + def read(self, cookies: dict) -> dict | None: + """The session of a request's ``cookies``: its own cookie's, else the shared legacy cookie's.""" + session = self.decode(cookies.get(self.cookie_name)) + if session is None and self.cookie_name != SESSION_COOKIE: + session = self.decode(cookies.get(SESSION_COOKIE)) + return session + def cookie(self, user: str | None, ttl_s: int = SESSION_TTL_S) -> dict: """The cookie a browser sets to be signed in as ``user``, as a browser automation adds one; its domain is left to whoever adds it.""" value, expires = self.encode(user, ttl_s) - return {"name": SESSION_COOKIE, "value": value, "path": "/", "expires": expires, "httpOnly": True, + return {"name": self.cookie_name, "value": value, "path": "/", "expires": expires, "httpOnly": True, "sameSite": "Lax"} def _sign(self, payload: str) -> str: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/store.py b/packages/agentenv-protocol/src/agentenv_protocol/website/store.py index 657ea78f0..63c316a75 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/store.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/store.py @@ -1,11 +1,19 @@ -"""The default data store of a simple site: named collections of JSON objects, each with an ``id``. Its data is a JSON -object of collections by name, beside which a string ``current_user`` names the user a browser starts signed in as; -the data plane loads, exports and resets it.""" +"""The default state store of a simple site: named collections of JSON objects, each with an ``id``. Its data is a JSON +object of collections by name, beside which a string ``current_user`` names the user a browser starts signed in as. +It remembers the state it was last loaded with, so a verifier can read the initial state, the current one and the +difference between them. + +A store also keeps sessions, as CUA-Gym sites do, so browsers that each name a session id (``?sid=``) never see one +another's changes: ``session(sid)`` is a store of its own, started on first use as a copy of this store's initial +state, and the session ``default``, or none, is this store itself. ``reset`` drops every session; a session started +before a later ``load`` keeps the state it was started with.""" from __future__ import annotations import base64 +import contextvars import copy import json +import re from pathlib import Path from typing import Any, Iterable from urllib.parse import unquote, urlparse @@ -13,6 +21,17 @@ from ..types import DataPart, FilePart, Part, TextPart CURRENT_USER = "current_user" +DEFAULT_SID = "default" +SID_MAX = 128 +_NOT_SID = re.compile(r"[^A-Za-z0-9_-]") +active_sid: contextvars.ContextVar[str | None] = contextvars.ContextVar("agentenv_website_sid", default=None) + + +def session_id(value: str | None) -> str | None: + """The session id ``value`` names, as CUA-Gym sites read ``sid``: its letters, digits, ``_`` and ``-``, at most + ``SID_MAX`` of them; None, the default session, for one that is empty or ``default``.""" + sid = _NOT_SID.sub("", value or "")[:SID_MAX] + return None if sid in ("", DEFAULT_SID) else sid class Collections: @@ -21,12 +40,33 @@ class Collections: def __init__(self, data: dict | None = None) -> None: self._collections: dict[str, list[dict]] = {} self.current_user: str | None = None + self.initial: dict = {} + self._sessions: dict[str, Collections] = {} if data: self.load(data) + self.mark_initial() def __getitem__(self, name: str) -> list[dict]: return self._collections.setdefault(name, []) + def session(self, sid: str | None) -> Collections: + """The store of session ``sid``, started as a copy of this one's initial state on first use; this store + itself for the default session.""" + sid = session_id(sid) + if sid is None: + return self + if sid not in self._sessions: + self._sessions[sid] = Collections(self.initial) + return self._sessions[sid] + + def sessions(self) -> list[str]: + """The ids of the sessions started since the store was last reset, the default one aside.""" + return list(self._sessions) + + def changed_sessions(self) -> list[str]: + """The ids of the sessions, the default one aside, whose state differs from the one they started with.""" + return [sid for sid, store in self._sessions.items() if store.diff()] + def names(self) -> list[str]: return list(self._collections) @@ -70,11 +110,13 @@ def load(self, data: dict) -> None: def load_parts(self, parts: Iterable[Part]) -> None: for part in parts: - self.load(_part_data(part)) + self.load(part_data(part)) def reset(self) -> None: self._collections.clear() self.current_user = None + self.initial = {} + self._sessions.clear() def export(self) -> dict: data: dict = copy.deepcopy(self._collections) @@ -82,8 +124,49 @@ def export(self) -> dict: data[CURRENT_USER] = self.current_user return data + def mark_initial(self) -> None: + self.initial = self.export() + + def diff(self) -> dict: + return state_diff(self.initial, self.export()) + + def state(self) -> dict: + return verifier_state(self.initial, self.export()) + + +def state_diff(initial: dict, current: dict, key: Any = None) -> dict: + """Each collection that changed from ``initial`` to ``current``, as its ``added``, ``removed`` and ``changed`` + objects, matched by ``key(name, doc)``, their ``id`` by default; a changed one as its ``id``, ``before`` and + ``after``.""" + key = key or (lambda name, doc: _key(doc)) + changes: dict = {} + for name in dict.fromkeys([*initial, *current]): + if name == CURRENT_USER: + continue + before = {key(name, d): d for d in initial.get(name, [])} + after = {key(name, d): d for d in current.get(name, [])} + entry = { + "added": [d for k, d in after.items() if k not in before], + "removed": [d for k, d in before.items() if k not in after], + "changed": [{"id": d.get("id"), "before": before[k], "after": d} + for k, d in after.items() if k in before and before[k] != d], + } + if any(entry.values()): + changes[name] = entry + return changes + + +def verifier_state(initial: dict, current: dict, key: Any = None) -> dict: + """What ``/go`` answers: ``{initial_state, current_state, state_diff}``.""" + return {"initial_state": copy.deepcopy(initial), "current_state": current, + "state_diff": state_diff(initial, current, key)} + + +def _key(doc: dict) -> str: + return json.dumps(doc.get("id")) if doc.get("id") is not None else json.dumps(doc, sort_keys=True) + -def _part_data(part: Part) -> dict: +def part_data(part: Part) -> dict: """A part's JSON: a data part's data, a text part's text, or a file part's bytes or ``file://`` file.""" if isinstance(part, DataPart): return part.data diff --git a/packages/agentenv-protocol/tests/test_website_conformance.py b/packages/agentenv-protocol/tests/test_website_conformance.py index 281561bd0..af03260b3 100644 --- a/packages/agentenv-protocol/tests/test_website_conformance.py +++ b/packages/agentenv-protocol/tests/test_website_conformance.py @@ -6,6 +6,7 @@ import datetime from pathlib import Path +import httpx import pytest from starlette.applications import Starlette from starlette.requests import Request @@ -17,7 +18,7 @@ from agentenv_protocol.website import conformance from agentenv_protocol.website.clock import UNIVERSE_NOW_HEADER, iso -CHECKS = ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"] +CHECKS = ["card", "any_host", "health", "universe_now", "reserved_paths", "data_plane", "clock", "forwarded", "cookies"] def statuses(results: list[conformance.Result]) -> dict[str, str]: @@ -36,6 +37,7 @@ def notes(self) -> list: env = Notes() env.store.load({"current_user": "ann", "notes": [{"id": 1, "text": "hello"}]}) + env.store.mark_initial() return env @@ -56,10 +58,12 @@ def _handwritten(*, leak_host: bool = False, domain_cookie: bool = False, clock: clock_ops_first: tuple[str, ...] = (), leak_redirect: bool = False, stamp_rpc: bool = True, reset_keeps: bool = False, get_fails_after_reset: bool = False, rpc_reply: tuple[int, dict] | None = None, - ready_answer: object = None, ready_status: int = 200, rpc_path: str | None = None) -> Starlette: + ready_answer: object = None, ready_status: int = 200, rpc_path: str | None = None, + state_path: str | None = None, changed: bool = False) -> Starlette: """A server written without the SDK: the contract by hand, optionally broken. Its state is its ``state.data``; with ``ready_answer`` its card advertises ``website/v1``'s ``ready``, which answers it with ``ready_status``. With ``rpc_path`` its card's - ``url`` names its data plane there, not at ``/agentenv``.""" + ``url`` names its data plane there, not at ``/agentenv``, and with ``state_path`` its ``params.state.path`` names + where it serves its state, which shows changes since the initial state when ``changed``.""" state = {"items": ["a"], "url": None, "at": None, "reset": False} def now() -> datetime.datetime: @@ -94,6 +98,8 @@ async def card(request: Request) -> Response: "routes": [{"method": "GET", "path": "/items", "name": "items"}]}}] if ready_answer is not None: extensions[0]["params"]["methods"] = {"ready": {"method": "GET", "endpoint": "/ready"}} + if state_path is not None: + extensions[0]["params"]["state"] = {"path": state_path} if clock: extensions.append({"uri": "urn:agentenv:clock/v1", "params": { "endpoint": "/agentenv/ext/sync_time", "methods": { @@ -103,6 +109,9 @@ async def card(request: Request) -> Response: "capabilities": {"extensions": extensions, "operations": ["data/reset", "data/add", "data/get"]}})) + async def site_state(request: Request) -> Response: + return JSONResponse({"state_diff": {"items": {"added": ["b"]}} if changed else {}}) + async def rpc(request: Request) -> Response: body = await request.json() method = body["method"] @@ -132,10 +141,15 @@ async def sync_time(request: Request) -> Response: state["at"] = datetime.datetime.fromisoformat(virtual.replace("Z", "+00:00")) return stamp(JSONResponse({"gateway_virtual_time": virtual})) + async def unrouted(request: Request, exc: Exception) -> Response: + return stamp(JSONResponse({"error": "not found"}, status_code=getattr(exc, "status_code", 404))) + app = Starlette(routes=[Route("/", page), Route("/healthz", health), Route("/ready", ready), Route("/items", items), Route("/items/", items_slash), Route("/.well-known/agent-env.json", card), Route(rpc_path or "/agentenv", rpc, methods=["POST"]), - Route("/agentenv/ext/sync_time", sync_time, methods=["POST"])]) + Route("/agentenv/ext/sync_time", sync_time, methods=["POST"]), + *([Route(state_path, site_state)] if state_path else [])], + exception_handlers={404: unrouted, 405: unrouted}) app.state.data = state return app @@ -181,7 +195,7 @@ def test_an_answer_to_ready_that_isnt_a_website_readiness_fails_health_saying_wh def test_a_server_breaking_the_contract_fails_those_checks() -> None: results = statuses(conformance.check("http://testserver", client=TestClient(_handwritten(leak_host=True, domain_cookie=True, clock=False)))) - assert results["forwarded"] == results["cookies"] == results["clock"] == "failed" + assert results["forwarded"] == results["cookies"] == results["clock"] == "warning" assert results["card"] == results["health"] == results["data_plane"] == "passed" @@ -190,13 +204,13 @@ def test_the_forwarded_check_follows_no_redirect_and_reads_its_location(sdk_site assert forwarded.status == "passed" and "/api/notes/" in forwarded.detail leaked = {r.name: r for r in conformance.check("http://testserver", client=TestClient(_handwritten(leak_redirect=True)))}["forwarded"] - assert leaked.status == "failed" and "Location of /items/" in leaked.detail + assert leaked.status == "warning" and "Location of /items/" in leaked.detail def test_universe_now_is_checked_on_the_answers_of_every_check() -> None: result = {r.name: r for r in conformance.check("http://testserver", client=TestClient(_handwritten(stamp_rpc=False)))}["universe_now"] - assert result.status == "failed" and "/agentenv" in result.detail + assert result.status == "warning" and "/agentenv" in result.detail def test_a_reset_that_clears_nothing_fails_the_data_plane_and_leaves_the_data() -> None: @@ -220,14 +234,28 @@ def test_a_data_request_without_a_2xx_json_rpc_result_fails_the_data_plane(reply assert result.status == "failed" and "no JSON-RPC result" in result.detail -def test_an_sdk_sites_data_round_trips_and_is_left_as_the_check_found_it_changed_or_not( +def test_an_sdk_site_keeps_its_initial_state_and_one_changed_since_skips_the_round_trip( + notes: WebsiteEnvironment, sdk_site: TestClient) -> None: + before = sdk_site.get("/go").json() + conformance.check("http://testserver", client=sdk_site) + assert sdk_site.get("/go").json() == before + notes.store.insert("notes", {"text": "new"}) + changed = sdk_site.get("/go").json() + assert changed["state_diff"] + result = {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["data_plane"] + assert result.status == "skipped" and "website/v1 state shows changes" in result.detail + assert sdk_site.get("/go").json() == changed + + +def test_a_named_session_with_changes_skips_the_round_trip_and_keeps_its_state( notes: WebsiteEnvironment, sdk_site: TestClient) -> None: - for _ in range(2): - before = notes.store.export() - result = {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["data_plane"] - assert result.status == "passed", result - assert notes.store.export() == before - notes.store.insert("notes", {"text": "new"}) + sdk_site.get("/api/notes", params={"sid": "task-1"}) + assert {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["data_plane"].status == "passed" + notes.store.session("task-2")["notes"].append({"id": 2, "text": "draft"}) + result = {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["data_plane"] + assert result.status == "skipped" and "named sessions" in result.detail + assert sdk_site.get("/go", params={"sid": "task-2"}).json()["state_diff"]["notes"]["added"] == [ + {"id": 2, "text": "draft"}] def test_the_data_plane_is_reached_at_the_cards_url() -> None: @@ -243,22 +271,82 @@ def test_the_clock_check_calls_sync_time_whichever_operation_the_card_lists_firs assert results["clock"] == "passed", results -def test_a_server_with_no_card_health_or_header_fails_them() -> None: +@pytest.mark.parametrize(("changed", "status"), [(True, "skipped"), (False, "passed")]) +def test_a_card_without_state_is_read_at_the_state_path_it_names(changed: bool, status: str) -> None: + app = _handwritten(state_path="/grading/state", changed=changed) + result = {r.name: r for r in conformance.check("http://testserver", client=TestClient(app))}["data_plane"] + assert result.status == status, result + assert ("/grading/state shows changes" in result.detail) is changed + + +def test_a_server_with_no_card_health_or_header_fails_the_contract_and_warns_of_the_rest() -> None: bare = Starlette(routes=[Route("/", lambda request: JSONResponse({"not": "html"}))]) - results = statuses(conformance.check("http://testserver", client=TestClient(bare))) - assert results["card"] == results["any_host"] == results["health"] == results["universe_now"] == "failed" - assert results["data_plane"] == results["clock"] == "failed" - assert results["cookies"] == "skipped" + results = conformance.check("http://testserver", client=TestClient(bare)) + assert statuses(results) == {"card": "warning", "any_host": "failed", "health": "failed", "universe_now": "warning", + "reserved_paths": "passed", "data_plane": "skipped", "clock": "warning", + "forwarded": "passed", "cookies": "skipped"} + assert [r.name for r in results if r.required] == ["any_host", "health", "reserved_paths", "data_plane"] + + +def test_a_bare_server_meeting_only_the_contract_passes_with_warnings() -> None: + async def page(request: Request) -> Response: + return HTMLResponse("hi") + + bare = Starlette(routes=[Route("/", page), Route("/api/health", lambda request: JSONResponse({"ok": True}))]) + results = conformance.check("http://testserver", client=TestClient(bare)) + assert not any(r.fails() for r in results) and any(r.fails(strict=True) for r in results) + assert {r.name for r in results if r.status == "warning"} == {"card", "universe_now", "clock"} + + +def test_a_spa_fallback_that_serves_the_reserved_paths_fails() -> None: + async def page(request: Request) -> Response: + return HTMLResponse("app") + + spa = Starlette(routes=[Route("/{path:path}", page)]) + reserved = {r.name: r for r in conformance.check("http://testserver", client=TestClient(spa))}["reserved_paths"] + assert reserved.status == "failed" and reserved.detail.startswith("/agentenv answered a page") + + +@pytest.mark.parametrize("exact", ["/agentenv", "/.well-known/agent-env.json"]) +def test_a_page_at_a_reserved_path_itself_fails(exact: str) -> None: + async def page(request: Request) -> Response: + return HTMLResponse("app") + + server = Starlette(routes=[Route("/", page), Route(exact, page)]) + reserved = {r.name: r for r in conformance.check("http://testserver", client=TestClient(server))}["reserved_paths"] + assert reserved.status == "failed" and reserved.detail.startswith(f"{exact} answered a page") def test_main_prints_each_check_and_exits_by_outcome(monkeypatch, capsys) -> None: - monkeypatch.setattr(conformance, "check", lambda url, **options: [conformance.Result("card", "passed"), - conformance.Result("clock", "failed", "late")]) - assert conformance.main(["http://site"]) == 1 + results = [conformance.Result("health", "passed"), conformance.Result("clock", "warning", "late", required=False)] + monkeypatch.setattr(conformance, "check", lambda url, **options: results) + assert conformance.main(["http://site"]) == 0 printed = capsys.readouterr().out - assert "PASS card" in printed and "FAIL clock: late" in printed + assert "PASS health (required)" in printed and "WARN clock (recommended): late" in printed + assert "1 passed, 0 failed, 1 warning(s), 0 skipped" in printed + assert conformance.main(["http://site", "--strict"]) == 1 + results.append(conformance.Result("any_host", "failed", "json")) + assert conformance.main(["http://site"]) == 1 + assert "FAIL any_host (required): json" in capsys.readouterr().out def test_main_exits_2_when_the_server_cannot_be_reached(capsys) -> None: assert conformance.main(["http://127.0.0.1:9", "--timeout", "2"]) == 2 assert "couldn't reach" in capsys.readouterr().err + + +def test_the_cookie_check_holds_the_session_path_to_the_cookie_the_card_names(sdk_site: TestClient) -> None: + card = sdk_site.get("/.well-known/agent-env.json").json() + for extension in card["capabilities"]["extensions"]: + if extension["uri"] == "urn:agentenv:website/v1": + extension["params"]["session_cookie"] = "agentenv_session_other" + real = sdk_site.get + + def get(url, **kwargs): + if url.endswith("/.well-known/agent-env.json"): + return httpx.Response(200, json=card, request=httpx.Request("GET", url)) + return real(url, **kwargs) + + sdk_site.get = get + cookies = {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["cookies"] + assert cookies.status == "warning" and "not the card's agentenv_session_other" in cookies.detail diff --git a/packages/agentenv-protocol/tests/test_website_database.py b/packages/agentenv-protocol/tests/test_website_database.py new file mode 100644 index 000000000..1b12d96de --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_database.py @@ -0,0 +1,213 @@ +"""A site of ``@website(models=...)``: its state is a database's, SQLAlchemy models' tables on DATABASE_URL (sqlite +here) or an object's own load/export/reset, which its data plane and /go read and write, and its routes reach +through ``self.db``.""" + +from __future__ import annotations + +import datetime +import sys +import types +from pathlib import Path + +import pytest +from starlette.testclient import TestClient + +from agentenv_protocol.website import ApiContext, WebsiteEnvironment, api, conformance, website + +sqlalchemy = pytest.importorskip("sqlalchemy") +from sqlalchemy import DateTime, ForeignKey, Integer, String, select # noqa: E402 +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column # noqa: E402 + +RPC = "/agentenv" + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String) + + +class Ticket(Base): + __tablename__ = "tickets" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner: Mapped[str] = mapped_column(ForeignKey("users.id")) + title: Mapped[str] = mapped_column(String) + opened_at: Mapped[datetime.datetime] = mapped_column(DateTime) + + +SEED = {"current_user": "ann", "users": [{"id": "ann", "name": "Ann"}], + "tickets": [{"id": 1, "owner": "ann", "title": "printer", "opened_at": "2031-01-02T03:04:05"}]} + + +def rpc(client: TestClient, method: str, params: dict | None = None) -> dict: + body = client.post(RPC, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}).json() + assert "error" not in body, body + return body["result"] + + +@pytest.fixture +def dist(tmp_path: Path) -> Path: + (tmp_path / "index.html").write_text("desk") + return tmp_path + + +@pytest.fixture +def database(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str: + url = f"sqlite:///{tmp_path / 'service.db'}" + monkeypatch.setenv("DATABASE_URL", url) + return url + + +def helpdesk(dist: Path, models: object) -> type[WebsiteEnvironment]: + @website(domains=["desk.example"], static=str(dist), models=models) + class Helpdesk(WebsiteEnvironment): + @api.get("/api/tickets") + def tickets(self, ctx: ApiContext) -> list: + with self.db() as session: + return [{"id": t.id, "title": t.title} for t in session.scalars( + select(Ticket).where(Ticket.owner == ctx.user).order_by(Ticket.id))] + + @api.post("/api/tickets", status=201) + def open_ticket(self, body: dict, ctx: ApiContext) -> dict: + with self.db.begin() as session: + ticket = Ticket(owner=ctx.user, title=body["title"], opened_at=self.now().replace(tzinfo=None)) + session.add(ticket) + session.flush() + return {"id": ticket.id} + + return Helpdesk + + +def test_the_data_plane_loads_exports_and_resets_the_models_tables(dist: Path, database: str) -> None: + env = helpdesk(dist, Base)() + client = TestClient(env.create_app()) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": SEED}]}) + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + assert client.get("/api/session").json()["user"] == "ann" + assert client.get("/api/tickets").json() == [{"id": 1, "title": "printer"}] + rpc(client, "data/reset") + assert rpc(client, "data/get")["parts"][0]["data"] == {"users": [], "tickets": []} + + +def test_rows_that_leave_an_optional_column_out_load_as_given_in_either_order(dist: Path, database: str) -> None: + class Threads(DeclarativeBase): + pass + + class Note(Threads): + __tablename__ = "notes" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + parent_id: Mapped[int | None] = mapped_column(ForeignKey("notes.id"), nullable=True) + + client = TestClient(helpdesk(dist, Threads)().create_app()) + for seed in ([{"id": 1}, {"id": 2, "parent_id": 1}, {"id": 3, "parent_id": 1}, {"id": 4}], + [{"id": 1, "parent_id": None}, {"id": 2}, {"id": 3, "parent_id": 2}]): + rpc(client, "data/reset") + rpc(client, "data/add", {"parts": [{"kind": "data", "data": {"notes": seed}}]}) + assert rpc(client, "data/get")["parts"][0]["data"]["notes"] == [{"parent_id": None, **row} for row in seed] + + +def test_a_row_the_gui_adds_is_the_mcp_servers_and_go_reports_it(dist: Path, database: str) -> None: + env = helpdesk(dist, Base)() + client = TestClient(env.create_app()) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": SEED}]}) + assert client.post("/api/tickets", json={"title": "badge"}).json() == {"id": 2} + server = sqlalchemy.create_engine(database) + with server.connect() as connection: + assert connection.execute(select(Ticket.title).order_by(Ticket.id)).scalars().all() == ["printer", "badge"] + connection.execute(sqlalchemy.update(Ticket).where(Ticket.id == 1).values(title="printer jam")) + connection.commit() + state = client.get("/go", params={"sid": "default"}).json() + assert state["initial_state"] == SEED + diff = state["state_diff"]["tickets"] + assert [t["title"] for t in diff["added"]] == ["badge"] and diff["removed"] == [] + assert diff["changed"][0]["before"]["title"] == "printer" and diff["changed"][0]["after"]["title"] == "printer jam" + assert client.get("/go", params={"sid": "other"}).json() == state + + +def test_a_models_site_passes_the_conformance_check(dist: Path, database: str) -> None: + client = TestClient(helpdesk(dist, Base)().create_app()) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": SEED}]}) + results = conformance.check("http://testserver", client=client) + assert all(r.status == "passed" for r in results), results + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + + +def test_models_may_be_a_metadata_or_a_module_and_refuse_anything_else(dist: Path, database: str) -> None: + module = types.ModuleType("helpdesk_models") + module.Base, module.Ticket = Base, Ticket + for models in (Base.metadata, module): + assert helpdesk(dist, models)()._tables.metadata is Base.metadata + with pytest.raises(TypeError, match="models="): + helpdesk(dist, object()) + with pytest.raises(ValueError, match="data/add"): + website(models=Base, data="seed.json") + + +def test_unknown_tables_and_a_missing_database_url_are_refused(dist: Path, monkeypatch: pytest.MonkeyPatch, + database: str) -> None: + client = TestClient(helpdesk(dist, Base)().create_app()) + body = client.post(RPC, json={"jsonrpc": "2.0", "id": 1, "method": "data/add", + "params": {"parts": [{"kind": "data", "data": {"orders": []}}]}}).json() + assert "error" in body + monkeypatch.delenv("DATABASE_URL") + with pytest.raises(RuntimeError, match="DATABASE_URL"): + helpdesk(dist, Base)().db() + + +def test_a_database_url_whose_driver_isnt_installed_names_it(dist: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "psycopg2", None) + monkeypatch.setitem(sys.modules, "psycopg", None) + monkeypatch.setenv("DATABASE_URL", "postgresql://desk@localhost/service") + with pytest.raises(ImportError, match=r"postgresql:// needs the database driver psycopg, .* carries psycopg"): + helpdesk(dist, Base)().db() + + +def test_an_object_with_its_own_hooks_is_the_sites_data(dist: Path) -> None: + class Rows: + def __init__(self) -> None: + self.rows: list = [] + + def load(self, data: dict) -> None: + self.rows += data["rows"] + + async def export(self) -> dict: + return {"rows": list(self.rows)} + + def reset(self) -> None: + self.rows.clear() + + rows = Rows() + env = helpdesk(dist, rows)() + client = TestClient(env.create_app()) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": {"current_user": "bo", "rows": [{"id": 1}]}}]}) + rows.rows.append({"id": 2}) + assert rpc(client, "data/get")["parts"][0]["data"] == {"current_user": "bo", "rows": [{"id": 1}, {"id": 2}]} + assert client.get("/go").json()["state_diff"] == {"rows": {"added": [{"id": 2}], "removed": [], "changed": []}} + assert env.acting_user() == "bo" + assert env.db is None + + +def test_editing_a_row_a_hook_still_owns_leaves_the_initial_state(dist: Path) -> None: + class Rows: + def __init__(self) -> None: + self.rows: list = [] + + def load(self, data: dict) -> None: + self.rows += data["rows"] + + def export(self) -> dict: + return {"rows": list(self.rows)} + + def reset(self) -> None: + self.rows.clear() + + rows = Rows() + client = TestClient(helpdesk(dist, rows)().create_app()) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": {"rows": [{"id": 1, "status": "open"}]}}]}) + rows.rows[0]["status"] = "closed" + assert client.get("/go").json()["state_diff"] == {"rows": {"added": [], "removed": [], "changed": [ + {"id": 1, "before": {"id": 1, "status": "open"}, "after": {"id": 1, "status": "closed"}}]}} diff --git a/packages/agentenv-protocol/tests/test_website_environment.py b/packages/agentenv-protocol/tests/test_website_environment.py index 05c118591..e2ccfccbb 100644 --- a/packages/agentenv-protocol/tests/test_website_environment.py +++ b/packages/agentenv-protocol/tests/test_website_environment.py @@ -37,6 +37,7 @@ from agentenv_protocol.website.clock import parse_time RPC = "/agentenv" +COOKIE = "agentenv_session_Shop" def rpc(client: TestClient, method: str, params: dict | None = None) -> dict: @@ -238,6 +239,39 @@ def test_a_sites_data_is_read_through_the_data_plane_alone(client: TestClient) - assert "state" not in website_params and "state" not in website_params.get("methods", {}) +def test_each_sid_keeps_a_state_of_its_own_started_from_the_initial_state(client: TestClient, + shop: WebsiteEnvironment) -> None: + client.post("/api/orders", json={"sku": "mug"}) + assert TestClient(shop.asgi).get("/go", params={"sid": "task-7"}).json() == { + "initial_state": SEED, "current_state": SEED, "state_diff": {}, "changed_sessions": []} + page = client.get("/", params={"sid": "task-7"}) + assert page.headers["set-cookie"].startswith("agentenv_sid_Shop=task-7;") + assert "set-cookie" not in client.get("/", params={"sid": "task-7"}).headers + client.post("/api/orders", json={"sku": "pen"}) + client.post("/api/orders", params={"sid": "other"}, json={"sku": "cup"}) + added = {sid: [o.get("sku") for d in [client.get("/go", params={"sid": sid}).json()["state_diff"]] + for o in d.get("orders", {}).get("added", [])] for sid in ("default", "task-7", "other")} + assert added == {"default": ["mug"], "task-7": ["pen"], "other": ["cup"]} + assert client.get("/go").json()["state_diff"]["orders"]["added"][0]["sku"] == "cup" + client.get("/", params={"sid": "default"}) + assert client.get("/go").json()["state_diff"]["orders"]["added"][0]["sku"] == "mug" + assert rpc(client, "data/get")["parts"][0]["data"]["orders"][-1]["sku"] == "mug" + assert sorted(shop._store.sessions()) == ["other", "task-7"] + assert sorted(client.get("/go").json()["changed_sessions"]) == ["other", "task-7"] + rpc(client, "data/reset") + assert shop._store.sessions() == [] and client.get("/go", params={"sid": "task-7"}).json()["current_state"] == {} + + +def test_a_session_id_is_sanitised_as_cua_gym_sites_do() -> None: + from agentenv_protocol.website.store import session_id + + assert [session_id(v) for v in (None, "", "default", "../a b", "x" * 200)] == [None, None, None, "ab", "x" * 128] + store = Collections({"items": [{"id": 1}]}) + store.session("s")["items"].append({"id": 2}) + assert store.session("default") is store and store.export() == {"items": [{"id": 1}]} + assert store.session("s").diff() == {"items": {"added": [{"id": 2}], "removed": [], "changed": []}} + + def test_broad_routes_leave_the_built_in_ones_answering_their_paths(dist: Path) -> None: class Broad(site(dist)): @api.get("/{slug}") @@ -291,7 +325,7 @@ def test_the_acting_user_is_signed_in_until_a_session_says_else(client: TestClie first = client.get("/api/session") assert first.json() == {"user": "alice", "role": "default", "signed_in": True} cookie = first.headers["set-cookie"] - assert cookie.startswith(f"{SESSION_COOKIE}=") and "domain" not in cookie.lower() and "httponly" in cookie.lower() + assert cookie.startswith(f"{COOKIE}=") and "domain" not in cookie.lower() and "httponly" in cookie.lower() client.post("/api/sign-in", json={"user": "bob"}) assert client.get("/api/orders").json() == [{"id": 1, "user": "bob"}] assert client.get("/api/session").json()["user"] == "bob" @@ -299,7 +333,7 @@ def test_the_acting_user_is_signed_in_until_a_session_says_else(client: TestClie assert client.get("/api/session").json()["user"] is None # The jar holds the site's signed-out cookie under its own domain; a second cookie of the name would race it. client.cookies.clear() - client.cookies.set(SESSION_COOKIE, "forged.signature") + client.cookies.set(COOKIE, "forged.signature") assert client.get("/api/session").json()["user"] == "alice" @@ -317,11 +351,65 @@ def test_the_session_operation_mints_cookies_for_a_user_or_signed_out(shop: Webs minted = client.post("/agentenv/ext/website/session", json={"user": "bob"}).json() assert minted["user"] == "bob" and minted["expires_at"].endswith("Z") (cookie,) = minted["cookies"] - assert cookie["name"] == SESSION_COOKIE and "domain" not in cookie and cookie["path"] == "/" - browser = TestClient(shop.asgi, cookies={SESSION_COOKIE: cookie["value"]}) + assert cookie["name"] == COOKIE and "domain" not in cookie and cookie["path"] == "/" + browser = TestClient(shop.asgi, cookies={COOKIE: cookie["value"]}) assert browser.get("/api/session").json()["user"] == "bob" signed_out = client.post("/agentenv/ext/website/session", json={}).json()["cookies"][0]["value"] - assert TestClient(shop.asgi, cookies={SESSION_COOKIE: signed_out}).get("/api/session").json()["user"] is None + assert TestClient(shop.asgi, cookies={COOKIE: signed_out}).get("/api/session").json()["user"] is None + + +def test_each_site_keeps_its_own_session_cookie_and_reads_the_legacy_one(dist: Path, monkeypatch) -> None: + monkeypatch.setenv("ENVIRONMENT_NAME", "shop a/1") + first = site(dist)() + monkeypatch.setenv("ENVIRONMENT_NAME", "shop-b") + second = site(dist)() + for env in (first, second): + env.store.load(SEED) + named = "agentenv_session_shop_a_1~030f5785d67a" + assert (first.session_cookie, second.session_cookie) == (named, "agentenv_session_shop-b") + browser = TestClient(first.create_app()) + browser.post("/api/sign-in", json={"user": "bob"}) + other = TestClient(second.create_app(), cookies=browser.cookies) + other.post("/api/sign-in", json={"user": "alice"}) + assert set(other.cookies.keys()) == {named, "agentenv_session_shop-b"} + assert TestClient(first.asgi, cookies=other.cookies).get("/api/session").json()["user"] == "bob" + card = browser.get("/.well-known/agent-env.json").json() + assert _extension(card, WEBSITE_EXTENSION_URI)["params"]["session_cookie"] == named + legacy = first._session_signer().encode("bob")[0] + assert TestClient(first.asgi, cookies={SESSION_COOKIE: legacy}).get("/api/orders").json() == [{"id": 1, "user": "bob"}] + + +def test_no_site_s_session_choice_is_another_site_s_sign_in(dist: Path, monkeypatch) -> None: + monkeypatch.setenv("ENVIRONMENT_NAME", "notes") + notes = site(dist)() + monkeypatch.setenv("ENVIRONMENT_NAME", "notes_sid") + notes_sid = site(dist)() + browser = TestClient(notes.create_app()) + browser.post("/api/orders", params={"sid": "task-1"}, json={"sku": "mug"}) + other = TestClient(notes_sid.create_app(), cookies=browser.cookies) + other.post("/api/sign-in", json={"user": "bob"}) + back = TestClient(notes.asgi, cookies=other.cookies) + assert [o["sku"] for o in back.get("/go").json()["current_state"]["orders"]] == ["mug"] + assert TestClient(notes_sid.asgi, cookies=back.cookies).get("/api/session").json()["user"] == "bob" + + +def test_sites_whose_names_spell_alike_keep_their_own_cookies(dist: Path, monkeypatch) -> None: + apps = [] + for name in ("shop a/1", "shop_a_1"): + monkeypatch.setenv("ENVIRONMENT_NAME", name) + env = site(dist)() + env.store.load(SEED) + apps.append(env.create_app()) + browser = TestClient(apps[0]) + browser.post("/api/sign-in", json={"user": "bob"}) + browser.get("/", params={"sid": "task-1"}) + other = TestClient(apps[1], cookies=browser.cookies) + other.post("/api/sign-in", json={"user": "alice"}) + other.get("/", params={"sid": "task-2"}) + assert len(other.cookies.keys()) == 4 + back = TestClient(apps[0], cookies=other.cookies) + assert back.get("/api/session").json()["user"] == "bob" + assert back.cookies.get("agentenv_sid_shop_a_1~030f5785d67a") == "task-1" def test_every_answer_carries_the_universe_time(client: TestClient) -> None: @@ -335,13 +423,16 @@ def test_every_answer_carries_the_universe_time(client: TestClient) -> None: class _Clock: """A gateway-shaped /clock/time whose time a test sets.""" - def __init__(self, virtual: str) -> None: - self.virtual = virtual + def __init__(self, virtual: str, rate: object = None) -> None: + self.virtual, self.rate = virtual, rate outer = self class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: - body = json.dumps({"virtual_time": outer.virtual}).encode() + answer = {"virtual_time": outer.virtual} + if outer.rate is not None: + answer["virtual_seconds_per_real_second"] = outer.rate + body = json.dumps(answer).encode() self.send_response(200) self.send_header("Content-Length", str(len(body))) self.end_headers() @@ -544,8 +635,8 @@ async def _clock_rates(monkeypatch) -> None: real, virtual = [0.0], [datetime.datetime(2031, 1, 1, tzinfo=datetime.timezone.utc)] - async def read(url: str) -> datetime.datetime: - return virtual[0] + async def read(url: str) -> tuple[datetime.datetime, None]: + return virtual[0], None monkeypatch.setattr(clock_module.time, "monotonic", lambda: real[0]) monkeypatch.setattr(clock_module, "_read", read) @@ -573,6 +664,39 @@ def tick(seconds: float, virtual_seconds: float) -> None: assert clock._rate == 100.0 and clock.now() == virtual[0] +def test_the_clock_extrapolates_at_the_rate_the_clock_answers_with(monkeypatch) -> None: + asyncio.run(_answered_rates(monkeypatch)) + + +async def _answered_rates(monkeypatch) -> None: + from agentenv_protocol.website import UniverseClock + + gateway = _Clock("2031-01-01T00:00:00Z", rate=60) + try: + assert await clock_module._read(gateway.url) == (parse_time("2031-01-01T00:00:00Z"), 60.0) + for unusable in ("fast", True, -1, float("inf")): + gateway.rate = unusable + assert (await clock_module._read(gateway.url))[1] is None + finally: + gateway.close() + real, answers = [0.0], [(parse_time("2031-01-01T00:00:00Z"), 60.0)] + + async def read(url: str) -> tuple[datetime.datetime, float | None]: + return answers[0] + + monkeypatch.setattr(clock_module.time, "monotonic", lambda: real[0]) + monkeypatch.setattr(clock_module, "_read", read) + clock = UniverseClock() + await clock.sync("http://gateway/clock/time") + real[0] += 0.5 + assert clock._rate == 60.0 and clock.now() == answers[0][0] + datetime.timedelta(seconds=30) + real[0] += 0.5 + answers[0] = (parse_time("2031-02-01T00:00:00Z"), 0.0) + await clock.refresh() + real[0] += 10 + assert clock._rate == 0.0 and clock.now() == answers[0][0] + + def test_a_refresh_of_the_old_clock_never_overwrites_a_sync_to_a_new_one(monkeypatch) -> None: asyncio.run(_refresh_during_sync(monkeypatch)) @@ -583,12 +707,12 @@ async def _refresh_during_sync(monkeypatch) -> None: old, new = (datetime.datetime(year, 1, 1, tzinfo=datetime.timezone.utc) for year in (2031, 2040)) release, held = asyncio.Event(), [] - async def read(url: str) -> datetime.datetime: + async def read(url: str) -> tuple[datetime.datetime, None]: if url == "http://old/clock/time": if held: await release.wait() - return old - return new + return old, None + return new, None monkeypatch.setattr(clock_module, "_read", read) monkeypatch.setattr(clock_module, "REFRESH_S", 0.0) diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index 4d7110657..93eb51cc1 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -18,7 +18,7 @@ from starlette.responses import PlainTextResponse from starlette.testclient import TestClient -from agentenv_protocol.website import (ROLE_KEY_ENV, ROLE_KEY_HEADER, SESSION_COOKIE, UNIVERSE_NOW_HEADER, WebsiteEnvironment, +from agentenv_protocol.website import (ROLE_KEY_ENV, ROLE_KEY_HEADER, UNIVERSE_NOW_HEADER, WebsiteEnvironment, conformance, website) NOTES_DIR = Path(__file__).resolve().parents[1] / "examples" / "website" / "notes" @@ -104,7 +104,8 @@ def gateway_clock(): def test_the_example_passes_every_conformance_check(client: TestClient) -> None: results = conformance.check("http://testserver", client=client) assert {r.name: r.status for r in results} == dict.fromkeys( - ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"], "passed"), results + ["card", "any_host", "health", "universe_now", "reserved_paths", "data_plane", "clock", "forwarded", + "cookies"], "passed"), results def test_the_card_declares_the_site_its_health_path_and_its_routes_by_name(client: TestClient) -> None: @@ -121,7 +122,7 @@ def test_the_card_declares_the_site_its_health_path_and_its_routes_by_name(clien def test_a_browser_is_signed_in_as_the_acting_user_and_can_sign_in_as_another(client: TestClient) -> None: session = client.get("/api/session") assert session.json() == {"user": "ann", "role": "default", "signed_in": True} - assert SESSION_COOKIE in session.cookies + assert "agentenv_session_notes" in session.cookies assert [n["title"] for n in client.get("/api/notes").json()] == ["Buy milk", "Book dentist"] assert client.post("/api/sign-in", json={"user": "ben"}).json() == {"user": "ben"} assert client.get("/api/session").json()["user"] == "ben" @@ -195,6 +196,15 @@ def test_the_seed_loads_exports_and_resets_through_the_data_plane(client: TestCl assert next(note for note in toggled if note["id"] == 1)["done"] is True + +def test_a_page_opened_at_a_sid_keeps_that_sessions_notes_apart(client: TestClient, env: WebsiteEnvironment) -> None: + other = TestClient(env.asgi) + other.get("/", params={"sid": "task-1"}) + created = other.post("/api/notes", json={"title": "only in task-1"}).json() + assert client.get("/go", params={"sid": "task-1"}).json()["state_diff"]["notes"]["added"] == [created] + assert client.get("/go", params={"sid": "default"}).json()["state_diff"] == {} + assert all(note["title"] != "only in task-1" for note in client.get("/api/notes").json()) + GATEWAY_PREFIX = "/website/notes" diff --git a/packages/agentenv-protocol/tests/test_website_state.py b/packages/agentenv-protocol/tests/test_website_state.py new file mode 100644 index 000000000..b4e4ec882 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_state.py @@ -0,0 +1,132 @@ +"""What a website env's task state is: WebsiteState, the SDK's website/v1 state, and website_state and website_sites +reading it through cards.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest.mock import patch + +import httpx +import pytest +from pydantic import ValidationError +from starlette.testclient import TestClient + +from agentenv_protocol import DataPart +from agentenv_protocol.agent_env_environment import get_data +from agentenv_protocol.client import find_extension_method +from agentenv_protocol.website import ( + STATE_PATH, + WEBSITE_EXTENSION_URI, + WEBSITE_STATE_OP, + WebsiteEnvironment, + WebsiteState, + api, + website, + website_sites, + website_state, +) + +_GW = "http://gw" + + +def _site_card(name: str, *, state: bool = True) -> dict: + methods = {"state": {"method": "GET", "endpoint": f"/svc/{name}/agentenv/ext/website/state"}} if state else {} + return {"name": name, "url": f"/svc/{name}/agentenv", "capabilities": {"extensions": [ + {"uri": WEBSITE_EXTENSION_URI, "params": {"methods": methods}}]}} + + +def _routed(handler): + real = httpx.AsyncClient + return patch.object(httpx, "AsyncClient", lambda *a, **k: real(transport=httpx.MockTransport(handler))) + + +@pytest.fixture +def shop(tmp_path: Path) -> WebsiteEnvironment: + (tmp_path / "index.html").write_text("") + + @website(domains=["shop.test"], static=str(tmp_path)) + class Shop(WebsiteEnvironment): + @api.post("/api/orders") + def place(self, order: dict) -> dict: + return self.store.insert("orders", order) + + env = Shop() + env.store.load({"orders": [{"id": 1, "sku": "pen"}]}) + env.store.mark_initial() + return env + + +def test_a_website_state_ignores_fields_it_doesnt_know_and_refuses_a_state_that_isnt_an_object() -> None: + assert WebsiteState.model_validate({"current_state": {"a": 1}, "seen_at": "now"}).model_dump() == { + "initial_state": None, "current_state": {"a": 1}, "state_diff": None} + with pytest.raises(ValidationError): + WebsiteState.model_validate({"current_state": [1]}) + + +def test_an_sdk_site_advertises_state_and_answers_its_stores_state_there_and_at_the_path_it_names( + shop: WebsiteEnvironment) -> None: + client = TestClient(shop.create_app()) + card = client.get("/.well-known/agent-env.json").json() + op = find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_STATE_OP) + params = next(e for e in card["capabilities"]["extensions"] if e["uri"] == WEBSITE_EXTENSION_URI)["params"] + assert (op["method"], op["endpoint"], params["state"]) == ("GET", "/agentenv/ext/website/state", {"path": STATE_PATH}) + client.post("/api/orders", json={"sku": "mug"}) + answer = client.get(op["endpoint"], params={"sid": "default"}).json() + assert answer == client.get(STATE_PATH).json() == {**shop.store.state(), "changed_sessions": []} + assert answer["state_diff"]["orders"]["added"][0]["sku"] == "mug" + + +def test_an_sdk_sites_state_op_answers_the_session_its_sid_names(shop: WebsiteEnvironment) -> None: + client = TestClient(shop.create_app()) + client.post("/api/orders", params={"sid": "task-1"}, json={"sku": "mug"}) + named = client.get("/agentenv/ext/website/state", params={"sid": "task-1"}).json() + assert named == client.get(STATE_PATH, params={"sid": "task-1"}).json() + assert named["state_diff"]["orders"]["added"][0]["sku"] == "mug" and named["changed_sessions"] == ["task-1"] + default = client.get("/agentenv/ext/website/state").json() + assert WebsiteState.model_validate(default).state_diff == shop.store.state()["state_diff"] + assert default["current_state"]["orders"] == [{"id": 1, "sku": "pen"}] + + +def test_a_site_with_data_hooks_of_its_own_advertises_no_state(tmp_path: Path) -> None: + @website(domains=["own.test"]) + class Own(WebsiteEnvironment): + @get_data + def export(self) -> list: + return [DataPart(data={"mine": True})] + + client = TestClient(Own().create_app()) + card = client.get("/.well-known/agent-env.json").json() + params = next(e for e in card["capabilities"]["extensions"] if e["uri"] == WEBSITE_EXTENSION_URI)["params"] + assert find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_STATE_OP) is None and "state" not in params + assert client.get("/agentenv/ext/website/state").status_code == 404 + + +def test_website_state_asks_the_cards_state_of_the_session_with_the_headers_given() -> None: + seen = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append((request.url.path, dict(request.url.params), request.headers.get("authorization"))) + return httpx.Response(200, json={"initial_state": {}, "current_state": {"n": 1}, "state_diff": {"n": {}}}) + + with _routed(handler): + state = asyncio.run(website_state(_GW, _site_card("mail"), sid="s1", headers={"Authorization": "Bearer t"})) + assert asyncio.run(website_state(_GW, _site_card("mail", state=False))) is None + assert state == WebsiteState(initial_state={}, current_state={"n": 1}, state_diff={"n": {}}) + assert seen == [("/svc/mail/agentenv/ext/website/state", {"sid": "s1"}, "Bearer t")] + + +def test_an_answer_that_isnt_a_website_state_is_refused() -> None: + with _routed(lambda request: httpx.Response(200, json={"current_state": "full"})): + with pytest.raises(ValueError): + asyncio.run(website_state(_GW, _site_card("mail"))) + + +def test_website_sites_are_a_single_sites_own_card_or_a_composed_cards_website_children() -> None: + mail, calendar = _site_card("mail"), _site_card("calendar", state=False) + mcp = {"name": "mail", "url": "/svc/mcp-mail/agentenv", "capabilities": {"extensions": []}} + composed = {"name": "gw", "children_environments": [mail, mcp, calendar, "junk"]} + assert [(site.name, site.url) for site in website_sites(composed)] == [ + ("mail", "/svc/mail/agentenv"), ("calendar", "/svc/calendar/agentenv")] + assert [site.card for site in website_sites(mail)] == [mail] + assert website_sites({"name": "plain", "children_environments": [mcp]}) == [] diff --git a/src/agent_env/env/gateway/clock.py b/src/agent_env/env/gateway/clock.py index cceb14364..dda1dd7eb 100644 --- a/src/agent_env/env/gateway/clock.py +++ b/src/agent_env/env/gateway/clock.py @@ -217,11 +217,13 @@ def at_elapsed(self, elapsed: float) -> datetime | None: return _advance(self._t0, elapsed - self._lived + self._ahead) def read(self) -> dict: - """The frozen consumer contract: {"virtual_time": ""} — pure read, never advances.""" - current = self.now() - if current is None: - raise ClockError("clock is not armed") - return {"virtual_time": _iso(current)} + """The frozen consumer contract: {"virtual_time": ""} — pure read, never advances — plus the rate it + runs at, "virtual_seconds_per_real_second", so a reader extrapolates between reads without guessing it.""" + with self._lock: + if not self._armed or self._t0 is None: + raise ClockError("clock is not armed") + current = _advance(self._t0, (self._now() - self._anchor) * self._rate + self._ahead) + return {"virtual_time": _iso(current), "virtual_seconds_per_real_second": self._rate} def state(self) -> dict: with self._lock: diff --git a/tst/unit/env/gateway/clock_advance_test.py b/tst/unit/env/gateway/clock_advance_test.py index 0a76031f9..53bdbfbab 100644 --- a/tst/unit/env/gateway/clock_advance_test.py +++ b/tst/unit/env/gateway/clock_advance_test.py @@ -165,3 +165,22 @@ def test_route_refuses_a_backwards_advance(gateway): gateway._clock.set_time("2026-01-04T08:00:00Z", rate=0) resp = _put(gateway, "/clock/advance", {"virtual_time": T0}) assert resp.status_code == 400 and "only moves forward" in resp.json()["error"] + + +def _get(gw: Gateway, path: str) -> httpx.Response: + async def _do() -> httpx.Response: + transport = httpx.ASGITransport(app=gw._mcp.streamable_http_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://gateway") as client: + return await client.get(path) + + return asyncio.run(_do()) + + +def test_clock_time_answers_the_rate_beside_the_time(gateway): + assert _get(gateway, "/clock/time").status_code == 404 + gateway._clock.set_time(T0, rate=60) + gateway._clock._now.tick(2) + assert _get(gateway, "/clock/time").json() == {"virtual_time": "2026-01-01T00:02:00Z", + "virtual_seconds_per_real_second": 60.0} + gateway._clock.advance("2026-01-02T00:00:00Z") + assert _get(gateway, "/clock/time").json()["virtual_seconds_per_real_second"] == 60.0 diff --git a/uv.lock b/uv.lock index f0342e1ea..241ad385f 100644 --- a/uv.lock +++ b/uv.lock @@ -298,6 +298,10 @@ computer = [ { name = "pillow" }, { name = "uvicorn" }, ] +db = [ + { name = "psycopg", extra = ["binary"] }, + { name = "sqlalchemy" }, +] dev = [ { name = "a2a-sdk", extra = ["http-server"] }, { name = "av", version = "18.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, @@ -309,6 +313,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "python-multipart" }, { name = "regex" }, + { name = "sqlalchemy" }, ] [package.metadata] @@ -325,6 +330,7 @@ requires-dist = [ { name = "mcp", marker = "extra == 'dev'", specifier = ">=1.25,<2" }, { name = "pillow", marker = "extra == 'computer'", specifier = ">=10.4" }, { name = "pillow", marker = "extra == 'dev'", specifier = ">=10.4" }, + { name = "psycopg", extras = ["binary"], marker = "extra == 'db'", specifier = ">=3.1" }, { name = "pydantic", specifier = ">=2.11" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, @@ -332,12 +338,14 @@ requires-dist = [ { name = "python-multipart", marker = "extra == 'dev'", specifier = ">=0.0.13" }, { name = "regex", marker = "extra == 'agent'", specifier = ">=2023.12.25" }, { name = "regex", marker = "extra == 'dev'", specifier = ">=2023.12.25" }, + { name = "sqlalchemy", marker = "extra == 'db'", specifier = ">=2.1" }, + { name = "sqlalchemy", marker = "extra == 'dev'", specifier = ">=2.1" }, { name = "starlette", specifier = ">=0.37" }, { name = "uvicorn", marker = "extra == 'agent'", specifier = ">=0.30" }, { name = "uvicorn", marker = "extra == 'app'", specifier = ">=0.30" }, { name = "uvicorn", marker = "extra == 'computer'", specifier = ">=0.30" }, ] -provides-extras = ["agent", "app", "computer", "dev"] +provides-extras = ["agent", "app", "computer", "db", "dev"] [[package]] name = "aiofile" @@ -5004,6 +5012,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/a3/55c9fb649391fddec8d7ba3005162cd76e8cb3b2eea3dc922684aa0013e4/psycopg-3.3.7.tar.gz", hash = "sha256:6574f23fd79bf129ee0616b49f6e358faf7ae1bf3a362d2ad599cf8f9c3d4935", size = 168912, upload-time = "2026-11-03T03:58:07.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/2a/2e05cc01b2817b8baff419823774bb928efd0a36de12da78f19482cfa329/psycopg-3.3.7-py3-none-any.whl", hash = "sha256:d592e88a077e01ff57f22d59f16e828b92f2b2d9f439b6679d582b48d74cf306", size = 216207, upload-time = "2026-11-03T03:51:21.09Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/19/07272d51290a502281fa3f54e76ef95fa72bd05abd913a57f033162d7768/psycopg_binary-3.3.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f9ec0cc3b327f8ed1e2ba40441dd843be9c82b3ac5dfe75eb00ece02a454ec3", size = 4731229, upload-time = "2026-11-03T03:52:28.922Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a3/beb642b331f1611f7f87c75c5b0803bfa779f6ccd4e79e381961484a20b2/psycopg_binary-3.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0734da871182afdefcc9b3fbaf13c70408f47a2de64394208606d8f45c4027ec", size = 4779422, upload-time = "2026-11-03T03:52:34.545Z" }, + { url = "https://files.pythonhosted.org/packages/be/5d/41538a43e0364b8f8fc669056f8f2eeb884a7374c16f65695ee3868113af/psycopg_binary-3.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:05fa93c416af799f8d0db325e4a58ed0df68ef7ff85a48192b616b4e1be7be26", size = 5616589, upload-time = "2026-11-03T03:52:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/5f/87/c290af551e9b71ef048601fe261e7f91212274fa1fb224619d35818aca33/psycopg_binary-3.3.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:652a6029e53635ef3106c726ef3b4c01429a586372dd516fc56e75ab8a2bd934", size = 5330007, upload-time = "2026-11-03T03:52:47.627Z" }, + { url = "https://files.pythonhosted.org/packages/af/e1/efa4ea43b53fcf0f33be16d968851b610fd02c45211306a97fc84446b877/psycopg_binary-3.3.7-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2fa90f30e4a5a1dff00301797ec66b5eba991566291dcc9df6dfb6fd68965e1", size = 6879399, upload-time = "2026-11-03T03:52:57.022Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2e/7d6d734fc4be304b567c47e28c6eca98cdcb30eeb84d8cb352f4bdadb0c0/psycopg_binary-3.3.7-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:496da9a6f4f7fcdb8394b1273f9ffc10deb5a95dd2aa6e85212a732bfd9a2424", size = 5110921, upload-time = "2026-11-03T03:53:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3c/d6e035a73062d3abbb8829bc294ae027eb7f99ca09bc0ddbea1d78627972/psycopg_binary-3.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d7434c3fc858d86eb09217bce226274245957299d22e2c7d8fc7b564409d242e", size = 4647372, upload-time = "2026-11-03T03:53:06.633Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f3/8078373f96894d3f4988a3ef924646bcecddce884dfc8f547a6d66cdb517/psycopg_binary-3.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a07ead8eacaa3b2e130ebf5357035c53fc9bbb623fe5d7c02d83944ffeae5d09", size = 4343403, upload-time = "2026-11-03T03:53:16.037Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4a/236c3be410e3c420249119c0ed7fc0372bd292f35f980efe1ca0825e42fc/psycopg_binary-3.3.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4e960429b237ccff892a489b9348ade8cf780a103fcea403ddf924e0a07ad671", size = 4058720, upload-time = "2026-11-03T03:53:22.364Z" }, + { url = "https://files.pythonhosted.org/packages/8a/78/50b5b85c16e3526c2e001d0faccf9241e2bf7407cd4ad2a16bd353e4a991/psycopg_binary-3.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:34e2cff14aafa3d0649b48cd18c447c163e0a3ec7bbfd2e38314cd721f33add7", size = 4363326, upload-time = "2026-11-03T03:53:26.846Z" }, + { url = "https://files.pythonhosted.org/packages/80/95/559b7910fe35a5f63adc7425bcad46c85ee10880dae8096b7789ea6d75ac/psycopg_binary-3.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:cb6fe750879c781d3d3667b6d0f66318071d0245e60b51ad9a00186708978089", size = 3636509, upload-time = "2026-11-03T03:53:31.315Z" }, + { url = "https://files.pythonhosted.org/packages/bb/da/3a41bfb4ca39f601abaa20f8170545b27247df4d4850ee3647a10091c219/psycopg_binary-3.3.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:edfd6abb5da972cae50984d41d44cc744a6842ee359b0df24b39fb1524b1e654", size = 4710682, upload-time = "2026-11-03T03:53:37.421Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/775f908d6af11f0d658526cdd564dc54f03463c53de245cd7e23544c133b/psycopg_binary-3.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d43d35af50c66fd5b0d2537ef20bddee61b68fbfbd24f8c44a0be6f65a68301", size = 4773036, upload-time = "2026-11-03T03:53:44.926Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7f/40944fa341e752bb79410bd2f6542c71e3103a476ff4e88cc780fd53caac/psycopg_binary-3.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1a831476577db5e62f1aa3942a60c5088f459f2c14c0e2f4639a823d23b5ffb6", size = 5575526, upload-time = "2026-11-03T03:53:51.59Z" }, + { url = "https://files.pythonhosted.org/packages/df/70/0d68867bdb39834ae5a46d5afa1d12053dd12b061659652294fbaa092f71/psycopg_binary-3.3.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df30e4e2fe7b4f4ae401326f8f24fd572d1f08543b97c2daff9bcb623aebb41f", size = 5300375, upload-time = "2026-11-03T03:53:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/bf/04/4ab0135ff483b6bdcdb7fa2b6c74efec93b0d5bb2e0ffc33b099d9cae98c/psycopg_binary-3.3.7-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71601439823d28250c28255b2204376a7d686c2ab1259e5b6e0fce70cd2309d5", size = 6852910, upload-time = "2026-11-03T03:54:03.57Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/0d360077293a95f3c2539e2afe054fee657e1dd5278e00f2007b645f7841/psycopg_binary-3.3.7-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c9e740f8f9fda8ac878d11d590a8b802f9b0ebf7a2c4c1fbbd577b5ceaff6694", size = 5073829, upload-time = "2026-11-03T03:54:09.479Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6d/8e1b4186702b55a54a2e8f8fb7b766484659c3298de58eec754b3c801691/psycopg_binary-3.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e23fb1b141e9f05724f5bc83177427b3c32ca7f814450721614d23c4b2828cf5", size = 4618943, upload-time = "2026-11-03T03:54:17.659Z" }, + { url = "https://files.pythonhosted.org/packages/8d/cc/34bf9da0ce2103c7bab4de0e2716646448355fceab847620b74d67839819/psycopg_binary-3.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d52a488169e52774a9e09d4d0d0082d6f19cb59c58f8308b53bf355ae160a1ac", size = 4302298, upload-time = "2026-11-03T03:54:25.646Z" }, + { url = "https://files.pythonhosted.org/packages/26/38/75b5dca8d42e983d900688dbc9ada57b55ebcc3406cbcea7f1b8c92eac32/psycopg_binary-3.3.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b2cbb774a73d6f12fbc2ab4d2faddd05a043dd623dbd07df59ec42535b00fedf", size = 4020129, upload-time = "2026-11-03T03:54:30.715Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6c/0dfac7abe24a08b98367960535d164544f89eb3eb317e16979db36365dc6/psycopg_binary-3.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:df59dd2a1aa038856952360bbc72599ed118fe14cef558a499476bf56b0ecb45", size = 4334125, upload-time = "2026-11-03T03:54:36.812Z" }, + { url = "https://files.pythonhosted.org/packages/36/60/f412c2accfb92f8382b812f45990965e4392674a1d0d1648fe743a8fe787/psycopg_binary-3.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:7a1fd4e844630393800a86e55312c77dcdfa12dd7baf02da3230c7147a805793", size = 3619065, upload-time = "2026-11-03T03:54:41.265Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ff/936c13638c8b9bd4519d3ad7996e7600b066909156cb1cd6f55b73d2203e/psycopg_binary-3.3.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e0639d06c50c684bb9867fb4c0593b7327525275142c0b620053c55134572773", size = 4715405, upload-time = "2026-11-03T03:54:45.749Z" }, + { url = "https://files.pythonhosted.org/packages/7a/19/98a3d8dd5b61478ce242f017b70d68241d151826409ab3f88066ed2ec327/psycopg_binary-3.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89b508e50ccfe93a440ce9450c33ff78a3e2462dab369e66ee63e725562421cb", size = 4775846, upload-time = "2026-11-03T03:54:51.138Z" }, + { url = "https://files.pythonhosted.org/packages/9d/74/c855489f8be5ee3d8bfd4cee523e036a49b3aa1bfdc6eb5b72c34828fdb2/psycopg_binary-3.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a0aeb1b680b2ee414a0196a9dd94b17edaa75c1c04f7cddadc9aae95d0dd940c", size = 5575585, upload-time = "2026-11-03T03:54:57.816Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c9/860db102775b7fcf1f08fe06b0d845f926fe5ab89599d23007693354331d/psycopg_binary-3.3.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e3cc435fd83ff3fe627370bb8208bbcfd79631be2ac3b49b7a5c7edb528011c9", size = 5301826, upload-time = "2026-11-03T03:55:05.309Z" }, + { url = "https://files.pythonhosted.org/packages/a7/37/5f09fb2d1ad05f439329350097c702c52c1355156c176e3515d12496f723/psycopg_binary-3.3.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b311c834ece61e9be0df48c6078ca8949ef50d43a884f4c05628f77a8b31442", size = 6852614, upload-time = "2026-11-03T03:55:14.88Z" }, + { url = "https://files.pythonhosted.org/packages/6e/62/befa167c2ea5bfdeb65ce0f799aa35d78eb693250b650963ec7cba1fa17d/psycopg_binary-3.3.7-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:15873c72c6db035b7bbe9d443dedc571753c713b7d10223aea6438310c0f468e", size = 5076255, upload-time = "2026-11-03T03:55:23.593Z" }, + { url = "https://files.pythonhosted.org/packages/eb/97/8e622891df044606e0819cd17b589d4b47834eddd3f941848edab207b7e8/psycopg_binary-3.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:191dc5f2056368e0556359be7782a3be201399c7f1dfc3fe0ebffde37446e1f2", size = 4619359, upload-time = "2026-11-03T03:55:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/40/84/857ec7d5ab5e63ec05e98d22d0e8c3b85df5a49cdfb8b3fc84879424040a/psycopg_binary-3.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:21dccda9c04e52240b9d6d934c5b710c198fdd7d0c60a358d78927988bca154e", size = 4301370, upload-time = "2026-11-03T03:55:35.691Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f3/9a21844b395d49dbe7e6fd53c7cddfc7e54766e7d9957c89a8fec6c75c2a/psycopg_binary-3.3.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9ae3e685302569c34bf159927e9667d3afd5dd34e68a92afabe05e2022dafc68", size = 4021521, upload-time = "2026-11-03T03:55:39.78Z" }, + { url = "https://files.pythonhosted.org/packages/08/d7/8eda1d662b7049a6729a6b5f30601561e5204bb7ff3ae15796640afdba53/psycopg_binary-3.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c8ab53d4a7c9f68d4b2cf878d1e56880bb87a1008435a5c3770422ac1e1b4ef", size = 4334750, upload-time = "2026-11-03T03:55:45.384Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/2a1ad37faa52270594e92c483a28084d75a3c696b95ad8f6fc18faf39ead/psycopg_binary-3.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:63fc0a5a226e63db37a3f038f2581ea8b8189112daa86bd8ae52c3897ca105da", size = 3618325, upload-time = "2026-11-03T03:55:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/07/19/a0b79901d532257d28867c3b5345f37a30191670a8998b8fd779f935d053/psycopg_binary-3.3.7-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d72b6bd81535303b3f6726de330c257d8912948d9e0cc1761c41911132633112", size = 4723602, upload-time = "2026-11-03T03:55:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/55/43/a22a87c7a51fd59c90223bd4d4fc604444b8647890af2893c50e823c1bcf/psycopg_binary-3.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c3753802f809d8fcec4fbf633f3c3335909a2f1638a36f9b924edfa2f6e8796", size = 4786213, upload-time = "2026-11-03T03:56:03.022Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2f6fe94326baaedcea29fc06c13b1b8b5dfe81690c422eda09039c531dbb/psycopg_binary-3.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:590c075b8645bdddb332a61ca1be5be52e7ecb57d3a3be63eba5c6a38a60b789", size = 5588332, upload-time = "2026-11-03T03:56:08.561Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/4e60003f3094e71849d29ac174b355fc0b0b9b16eb5799591b05b62a3893/psycopg_binary-3.3.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:20dbb0f8e173a46feb803136446a89d4b04959342f5331e7f3f93b4fe7f2c32e", size = 5310722, upload-time = "2026-11-03T03:56:14.427Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/08edccbdc1e25e99854297bdc93ad0de34b029ff707ff6567b54e0f4421f/psycopg_binary-3.3.7-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4171b7a2d0534baf7ac1792aade1f8aba74ca06357db79915eac265140fc12c8", size = 6866520, upload-time = "2026-11-03T03:56:21.962Z" }, + { url = "https://files.pythonhosted.org/packages/9f/59/b56fe4ed6747241fdd92c4859d17ea3a10bdbca586a1e1baff5bfba09eaf/psycopg_binary-3.3.7-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7965237729bfec12b3682dedf84538bc0936c2c442560dbca8cd66534d62747", size = 5085522, upload-time = "2026-11-03T03:56:30.456Z" }, + { url = "https://files.pythonhosted.org/packages/3c/07/4e7251607b1278aa47894fec895c6d961ff8b5d436be010557ba03647f69/psycopg_binary-3.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:100495111a2de4305c92996708469b5a461b24c28d152ba6bf2fbebe49fe02f2", size = 4633868, upload-time = "2026-11-03T03:56:36.786Z" }, + { url = "https://files.pythonhosted.org/packages/71/3d/b6b09ec08b9235eea343df05b9e5d978fb784c9adea01c4a1649c440436b/psycopg_binary-3.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f2018e449c7d60dd6e66261e7c4cd9a4274baf6d824994bbd55577435c081cea", size = 4319169, upload-time = "2026-11-03T03:56:41.292Z" }, + { url = "https://files.pythonhosted.org/packages/2c/93/ea77791ceaad20970a04d3fbc132138cfb2e6f845fd9604f5705da4b0dc9/psycopg_binary-3.3.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8487a5e112b6a22508e603c78a0dc496372daefed22a9b9d4de4b89e2d4456bc", size = 4032876, upload-time = "2026-11-03T03:56:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8c/1a/56e6bb0fad9d2751cb120a833e6113b860131e26a127f8a0e4088f56994d/psycopg_binary-3.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a58b9a9e7b2d31367f605c53d87d501bee7c52f5c009dd8a2d341e5f2c2b6f8e", size = 4346131, upload-time = "2026-11-03T03:56:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4d/efd82743bef4b5ff120f57f2c3ffb5b5e72f3f66dec1b17ad215c090c084/psycopg_binary-3.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:40a4231674cd06465384ade0fa5886820c6f25b5a26d38b4ce946c391162cb0f", size = 3715677, upload-time = "2026-11-03T03:56:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/5c/69/36a022cd3739bdb2d94003c9d3c220618f8c86b8596c29831d215b490a7b/psycopg_binary-3.3.7-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d8acf1f90217e5a398daaa0370e078675c0b767a3d0422b74fd3ffd83bcd874d", size = 4722833, upload-time = "2026-11-03T03:57:03.208Z" }, + { url = "https://files.pythonhosted.org/packages/88/52/c97e936e4db0522c93ea39ba26b5cb21de178b69cd37f37ce5d77344bad3/psycopg_binary-3.3.7-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3919dc8d37d1ed6a1247753111f3eee5a3b906846b27eb3f08af3c907bf15806", size = 4785089, upload-time = "2026-11-03T03:57:08.978Z" }, + { url = "https://files.pythonhosted.org/packages/be/85/9f78af7011735fc98a168a8a4b41abd07df53af0749eb5cf4daf076343a3/psycopg_binary-3.3.7-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:cdc2ce73b2409d2af6d2dbd24ced79cca8408fc57faef1bfc7a1fcf50391f1e1", size = 5586061, upload-time = "2026-11-03T03:57:14.849Z" }, + { url = "https://files.pythonhosted.org/packages/37/03/28a682eda331f59452b5745d776a72ad673f240a86f096d694344ad7e25f/psycopg_binary-3.3.7-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:022d7297c6ff13e3b9fe4772979b55dcea0f043b3a2d3211b92622403faffdd5", size = 5312779, upload-time = "2026-11-03T03:57:20.976Z" }, + { url = "https://files.pythonhosted.org/packages/8b/66/db3e08d00290ff08764506c643da0682565794d037e84787346da33a5e43/psycopg_binary-3.3.7-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b20a404dc921bde74c00c2ca082ba2864cf59ecc610675ac39b3a14a5aaacc", size = 6866400, upload-time = "2026-11-03T03:57:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2f/31b275285bbcce0df0c943908ee3543bdff6d46a657a3e7e101c4a39d5a3/psycopg_binary-3.3.7-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018ba4b5eeefd1400f7e2d042c853c14bc9d7a5e2b180b07e63a59e900a90e00", size = 5093067, upload-time = "2026-11-03T03:57:38.949Z" }, + { url = "https://files.pythonhosted.org/packages/6b/69/0868542c2cbfd622a35c39ada9b604e423610ac703dfcaa5f6b7908906a8/psycopg_binary-3.3.7-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a09474c66e0068724ffd2ab7a6ae0314526d9156dc43688d242d6d00654e4a75", size = 4634472, upload-time = "2026-11-03T03:57:44.967Z" }, + { url = "https://files.pythonhosted.org/packages/42/60/7998247ac07e5094c4c552a852ee8b0a9207083692a140d79cf29a00f696/psycopg_binary-3.3.7-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1da7f340bc7d3a7945422148001893ee003ab0e93722d6a996010cba96b71093", size = 4316481, upload-time = "2026-11-03T03:57:51.186Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/dae410a3c17f634f18f7f6bb1003a9986b2a52347593d87deaf509dd7056/psycopg_binary-3.3.7-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:f3e0ba732d49cebe08f62e8ba8995e7cf82c74f6b43b880d4f05bc592415787a", size = 4039869, upload-time = "2026-11-03T03:57:56.388Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3c/daadb40466b98e0465c767006c72bd3eda3e2d581822cd4212debd5273f2/psycopg_binary-3.3.7-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:48528deb18273d53fc49382cf4eaf309c3013f1a2aa8e22b0fdec580ba235189", size = 4349380, upload-time = "2026-11-03T03:58:00.373Z" }, + { url = "https://files.pythonhosted.org/packages/1c/85/728f34d24afdb135a1cd7bfe79dd76da3ee7a6089a1ddd61ee737f158300/psycopg_binary-3.3.7-cp315-cp315-win_amd64.whl", hash = "sha256:5ca2e91d166c1cdd44073fac07c6dd1dd80011e474650d24778086bb57f59a22", size = 3716314, upload-time = "2026-11-03T03:58:06.014Z" }, +] + [[package]] name = "psycopg2-binary" version = "2.9.12" @@ -6519,7 +6607,7 @@ name = "sqlalchemy" version = "2.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.13.14'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/36/dc/9addbf490110b5727380059ee542ad8353bdcee45a37ba6a5e0976966ae0/sqlalchemy-2.1.6.tar.gz", hash = "sha256:499eae5c9051efe96cb9a73ec2fbde8ffa4186f102b9094ebab8fcc9d3b99f52", size = 10589700, upload-time = "2026-10-21T16:25:06.497Z" } wheels = [ From ef1c24b3f9b21ac649c4a9403d880528442ac5b5 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 15:00:25 -0800 Subject: [PATCH 15/57] fix(website): a models= site's data load leaves out computed columns, so its own export loads back (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/database.py | 9 +++++++-- .../tests/test_website_database.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/database.py b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py index 34373ea47..97509fbcf 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/database.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py @@ -114,7 +114,8 @@ def export(self) -> dict: def load(self, data: dict) -> None: """Insert ``data``'s rows, each table's after the tables it refers to, in their order, each run of rows that name the same columns as one insert (one insert takes its columns from its first row, so a row that leaves an - optional column out goes in its own); refuses a name that is no table.""" + optional column out goes in its own), leaving out each computed column, which the database works out itself, so + what ``export`` gave loads back; refuses a name that is no table.""" tables = {table.name: table for table in self.metadata.sorted_tables} unknown = sorted(set(data) - set(tables)) if unknown: @@ -146,9 +147,13 @@ def key(self, name: str, row: dict) -> str: def _row(table: Any, row: dict) -> dict: - """``row`` with each JSON string a date, time, decimal or UUID column holds parsed to that type.""" + """``row`` without its computed columns' values, and with each JSON string a date, time, decimal or UUID column + holds parsed to that type.""" parsed = dict(row) for column in table.columns: + if column.computed is not None: + parsed.pop(column.name, None) + continue value = parsed.get(column.name) if isinstance(value, str): kind = _python_type(column) diff --git a/packages/agentenv-protocol/tests/test_website_database.py b/packages/agentenv-protocol/tests/test_website_database.py index 1b12d96de..7eeb96b64 100644 --- a/packages/agentenv-protocol/tests/test_website_database.py +++ b/packages/agentenv-protocol/tests/test_website_database.py @@ -110,6 +110,25 @@ class Note(Threads): assert rpc(client, "data/get")["parts"][0]["data"]["notes"] == [{"parent_id": None, **row} for row in seed] +def test_an_export_with_a_computed_column_loads_back_as_the_database_computes_it(dist: Path, database: str) -> None: + class Priced(DeclarativeBase): + pass + + class Item(Priced): + __tablename__ = "items" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + price: Mapped[int] = mapped_column(Integer) + doubled: Mapped[int] = mapped_column(Integer, sqlalchemy.Computed("price * 2")) + + client = TestClient(helpdesk(dist, Priced)().create_app()) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": {"items": [{"id": 1, "price": 3}]}}]}) + exported = rpc(client, "data/get")["parts"][0]["data"] + assert exported == {"items": [{"id": 1, "price": 3, "doubled": 6}]} + rpc(client, "data/reset") + rpc(client, "data/add", {"parts": [{"kind": "data", "data": exported}]}) + assert rpc(client, "data/get")["parts"][0]["data"] == exported + + def test_a_row_the_gui_adds_is_the_mcp_servers_and_go_reports_it(dist: Path, database: str) -> None: env = helpdesk(dist, Base)() client = TestClient(env.create_app()) From 9741dbf5d627371dfee1e02731158e4b43a0feff Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 16:00:45 -0800 Subject: [PATCH 16/57] fix(website): a models= site loads a self-referencing table's rows parents first, so its export loads back (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/database.py | 38 ++++++++++++++++--- .../tests/test_website_database.py | 27 +++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/database.py b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py index 97509fbcf..34181bf91 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/database.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py @@ -112,10 +112,11 @@ def export(self) -> dict: sqlalchemy.select(table).order_by(*table.primary_key.columns))] for table in self.metadata.sorted_tables} def load(self, data: dict) -> None: - """Insert ``data``'s rows, each table's after the tables it refers to, in their order, each run of rows that name - the same columns as one insert (one insert takes its columns from its first row, so a row that leaves an - optional column out goes in its own), leaving out each computed column, which the database works out itself, so - what ``export`` gave loads back; refuses a name that is no table.""" + """Insert ``data``'s rows, each table's after the tables it refers to, in their order but each row of a table that + refers to itself after the row it refers to (``_parents_first``), each run of rows that name the same columns as + one insert (one insert takes its columns from its first row, so a row that leaves an optional column out goes in + its own), leaving out each computed column, which the database works out itself, so what ``export`` gave loads + back; refuses a name that is no table.""" tables = {table.name: table for table in self.metadata.sorted_tables} unknown = sorted(set(data) - set(tables)) if unknown: @@ -125,7 +126,7 @@ def load(self, data: dict) -> None: rows = data.get(name) or [] if not (isinstance(rows, list) and all(isinstance(row, dict) for row in rows)): raise ValueError(f"table {name!r}'s data is not a list of rows") - for _, run in itertools.groupby(rows, key=lambda row: sorted(row)): + for _, run in itertools.groupby(_parents_first(table, rows), key=lambda row: sorted(row)): connection.execute(table.insert(), [_row(table, row) for row in run]) if connection.dialect.name == "postgresql": _advance_sequences(connection, tables.values()) @@ -142,6 +143,33 @@ def key(self, name: str, row: dict) -> str: return json.dumps([row.get(c) for c in columns] if columns else row, sort_keys=True, default=str) +def _parents_first(table: Any, rows: list[dict]) -> list[dict]: + """``rows`` in their order, but each row that refers to another row of ``table`` through a foreign key of the table's + own after that row, so a constraint that isn't deferred holds at each insert; rows in a cycle keep their order.""" + links = [(fk.parent.name, fk.column.name) for fk in table.foreign_keys if fk.column.table is table] + if not links: + return rows + by_value = {(referenced, row.get(referenced)): index for index, row in enumerate(rows) + for _, referenced in links if row.get(referenced) is not None} + ordered, placed, visiting = [], set(), set() + + def place(index: int) -> None: + if index in placed or index in visiting: + return + visiting.add(index) + for local, referenced in links: + parent = by_value.get((referenced, rows[index].get(local))) + if parent is not None and parent != index: + place(parent) + visiting.discard(index) + placed.add(index) + ordered.append(rows[index]) + + for index in range(len(rows)): + place(index) + return ordered + + _PARSERS: dict = {datetime.datetime: datetime.datetime.fromisoformat, datetime.date: datetime.date.fromisoformat, datetime.time: datetime.time.fromisoformat, decimal.Decimal: decimal.Decimal, uuid.UUID: uuid.UUID} diff --git a/packages/agentenv-protocol/tests/test_website_database.py b/packages/agentenv-protocol/tests/test_website_database.py index 7eeb96b64..e2690f233 100644 --- a/packages/agentenv-protocol/tests/test_website_database.py +++ b/packages/agentenv-protocol/tests/test_website_database.py @@ -129,6 +129,33 @@ class Item(Priced): assert rpc(client, "data/get")["parts"][0]["data"] == exported +def test_an_export_whose_child_row_sorts_before_its_parent_loads_back_with_foreign_keys_enforced( + dist: Path, database: str) -> None: + class Threads(DeclarativeBase): + pass + + class Note(Threads): + __tablename__ = "notes" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + parent_id: Mapped[int | None] = mapped_column(ForeignKey("notes.id"), nullable=True) + + def enforce(connection, record) -> None: + connection.execute("PRAGMA foreign_keys=ON") + + sqlalchemy.event.listen(sqlalchemy.engine.Engine, "connect", enforce) + try: + client = TestClient(helpdesk(dist, Threads)().create_app()) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": {"notes": [ + {"id": 3, "parent_id": None}, {"id": 2, "parent_id": 3}, {"id": 1, "parent_id": 2}]}}]}) + exported = rpc(client, "data/get")["parts"][0]["data"] + assert [row["id"] for row in exported["notes"]] == [1, 2, 3] + rpc(client, "data/reset") + rpc(client, "data/add", {"parts": [{"kind": "data", "data": exported}]}) + assert rpc(client, "data/get")["parts"][0]["data"] == exported + finally: + sqlalchemy.event.remove(sqlalchemy.engine.Engine, "connect", enforce) + + def test_a_row_the_gui_adds_is_the_mcp_servers_and_go_reports_it(dist: Path, database: str) -> None: env = helpdesk(dist, Base)() client = TestClient(env.create_app()) From aabc036d8040bda1c26dcd50239336f7ac157ddb Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 18:19:47 -0800 Subject: [PATCH 17/57] refactor(website): no /go?sid= sessions and no site-side state for models= sites; data/get alone (FD-4683) - The store keeps no sessions: no `?sid=`, no `agentenv_sid_` cookie, no `changed_sessions`; `self.store` is the site's one store, which the data plane loads, exports and resets. - A models= site keeps no initial state and no diff; its data plane is its database's. - Conformance runs the round trip with no skip for changed state or sessions. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/README.md | 5 +- .../agentenv_protocol/website/conformance.py | 39 +----- .../src/agentenv_protocol/website/database.py | 25 +--- .../agentenv_protocol/website/environment.py | 127 +++-------------- .../src/agentenv_protocol/website/session.py | 8 -- .../src/agentenv_protocol/website/store.py | 89 +----------- .../tests/test_website_conformance.py | 51 ++----- .../tests/test_website_database.py | 37 +---- .../tests/test_website_environment.py | 52 +------ .../tests/test_website_notes_example.py | 8 -- .../tests/test_website_state.py | 132 ------------------ 11 files changed, 50 insertions(+), 523 deletions(-) delete mode 100644 packages/agentenv-protocol/tests/test_website_state.py diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index fc5cba314..4f0c590d3 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -29,9 +29,8 @@ them. The page shows the universe's time. store's `current_user`), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded user. A user's notes are invisible to every other user, and another user's note answers 404. - **State.** The site writes no data hooks, so it uses the default store. `data/add` loads the seed (as a data part, or - as a file part by bytes or `file://`), `data/get` exports it, `data/reset` empties it, and `/go` returns - `{initial_state, current_state, state_diff}`. A page opened at `?sid=` keeps a state of its own, started - from the seed, which `/go?sid=` reports; without `sid` it is the default session, the data plane's. + as a file part by bytes or `file://`), `data/get` exports it, and `data/reset` empties it. `data/get` is how + agentenv reads what a task left the site with. - **Time.** Notes are stamped with `self.now()`, which follows the gateway's clock once `clock/v1`'s `sync_time` names it. The frontend reads `X-Universe-Now` from every API response into `universeNow()` and shows it in the header. It uses this helper rather than `clock_patch=True`, so the page works the same against a server written without the diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py index 67a8a114a..d5bc89c26 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -14,14 +14,10 @@ redirect), and cookies tied to no domain, the session path's the one the card names. A failed one is a ``warning``. The round trip leaves the server's data as it found it, adding the export back when a step after the reset fails too. -data/add sets the state a task starts from, so a server whose state shows changes since its initial state, as an SDK -site's does, would have them folded into it, and data/reset drops a site's named sessions, which data/get doesn't -export: the round trip is skipped for a server whose state shows changes or ``changed_sessions``. Its state is its -card's ``website/v1`` ``state``, else, for a card without it, what the path its ``params.state.path`` names answers, -else ``/go``'s, deprecated. The clock check leaves the server following a clock that is gone once the check ends, so a -server it ran against keeps extrapolating from that clock's time until it's synced again. The checker's clock starts at -``CLOCK_START``, far enough from wall time that a synced server shows it, and a synced server's ``X-Universe-Now`` may -be ``CLOCK_SLACK_S`` seconds from it.""" +The clock check leaves the server following a clock that is gone once the check ends, so a server it ran against +keeps extrapolating from that clock's time until it's synced again. The checker's clock starts at ``CLOCK_START``, far +enough from wall time that a synced server shows it, and a synced server's ``X-Universe-Now`` may be ``CLOCK_SLACK_S`` +seconds from it.""" from __future__ import annotations import argparse @@ -42,9 +38,8 @@ from ..client import find_extension, find_extension_method from ..types import METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, iso, parse_time -from .environment import DEFAULT_HEALTH_PATH, STATE_PATH +from .environment import DEFAULT_HEALTH_PATH from .readiness import WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness -from .state import WEBSITE_STATE_OP Status = Literal["passed", "failed", "warning", "skipped"] CLOCK_START = datetime.datetime(2031, 5, 6, 7, 8, 9, tzinfo=datetime.timezone.utc) @@ -204,10 +199,6 @@ def _data_plane(self, card: dict) -> tuple[Status, str]: if missing: return "failed", f"the card's operations lack {', '.join(missing)}" exported = self._rpc(METHOD_GET).get("parts") or [] - if where := self._changed_since_initial(card): - return "skipped", (f"data/get answered; {where} shows changes since the initial state, or named sessions " - "with changes, which data/reset and data/add would fold in or drop, so the round trip " - "was not run") self._rpc(METHOD_RESET) added_back = False try: @@ -229,26 +220,6 @@ def _data_plane(self, card: dict) -> tuple[Status, str]: return "failed", "data/get after data/reset and data/add of its own export returned other data" return "passed", f"{len(exported)} part(s) round-tripped through reset and add" - def _changed_since_initial(self, card: dict) -> str | None: - """Where the server's state was read when its ``state_diff`` names changes or it names ``changed_sessions``, - None when it names neither or can't be read: the card's ``website/v1`` ``state``, else the path its - ``params.state.path`` names, else ``/go``. Read aside, as no check's answer, since a server without one needn't - stamp its 404.""" - op = find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_STATE_OP) - if op is not None and op.get("endpoint"): - where, request = f"website/v1 {WEBSITE_STATE_OP}", (op["method"], self.base_url + op["endpoint"]) - else: - declared = ((find_extension(card, WEBSITE_EXTENSION_URI) or {}).get("params") or {}).get("state") - path = declared.get("path") if isinstance(declared, dict) else None - where = path if isinstance(path, str) and path.startswith("/") else STATE_PATH - request = ("GET", self.base_url + where) - try: - state = self.client.request(*request).json() - except Exception: - return None - changed = isinstance(state, dict) and bool(state.get("state_diff") or state.get("changed_sessions")) - return where if changed else None - def _clock(self, card: dict, health: str) -> tuple[Status, str]: if find_extension(card, CLOCK_EXTENSION_URI) is None: return "failed", f"the card declares no {CLOCK_EXTENSION_URI}" diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/database.py b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py index 34181bf91..dbc260f02 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/database.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py @@ -12,15 +12,12 @@ naming it. Beside the tables, the data's string ``current_user`` names the user a browser starts signed in as, as the default -store's does, and the state data/add last loaded is the initial one ``/go`` reports. A models site keeps one state, -the database's, so ``/go`` answers it for every ``sid``.""" +store's does.""" from __future__ import annotations -import copy import datetime import decimal import itertools -import json import types import uuid from typing import Any, Callable, Iterable @@ -28,7 +25,7 @@ from pydantic_core import to_jsonable_python from ..agent_env_environment import _maybe_await -from .store import CURRENT_USER, part_data, verifier_state +from .store import CURRENT_USER, part_data HOOKS = ("load", "export", "reset") @@ -136,12 +133,6 @@ def reset(self) -> None: for table in reversed(self.metadata.sorted_tables): connection.execute(table.delete()) - def key(self, name: str, row: dict) -> str: - """A row's identity, its primary key's values, which ``/go``'s diff matches rows by.""" - table = next((t for t in self.metadata.sorted_tables if t.name == name), None) - columns = [c.name for c in table.primary_key.columns] if table is not None else [] - return json.dumps([row.get(c) for c in columns] if columns else row, sort_keys=True, default=str) - def _parents_first(table: Any, rows: list[dict]) -> list[dict]: """``rows`` in their order, but each row that refers to another row of ``table`` through a foreign key of the table's @@ -230,17 +221,16 @@ def engine(self) -> Any: class ModelState: - """A models site's data plane and ``/go``: ``hooks`` loads, exports and resets its data, the state data/add last - loaded is the initial one, and the data's ``current_user`` is kept beside it.""" + """A models site's data plane: ``hooks`` loads, exports and resets its data, and the data's ``current_user`` is + kept beside it.""" def __init__(self, hooks: Any) -> None: self.hooks = hooks - self.initial: dict = {} self.current_user: str | None = None async def reset(self) -> None: await _maybe_await(self.hooks.reset()) - self.initial, self.current_user = {}, None + self.current_user = None async def load_parts(self, parts: list) -> None: for part in parts: @@ -248,14 +238,9 @@ async def load_parts(self, parts: list) -> None: if CURRENT_USER in data: self.current_user = data.pop(CURRENT_USER) await _maybe_await(self.hooks.load(data)) - self.initial = copy.deepcopy(await self.export()) async def export(self) -> dict: data = dict(await _maybe_await(self.hooks.export())) if self.current_user is not None: data[CURRENT_USER] = self.current_user return data - - async def state(self) -> dict: - key = getattr(self.hooks, "key", None) - return verifier_state(copy.deepcopy(self.initial), await self.export(), key) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py index 9a7a27059..5c3b1eda8 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py @@ -2,23 +2,13 @@ plain HTTP on ``PORT`` for any host name: its ``@api`` routes, its static folder at ``/`` with ``index.html`` for the pages a single-page app routes itself, its health path, and the data plane and card the base class serves. Every answer carries ``X-Universe-Now``, the universe's time, which ``self.now()`` reads and ``clock/v1``'s ``sync_time`` ties to -the gateway's virtual clock. Without data hooks of its own a site keeps its state in ``self.store``, or with -``@website(models=...)`` in an MCP server's database, which ``self.db`` opens sessions of (``website.database``). - -A store-backed site keeps a state per session, the CUA-Gym convention the website state verifier reads: a request's -session is its ``?sid=``, else the one its browser last named, which the site keeps in the ``agentenv_sid_`` -cookie, else the default one. ``self.store`` is the request's session's store, each session started as a copy of the -initial state; the default session, ``sid=default``, is the state the data plane loads, exports and resets, and the -one a browser that names no session uses, so a site browsed without ``sid`` works as it did before sessions. - -Two routes are built in, each replaced by a site's own ``@api`` route at its very path and answering before one whose -path parameters would also match it: ``/api/session``, which a frontend calls to learn who is signed in, signing the -acting user in when nobody is (GET), or to sign out (DELETE); and, for a store-backed site, ``/go?sid=``, that -session's ``{initial_state, current_state, state_diff}`` at the path ``website/v1``'s ``params.state.path`` names, -which a reader of a card without ``state`` falls back to, with the site's ``changed_sessions``, the named sessions whose -state changed, which data/reset would drop; a session no request has used yet answers the initial state, unchanged. A -store-backed site answers ``website/v1``'s ``state``, of the session its ``sid`` names, with the same ``WebsiteState``, -among agentenv's own routes, which is how the website state verifier reads it.""" +the gateway's virtual clock. Without data hooks of its own a site keeps its data in ``self.store``, or with +``@website(models=...)`` in an MCP server's database, which ``self.db`` opens sessions of (``website.database``); the +data plane loads, exports and resets it, and ``data/get`` is how agentenv reads what a task left a site with. + +One route is built in, replaced by a site's own ``@api`` route at its very path and answering before one whose path +parameters would also match it: ``/api/session``, which a frontend calls to learn who is signed in, signing the acting +user in when nobody is (GET), or to sign out (DELETE).""" from __future__ import annotations import inspect @@ -49,20 +39,18 @@ get_data, reset_data, ) -from ..types import DataPart, EnvironmentExtension +from ..types import DataPart from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, UniverseClock, clock_patch_script, iso from .database import ModelState, Sessions, Tables, is_hooks, metadata_of from .errors import NotFound from .readiness import WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness from .routes import (ApiContext, ApiRoute, endpoint, error_response, forwarded_prefix, openapi, openapi_path, request_role, reserved_path, routes_of) -from .session import SessionSigner, expires_at, session_cookie_name, sid_cookie_name -from .state import WEBSITE_STATE_OP, WebsiteState -from .store import DEFAULT_SID, Collections, active_sid, session_id +from .session import SessionSigner, expires_at, session_cookie_name +from .store import Collections DEFAULT_HEALTH_PATH = "/api/health" SESSION_PATH = "/api/session" -STATE_PATH = "/go" _WEBSITE_ATTR = "_aee_website" _READY_ATTR = "_aee_website_ready" _DEFAULT_HOOK_ATTR = "_aee_default_store_hook" @@ -87,8 +75,8 @@ def website(_cls: type | None = None, *, domains: list[str] | tuple[str, ...] = health: str = DEFAULT_HEALTH_PATH, clock_patch: bool = False, data: str | None = None, models: Any = None) -> Any: """Declare a WebsiteEnvironment's site. ``data`` names a JSON file the default store starts with; ``models`` - keeps the site's state in a database instead, an MCP server's (see ``website.database``), and the site's - data plane and ``/go`` are that database's.""" + keeps the site's data in a database instead, an MCP server's (see ``website.database``), and the site's + data plane is that database's.""" if _cls is not None: raise TypeError("use @website(...) with parentheses") if not health.startswith("/") or health == "/" or reserved_path(health): @@ -123,16 +111,6 @@ class WebsiteEnvironment(AgentEnvEnvironment): app: ClassVar[Any] = None asgi: Any = None db: Any = None - _store: Collections - - @property - def store(self) -> Collections: - """The state store of the request's session: the default session's outside a request.""" - return self._store.session(active_sid.get()) - - @store.setter - def store(self, value: Collections) -> None: - self._store = value def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) @@ -148,7 +126,6 @@ def __init__(self) -> None: self.store = Collections() if declaration.data is not None: self.store.load(json.loads(self._site_path(declaration.data).read_text(encoding="utf-8"))) - self.store.mark_initial() self.clock = UniverseClock() self._signer = SessionSigner(cookie_name=session_cookie_name(self.environment_name())) models = declaration.models @@ -217,14 +194,11 @@ def create_app(self) -> Starlette: api_routes = self.routes() taken = {openapi_path(r.path) for r in api_routes} root = self._site_path(declaration.static) if declaration.static is not None else None - asgi = Starlette(middleware=[Middleware(_Forwarded), Middleware(_Universe, env=self), - Middleware(_Session, cookie=sid_cookie_name(self.environment_name()))]) + asgi = Starlette(middleware=[Middleware(_Forwarded), Middleware(_Universe, env=self)]) AgentEnvStarletteApplication(self._build_card(), self).add_routes_to_app(asgi) asgi.router.routes.append(Route(declaration.health, self._health, methods=["GET"])) if SESSION_PATH not in taken: asgi.router.routes.append(Route(SESSION_PATH, self._session, methods=["GET", "DELETE"])) - if self._serves_state_path(): - asgi.router.routes.append(Route(STATE_PATH, self._state, methods=["GET"])) asgi.router.routes.extend(Route(r.path, _unreserved(endpoint(getattr(self, r.fn.__name__), r, self.context)), methods=[r.method]) for r in api_routes) asgi.router.default = _Fallback(root, type(self).app) @@ -249,17 +223,9 @@ def extension_params(self, uri: str) -> dict: declaration = self.declaration() return {"domains": list(declaration.domains), "health": declaration.health, "routes": [r.advertised() for r in self.routes()], "session_cookie": self.session_cookie, - "session_path": SESSION_PATH, **({"state": {"path": STATE_PATH}} if self._serves_state_path() else {})} + "session_path": SESSION_PATH} return super().extension_params(uri) - def served_extensions(self, extensions: list[EnvironmentExtension]) -> list[EnvironmentExtension]: - """The extensions the base class serves, ``website/v1``'s ``state`` only for a store-backed site: one with data - hooks of its own keeps its state where ``self.store`` can't see it.""" - served = super().served_extensions(extensions) - if self.uses_store: - return served - return [_without_method(e, WEBSITE_STATE_OP) if e.uri == WEBSITE_EXTENSION_URI else e for e in served] - @extension(CLOCK_EXTENSION_URI, description="Sync this site to the gateway's virtual clock.") async def sync_time(self, env_get_time_url: str, timezone: Optional[str] = None) -> dict: """Follow the gateway's virtual time at ``env_get_time_url`` from now on, in ``timezone`` when the task names @@ -270,9 +236,8 @@ async def sync_time(self, env_get_time_url: str, timezone: Optional[str] = None) @extension(WEBSITE_EXTENSION_URI, op="openapi", method="GET", description=( "A site's server: domains, its health path and its @api routes by name; openapi returns the OpenAPI document " - "of its routes, ready whether the site is usable now and why not, state the task state it is in (its " - "initial and current state and their diff), and session the cookies a browser adds to start signed in as user, " - "or signed out without one.")) + "of its routes, ready whether the site is usable now and why not, and session the cookies a browser adds to " + "start signed in as user, or signed out without one.")) async def _website_openapi(self) -> dict: name = self._application.environment_card.name if self._application is not None else type(self).__name__ return openapi(name, self.routes()) @@ -284,16 +249,6 @@ async def _website_ready(self) -> dict: problems = await self._not_ready() return WebsiteReadiness(ready=not problems, problems=problems).model_dump() - @extension(WEBSITE_EXTENSION_URI, op=WEBSITE_STATE_OP, method="GET") - async def _website_state(self, sid: Optional[str] = None) -> dict: - """The site's task state, a ``WebsiteState``: its state when the task started, now, and what changed between - them, of session ``sid``'s store (the default session's without one), with the site's ``changed_sessions`` as - ``/go`` names them, or of a models site's database, which keeps one.""" - if self._models is not None: - return WebsiteState.model_validate(await self._models.state()).model_dump() - return {**WebsiteState.model_validate(self._store.session(sid).state()).model_dump(), - "changed_sessions": self._store.changed_sessions()} - @extension(WEBSITE_EXTENSION_URI, op="session") async def _website_session(self, user: Optional[str] = None) -> dict: cookie = self._session_signer().cookie(user) @@ -303,18 +258,17 @@ async def _website_session(self, user: Optional[str] = None) -> dict: async def _store_reset(self) -> None: if self._models is not None: return await self._models.reset() - self._store.reset() + self.store.reset() @add_data async def _store_add(self, parts: list) -> None: if self._models is not None: return await self._models.load_parts(parts) - self._store.load_parts(parts) - self._store.mark_initial() + self.store.load_parts(parts) @get_data async def _store_get(self) -> list: - return [DataPart(data=await self._models.export() if self._models is not None else self._store.export())] + return [DataPart(data=await self._models.export() if self._models is not None else self.store.export())] async def _not_ready(self) -> list[str]: return [check.__name__ for check, _marker in _marked_members(self, _READY_ATTR) if not await _ready(check)] @@ -334,15 +288,6 @@ async def _session(self, request: Request) -> Response: return context.apply(JSONResponse({"user": context.user, "role": context.role, "signed_in": context.user is not None})) - def _serves_state_path(self) -> bool: - """Whether the site serves its state at ``STATE_PATH``: it is store-backed and no ``@api`` route takes the path.""" - return self.uses_store and STATE_PATH not in {openapi_path(r.path) for r in self.routes()} - - async def _state(self, request: Request) -> Response: - if self._models is not None: - return JSONResponse(await self._models.state()) - return JSONResponse({**self.store.state(), "changed_sessions": self._store.changed_sessions()}) - def _clock(self) -> UniverseClock: if not hasattr(self, "clock"): raise TypeError(f"{type(self).__qualname__}.__init__ must call super().__init__()") @@ -363,12 +308,6 @@ def _site_path(cls, path: str) -> Path: setattr(getattr(WebsiteEnvironment, _hook), _DEFAULT_HOOK_ATTR, True) -def _without_method(extension: EnvironmentExtension, method: str) -> EnvironmentExtension: - params = dict(extension.params or {}) - params["methods"] = {name: entry for name, entry in (params.get("methods") or {}).items() if name != method} - return extension.model_copy(update={"params": params}) - - async def _ready(check: Any) -> bool: try: return bool(await _maybe_await(check())) @@ -416,34 +355,6 @@ def _first(value: bytes | None) -> str: return value.decode("latin-1").split(",")[0].strip() if value else "" -class _Session: - """Runs the request in its session (``active_sid``): its ``sid`` query parameter's, else its ``cookie``'s; an - answer to a request naming a session other than its cookie's sets the cookie, so a page opened at ``?sid=`` keeps - its session for the requests it makes after.""" - - def __init__(self, app: Any, cookie: str) -> None: - self.app, self.cookie = app, cookie - - async def __call__(self, scope: dict, receive: Any, send: Any) -> None: - if scope["type"] != "http": - return await self.app(scope, receive, send) - request = Request(scope) - named, kept = request.query_params.get("sid"), request.cookies.get(self.cookie) - sid = session_id(named if named is not None else kept) - - async def remembered(message: dict) -> None: - if message["type"] == "http.response.start" and named is not None and session_id(kept) != sid: - MutableHeaders(scope=message).append( - "set-cookie", f"{self.cookie}={sid or DEFAULT_SID}; Path=/; HttpOnly; SameSite=Lax") - await send(message) - - token = active_sid.set(sid) - try: - await self.app(scope, receive, remembered) - finally: - active_sid.reset(token) - - class _Universe: """Stamps ``X-Universe-Now`` on every answer, and with ``clock_patch`` puts the clock script in its whole HTML (never a range of it), holding only the HTML up to its ```` (at most ``HEAD_SCAN_BYTES``), so a page streamed in chunks reaches the browser diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/session.py b/packages/agentenv-protocol/src/agentenv_protocol/website/session.py index b798afcd4..b84b47be5 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/session.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/session.py @@ -17,7 +17,6 @@ import time SESSION_COOKIE = "agentenv_session" -SID_COOKIE = "agentenv_sid" SECRET_ENV = "WEBSITE_SESSION_SECRET" SESSION_TTL_S = 7 * 24 * 3600 SECRET_BYTES = 32 @@ -40,13 +39,6 @@ def session_cookie_name(environment_name: str | None) -> str: return f"{SESSION_COOKIE}_{_cookie_suffix(environment_name)}" if environment_name else SESSION_COOKIE -def sid_cookie_name(environment_name: str | None) -> str: - """The cookie in which the site named ``environment_name`` keeps the store session its browser last named: - ``agentenv_sid_``, made as ``session_cookie_name`` makes its name, ``agentenv_sid`` for a site with no name. - Its prefix is no session cookie's, so no site's store-selection cookie is another site's session cookie.""" - return f"{SID_COOKIE}_{_cookie_suffix(environment_name)}" if environment_name else SID_COOKIE - - class SessionSigner: def __init__(self, secret: str | bytes | None = None, cookie_name: str = SESSION_COOKIE) -> None: secret = secret if secret is not None else os.environ.get(SECRET_ENV) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/store.py b/packages/agentenv-protocol/src/agentenv_protocol/website/store.py index 63c316a75..bf767efc3 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/store.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/store.py @@ -1,19 +1,11 @@ -"""The default state store of a simple site: named collections of JSON objects, each with an ``id``. Its data is a JSON -object of collections by name, beside which a string ``current_user`` names the user a browser starts signed in as. -It remembers the state it was last loaded with, so a verifier can read the initial state, the current one and the -difference between them. - -A store also keeps sessions, as CUA-Gym sites do, so browsers that each name a session id (``?sid=``) never see one -another's changes: ``session(sid)`` is a store of its own, started on first use as a copy of this store's initial -state, and the session ``default``, or none, is this store itself. ``reset`` drops every session; a session started -before a later ``load`` keeps the state it was started with.""" +"""The default data store of a simple site: named collections of JSON objects, each with an ``id``. Its data is a JSON +object of collections by name, beside which a string ``current_user`` names the user a browser starts signed in as; +the data plane loads, exports and resets it.""" from __future__ import annotations import base64 -import contextvars import copy import json -import re from pathlib import Path from typing import Any, Iterable from urllib.parse import unquote, urlparse @@ -21,17 +13,6 @@ from ..types import DataPart, FilePart, Part, TextPart CURRENT_USER = "current_user" -DEFAULT_SID = "default" -SID_MAX = 128 -_NOT_SID = re.compile(r"[^A-Za-z0-9_-]") -active_sid: contextvars.ContextVar[str | None] = contextvars.ContextVar("agentenv_website_sid", default=None) - - -def session_id(value: str | None) -> str | None: - """The session id ``value`` names, as CUA-Gym sites read ``sid``: its letters, digits, ``_`` and ``-``, at most - ``SID_MAX`` of them; None, the default session, for one that is empty or ``default``.""" - sid = _NOT_SID.sub("", value or "")[:SID_MAX] - return None if sid in ("", DEFAULT_SID) else sid class Collections: @@ -40,33 +21,12 @@ class Collections: def __init__(self, data: dict | None = None) -> None: self._collections: dict[str, list[dict]] = {} self.current_user: str | None = None - self.initial: dict = {} - self._sessions: dict[str, Collections] = {} if data: self.load(data) - self.mark_initial() def __getitem__(self, name: str) -> list[dict]: return self._collections.setdefault(name, []) - def session(self, sid: str | None) -> Collections: - """The store of session ``sid``, started as a copy of this one's initial state on first use; this store - itself for the default session.""" - sid = session_id(sid) - if sid is None: - return self - if sid not in self._sessions: - self._sessions[sid] = Collections(self.initial) - return self._sessions[sid] - - def sessions(self) -> list[str]: - """The ids of the sessions started since the store was last reset, the default one aside.""" - return list(self._sessions) - - def changed_sessions(self) -> list[str]: - """The ids of the sessions, the default one aside, whose state differs from the one they started with.""" - return [sid for sid, store in self._sessions.items() if store.diff()] - def names(self) -> list[str]: return list(self._collections) @@ -115,8 +75,6 @@ def load_parts(self, parts: Iterable[Part]) -> None: def reset(self) -> None: self._collections.clear() self.current_user = None - self.initial = {} - self._sessions.clear() def export(self) -> dict: data: dict = copy.deepcopy(self._collections) @@ -124,47 +82,6 @@ def export(self) -> dict: data[CURRENT_USER] = self.current_user return data - def mark_initial(self) -> None: - self.initial = self.export() - - def diff(self) -> dict: - return state_diff(self.initial, self.export()) - - def state(self) -> dict: - return verifier_state(self.initial, self.export()) - - -def state_diff(initial: dict, current: dict, key: Any = None) -> dict: - """Each collection that changed from ``initial`` to ``current``, as its ``added``, ``removed`` and ``changed`` - objects, matched by ``key(name, doc)``, their ``id`` by default; a changed one as its ``id``, ``before`` and - ``after``.""" - key = key or (lambda name, doc: _key(doc)) - changes: dict = {} - for name in dict.fromkeys([*initial, *current]): - if name == CURRENT_USER: - continue - before = {key(name, d): d for d in initial.get(name, [])} - after = {key(name, d): d for d in current.get(name, [])} - entry = { - "added": [d for k, d in after.items() if k not in before], - "removed": [d for k, d in before.items() if k not in after], - "changed": [{"id": d.get("id"), "before": before[k], "after": d} - for k, d in after.items() if k in before and before[k] != d], - } - if any(entry.values()): - changes[name] = entry - return changes - - -def verifier_state(initial: dict, current: dict, key: Any = None) -> dict: - """What ``/go`` answers: ``{initial_state, current_state, state_diff}``.""" - return {"initial_state": copy.deepcopy(initial), "current_state": current, - "state_diff": state_diff(initial, current, key)} - - -def _key(doc: dict) -> str: - return json.dumps(doc.get("id")) if doc.get("id") is not None else json.dumps(doc, sort_keys=True) - def part_data(part: Part) -> dict: """A part's JSON: a data part's data, a text part's text, or a file part's bytes or ``file://`` file.""" diff --git a/packages/agentenv-protocol/tests/test_website_conformance.py b/packages/agentenv-protocol/tests/test_website_conformance.py index af03260b3..9140ab39d 100644 --- a/packages/agentenv-protocol/tests/test_website_conformance.py +++ b/packages/agentenv-protocol/tests/test_website_conformance.py @@ -37,7 +37,6 @@ def notes(self) -> list: env = Notes() env.store.load({"current_user": "ann", "notes": [{"id": 1, "text": "hello"}]}) - env.store.mark_initial() return env @@ -58,12 +57,10 @@ def _handwritten(*, leak_host: bool = False, domain_cookie: bool = False, clock: clock_ops_first: tuple[str, ...] = (), leak_redirect: bool = False, stamp_rpc: bool = True, reset_keeps: bool = False, get_fails_after_reset: bool = False, rpc_reply: tuple[int, dict] | None = None, - ready_answer: object = None, ready_status: int = 200, rpc_path: str | None = None, - state_path: str | None = None, changed: bool = False) -> Starlette: + ready_answer: object = None, ready_status: int = 200, rpc_path: str | None = None) -> Starlette: """A server written without the SDK: the contract by hand, optionally broken. Its state is its ``state.data``; with ``ready_answer`` its card advertises ``website/v1``'s ``ready``, which answers it with ``ready_status``. With ``rpc_path`` its card's - ``url`` names its data plane there, not at ``/agentenv``, and with ``state_path`` its ``params.state.path`` names - where it serves its state, which shows changes since the initial state when ``changed``.""" + ``url`` names its data plane there, not at ``/agentenv``.""" state = {"items": ["a"], "url": None, "at": None, "reset": False} def now() -> datetime.datetime: @@ -98,8 +95,6 @@ async def card(request: Request) -> Response: "routes": [{"method": "GET", "path": "/items", "name": "items"}]}}] if ready_answer is not None: extensions[0]["params"]["methods"] = {"ready": {"method": "GET", "endpoint": "/ready"}} - if state_path is not None: - extensions[0]["params"]["state"] = {"path": state_path} if clock: extensions.append({"uri": "urn:agentenv:clock/v1", "params": { "endpoint": "/agentenv/ext/sync_time", "methods": { @@ -109,9 +104,6 @@ async def card(request: Request) -> Response: "capabilities": {"extensions": extensions, "operations": ["data/reset", "data/add", "data/get"]}})) - async def site_state(request: Request) -> Response: - return JSONResponse({"state_diff": {"items": {"added": ["b"]}} if changed else {}}) - async def rpc(request: Request) -> Response: body = await request.json() method = body["method"] @@ -147,8 +139,7 @@ async def unrouted(request: Request, exc: Exception) -> Response: app = Starlette(routes=[Route("/", page), Route("/healthz", health), Route("/ready", ready), Route("/items", items), Route("/items/", items_slash), Route("/.well-known/agent-env.json", card), Route(rpc_path or "/agentenv", rpc, methods=["POST"]), - Route("/agentenv/ext/sync_time", sync_time, methods=["POST"]), - *([Route(state_path, site_state)] if state_path else [])], + Route("/agentenv/ext/sync_time", sync_time, methods=["POST"])], exception_handlers={404: unrouted, 405: unrouted}) app.state.data = state return app @@ -234,28 +225,14 @@ def test_a_data_request_without_a_2xx_json_rpc_result_fails_the_data_plane(reply assert result.status == "failed" and "no JSON-RPC result" in result.detail -def test_an_sdk_site_keeps_its_initial_state_and_one_changed_since_skips_the_round_trip( - notes: WebsiteEnvironment, sdk_site: TestClient) -> None: - before = sdk_site.get("/go").json() - conformance.check("http://testserver", client=sdk_site) - assert sdk_site.get("/go").json() == before - notes.store.insert("notes", {"text": "new"}) - changed = sdk_site.get("/go").json() - assert changed["state_diff"] - result = {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["data_plane"] - assert result.status == "skipped" and "website/v1 state shows changes" in result.detail - assert sdk_site.get("/go").json() == changed - - -def test_a_named_session_with_changes_skips_the_round_trip_and_keeps_its_state( +def test_an_sdk_sites_data_round_trips_and_is_left_as_the_check_found_it_changed_or_not( notes: WebsiteEnvironment, sdk_site: TestClient) -> None: - sdk_site.get("/api/notes", params={"sid": "task-1"}) - assert {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["data_plane"].status == "passed" - notes.store.session("task-2")["notes"].append({"id": 2, "text": "draft"}) - result = {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["data_plane"] - assert result.status == "skipped" and "named sessions" in result.detail - assert sdk_site.get("/go", params={"sid": "task-2"}).json()["state_diff"]["notes"]["added"] == [ - {"id": 2, "text": "draft"}] + for _ in range(2): + before = notes.store.export() + result = {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["data_plane"] + assert result.status == "passed", result + assert notes.store.export() == before + notes.store.insert("notes", {"text": "new"}) def test_the_data_plane_is_reached_at_the_cards_url() -> None: @@ -271,14 +248,6 @@ def test_the_clock_check_calls_sync_time_whichever_operation_the_card_lists_firs assert results["clock"] == "passed", results -@pytest.mark.parametrize(("changed", "status"), [(True, "skipped"), (False, "passed")]) -def test_a_card_without_state_is_read_at_the_state_path_it_names(changed: bool, status: str) -> None: - app = _handwritten(state_path="/grading/state", changed=changed) - result = {r.name: r for r in conformance.check("http://testserver", client=TestClient(app))}["data_plane"] - assert result.status == status, result - assert ("/grading/state shows changes" in result.detail) is changed - - def test_a_server_with_no_card_health_or_header_fails_the_contract_and_warns_of_the_rest() -> None: bare = Starlette(routes=[Route("/", lambda request: JSONResponse({"not": "html"}))]) results = conformance.check("http://testserver", client=TestClient(bare)) diff --git a/packages/agentenv-protocol/tests/test_website_database.py b/packages/agentenv-protocol/tests/test_website_database.py index e2690f233..2cc5c8b8e 100644 --- a/packages/agentenv-protocol/tests/test_website_database.py +++ b/packages/agentenv-protocol/tests/test_website_database.py @@ -1,6 +1,6 @@ """A site of ``@website(models=...)``: its state is a database's, SQLAlchemy models' tables on DATABASE_URL (sqlite -here) or an object's own load/export/reset, which its data plane and /go read and write, and its routes reach -through ``self.db``.""" +here) or an object's own load/export/reset, which its data plane reads and writes, and its routes reach through +``self.db``.""" from __future__ import annotations @@ -156,7 +156,7 @@ def enforce(connection, record) -> None: sqlalchemy.event.remove(sqlalchemy.engine.Engine, "connect", enforce) -def test_a_row_the_gui_adds_is_the_mcp_servers_and_go_reports_it(dist: Path, database: str) -> None: +def test_a_row_the_gui_adds_is_the_mcp_servers_and_data_get_reports_it(dist: Path, database: str) -> None: env = helpdesk(dist, Base)() client = TestClient(env.create_app()) rpc(client, "data/add", {"parts": [{"kind": "data", "data": SEED}]}) @@ -166,12 +166,8 @@ def test_a_row_the_gui_adds_is_the_mcp_servers_and_go_reports_it(dist: Path, dat assert connection.execute(select(Ticket.title).order_by(Ticket.id)).scalars().all() == ["printer", "badge"] connection.execute(sqlalchemy.update(Ticket).where(Ticket.id == 1).values(title="printer jam")) connection.commit() - state = client.get("/go", params={"sid": "default"}).json() - assert state["initial_state"] == SEED - diff = state["state_diff"]["tickets"] - assert [t["title"] for t in diff["added"]] == ["badge"] and diff["removed"] == [] - assert diff["changed"][0]["before"]["title"] == "printer" and diff["changed"][0]["after"]["title"] == "printer jam" - assert client.get("/go", params={"sid": "other"}).json() == state + tickets = rpc(client, "data/get")["parts"][0]["data"]["tickets"] + assert [t["title"] for t in tickets] == ["printer jam", "badge"] def test_a_models_site_passes_the_conformance_check(dist: Path, database: str) -> None: @@ -232,28 +228,5 @@ def reset(self) -> None: rpc(client, "data/add", {"parts": [{"kind": "data", "data": {"current_user": "bo", "rows": [{"id": 1}]}}]}) rows.rows.append({"id": 2}) assert rpc(client, "data/get")["parts"][0]["data"] == {"current_user": "bo", "rows": [{"id": 1}, {"id": 2}]} - assert client.get("/go").json()["state_diff"] == {"rows": {"added": [{"id": 2}], "removed": [], "changed": []}} assert env.acting_user() == "bo" assert env.db is None - - -def test_editing_a_row_a_hook_still_owns_leaves_the_initial_state(dist: Path) -> None: - class Rows: - def __init__(self) -> None: - self.rows: list = [] - - def load(self, data: dict) -> None: - self.rows += data["rows"] - - def export(self) -> dict: - return {"rows": list(self.rows)} - - def reset(self) -> None: - self.rows.clear() - - rows = Rows() - client = TestClient(helpdesk(dist, rows)().create_app()) - rpc(client, "data/add", {"parts": [{"kind": "data", "data": {"rows": [{"id": 1, "status": "open"}]}}]}) - rows.rows[0]["status"] = "closed" - assert client.get("/go").json()["state_diff"] == {"rows": {"added": [], "removed": [], "changed": [ - {"id": 1, "before": {"id": 1, "status": "open"}, "after": {"id": 1, "status": "closed"}}]}} diff --git a/packages/agentenv-protocol/tests/test_website_environment.py b/packages/agentenv-protocol/tests/test_website_environment.py index e2ccfccbb..1cfb7c1d3 100644 --- a/packages/agentenv-protocol/tests/test_website_environment.py +++ b/packages/agentenv-protocol/tests/test_website_environment.py @@ -239,39 +239,6 @@ def test_a_sites_data_is_read_through_the_data_plane_alone(client: TestClient) - assert "state" not in website_params and "state" not in website_params.get("methods", {}) -def test_each_sid_keeps_a_state_of_its_own_started_from_the_initial_state(client: TestClient, - shop: WebsiteEnvironment) -> None: - client.post("/api/orders", json={"sku": "mug"}) - assert TestClient(shop.asgi).get("/go", params={"sid": "task-7"}).json() == { - "initial_state": SEED, "current_state": SEED, "state_diff": {}, "changed_sessions": []} - page = client.get("/", params={"sid": "task-7"}) - assert page.headers["set-cookie"].startswith("agentenv_sid_Shop=task-7;") - assert "set-cookie" not in client.get("/", params={"sid": "task-7"}).headers - client.post("/api/orders", json={"sku": "pen"}) - client.post("/api/orders", params={"sid": "other"}, json={"sku": "cup"}) - added = {sid: [o.get("sku") for d in [client.get("/go", params={"sid": sid}).json()["state_diff"]] - for o in d.get("orders", {}).get("added", [])] for sid in ("default", "task-7", "other")} - assert added == {"default": ["mug"], "task-7": ["pen"], "other": ["cup"]} - assert client.get("/go").json()["state_diff"]["orders"]["added"][0]["sku"] == "cup" - client.get("/", params={"sid": "default"}) - assert client.get("/go").json()["state_diff"]["orders"]["added"][0]["sku"] == "mug" - assert rpc(client, "data/get")["parts"][0]["data"]["orders"][-1]["sku"] == "mug" - assert sorted(shop._store.sessions()) == ["other", "task-7"] - assert sorted(client.get("/go").json()["changed_sessions"]) == ["other", "task-7"] - rpc(client, "data/reset") - assert shop._store.sessions() == [] and client.get("/go", params={"sid": "task-7"}).json()["current_state"] == {} - - -def test_a_session_id_is_sanitised_as_cua_gym_sites_do() -> None: - from agentenv_protocol.website.store import session_id - - assert [session_id(v) for v in (None, "", "default", "../a b", "x" * 200)] == [None, None, None, "ab", "x" * 128] - store = Collections({"items": [{"id": 1}]}) - store.session("s")["items"].append({"id": 2}) - assert store.session("default") is store and store.export() == {"items": [{"id": 1}]} - assert store.session("s").diff() == {"items": {"added": [{"id": 2}], "removed": [], "changed": []}} - - def test_broad_routes_leave_the_built_in_ones_answering_their_paths(dist: Path) -> None: class Broad(site(dist)): @api.get("/{slug}") @@ -379,20 +346,6 @@ def test_each_site_keeps_its_own_session_cookie_and_reads_the_legacy_one(dist: P assert TestClient(first.asgi, cookies={SESSION_COOKIE: legacy}).get("/api/orders").json() == [{"id": 1, "user": "bob"}] -def test_no_site_s_session_choice_is_another_site_s_sign_in(dist: Path, monkeypatch) -> None: - monkeypatch.setenv("ENVIRONMENT_NAME", "notes") - notes = site(dist)() - monkeypatch.setenv("ENVIRONMENT_NAME", "notes_sid") - notes_sid = site(dist)() - browser = TestClient(notes.create_app()) - browser.post("/api/orders", params={"sid": "task-1"}, json={"sku": "mug"}) - other = TestClient(notes_sid.create_app(), cookies=browser.cookies) - other.post("/api/sign-in", json={"user": "bob"}) - back = TestClient(notes.asgi, cookies=other.cookies) - assert [o["sku"] for o in back.get("/go").json()["current_state"]["orders"]] == ["mug"] - assert TestClient(notes_sid.asgi, cookies=back.cookies).get("/api/session").json()["user"] == "bob" - - def test_sites_whose_names_spell_alike_keep_their_own_cookies(dist: Path, monkeypatch) -> None: apps = [] for name in ("shop a/1", "shop_a_1"): @@ -402,14 +355,11 @@ def test_sites_whose_names_spell_alike_keep_their_own_cookies(dist: Path, monkey apps.append(env.create_app()) browser = TestClient(apps[0]) browser.post("/api/sign-in", json={"user": "bob"}) - browser.get("/", params={"sid": "task-1"}) other = TestClient(apps[1], cookies=browser.cookies) other.post("/api/sign-in", json={"user": "alice"}) - other.get("/", params={"sid": "task-2"}) - assert len(other.cookies.keys()) == 4 + assert len(other.cookies.keys()) == 2 back = TestClient(apps[0], cookies=other.cookies) assert back.get("/api/session").json()["user"] == "bob" - assert back.cookies.get("agentenv_sid_shop_a_1~030f5785d67a") == "task-1" def test_every_answer_carries_the_universe_time(client: TestClient) -> None: diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index 93eb51cc1..ad92a50df 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -197,14 +197,6 @@ def test_the_seed_loads_exports_and_resets_through_the_data_plane(client: TestCl -def test_a_page_opened_at_a_sid_keeps_that_sessions_notes_apart(client: TestClient, env: WebsiteEnvironment) -> None: - other = TestClient(env.asgi) - other.get("/", params={"sid": "task-1"}) - created = other.post("/api/notes", json={"title": "only in task-1"}).json() - assert client.get("/go", params={"sid": "task-1"}).json()["state_diff"]["notes"]["added"] == [created] - assert client.get("/go", params={"sid": "default"}).json()["state_diff"] == {} - assert all(note["title"] != "only in task-1" for note in client.get("/api/notes").json()) - GATEWAY_PREFIX = "/website/notes" diff --git a/packages/agentenv-protocol/tests/test_website_state.py b/packages/agentenv-protocol/tests/test_website_state.py deleted file mode 100644 index b4e4ec882..000000000 --- a/packages/agentenv-protocol/tests/test_website_state.py +++ /dev/null @@ -1,132 +0,0 @@ -"""What a website env's task state is: WebsiteState, the SDK's website/v1 state, and website_state and website_sites -reading it through cards.""" - -from __future__ import annotations - -import asyncio -from pathlib import Path -from unittest.mock import patch - -import httpx -import pytest -from pydantic import ValidationError -from starlette.testclient import TestClient - -from agentenv_protocol import DataPart -from agentenv_protocol.agent_env_environment import get_data -from agentenv_protocol.client import find_extension_method -from agentenv_protocol.website import ( - STATE_PATH, - WEBSITE_EXTENSION_URI, - WEBSITE_STATE_OP, - WebsiteEnvironment, - WebsiteState, - api, - website, - website_sites, - website_state, -) - -_GW = "http://gw" - - -def _site_card(name: str, *, state: bool = True) -> dict: - methods = {"state": {"method": "GET", "endpoint": f"/svc/{name}/agentenv/ext/website/state"}} if state else {} - return {"name": name, "url": f"/svc/{name}/agentenv", "capabilities": {"extensions": [ - {"uri": WEBSITE_EXTENSION_URI, "params": {"methods": methods}}]}} - - -def _routed(handler): - real = httpx.AsyncClient - return patch.object(httpx, "AsyncClient", lambda *a, **k: real(transport=httpx.MockTransport(handler))) - - -@pytest.fixture -def shop(tmp_path: Path) -> WebsiteEnvironment: - (tmp_path / "index.html").write_text("") - - @website(domains=["shop.test"], static=str(tmp_path)) - class Shop(WebsiteEnvironment): - @api.post("/api/orders") - def place(self, order: dict) -> dict: - return self.store.insert("orders", order) - - env = Shop() - env.store.load({"orders": [{"id": 1, "sku": "pen"}]}) - env.store.mark_initial() - return env - - -def test_a_website_state_ignores_fields_it_doesnt_know_and_refuses_a_state_that_isnt_an_object() -> None: - assert WebsiteState.model_validate({"current_state": {"a": 1}, "seen_at": "now"}).model_dump() == { - "initial_state": None, "current_state": {"a": 1}, "state_diff": None} - with pytest.raises(ValidationError): - WebsiteState.model_validate({"current_state": [1]}) - - -def test_an_sdk_site_advertises_state_and_answers_its_stores_state_there_and_at_the_path_it_names( - shop: WebsiteEnvironment) -> None: - client = TestClient(shop.create_app()) - card = client.get("/.well-known/agent-env.json").json() - op = find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_STATE_OP) - params = next(e for e in card["capabilities"]["extensions"] if e["uri"] == WEBSITE_EXTENSION_URI)["params"] - assert (op["method"], op["endpoint"], params["state"]) == ("GET", "/agentenv/ext/website/state", {"path": STATE_PATH}) - client.post("/api/orders", json={"sku": "mug"}) - answer = client.get(op["endpoint"], params={"sid": "default"}).json() - assert answer == client.get(STATE_PATH).json() == {**shop.store.state(), "changed_sessions": []} - assert answer["state_diff"]["orders"]["added"][0]["sku"] == "mug" - - -def test_an_sdk_sites_state_op_answers_the_session_its_sid_names(shop: WebsiteEnvironment) -> None: - client = TestClient(shop.create_app()) - client.post("/api/orders", params={"sid": "task-1"}, json={"sku": "mug"}) - named = client.get("/agentenv/ext/website/state", params={"sid": "task-1"}).json() - assert named == client.get(STATE_PATH, params={"sid": "task-1"}).json() - assert named["state_diff"]["orders"]["added"][0]["sku"] == "mug" and named["changed_sessions"] == ["task-1"] - default = client.get("/agentenv/ext/website/state").json() - assert WebsiteState.model_validate(default).state_diff == shop.store.state()["state_diff"] - assert default["current_state"]["orders"] == [{"id": 1, "sku": "pen"}] - - -def test_a_site_with_data_hooks_of_its_own_advertises_no_state(tmp_path: Path) -> None: - @website(domains=["own.test"]) - class Own(WebsiteEnvironment): - @get_data - def export(self) -> list: - return [DataPart(data={"mine": True})] - - client = TestClient(Own().create_app()) - card = client.get("/.well-known/agent-env.json").json() - params = next(e for e in card["capabilities"]["extensions"] if e["uri"] == WEBSITE_EXTENSION_URI)["params"] - assert find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_STATE_OP) is None and "state" not in params - assert client.get("/agentenv/ext/website/state").status_code == 404 - - -def test_website_state_asks_the_cards_state_of_the_session_with_the_headers_given() -> None: - seen = [] - - def handler(request: httpx.Request) -> httpx.Response: - seen.append((request.url.path, dict(request.url.params), request.headers.get("authorization"))) - return httpx.Response(200, json={"initial_state": {}, "current_state": {"n": 1}, "state_diff": {"n": {}}}) - - with _routed(handler): - state = asyncio.run(website_state(_GW, _site_card("mail"), sid="s1", headers={"Authorization": "Bearer t"})) - assert asyncio.run(website_state(_GW, _site_card("mail", state=False))) is None - assert state == WebsiteState(initial_state={}, current_state={"n": 1}, state_diff={"n": {}}) - assert seen == [("/svc/mail/agentenv/ext/website/state", {"sid": "s1"}, "Bearer t")] - - -def test_an_answer_that_isnt_a_website_state_is_refused() -> None: - with _routed(lambda request: httpx.Response(200, json={"current_state": "full"})): - with pytest.raises(ValueError): - asyncio.run(website_state(_GW, _site_card("mail"))) - - -def test_website_sites_are_a_single_sites_own_card_or_a_composed_cards_website_children() -> None: - mail, calendar = _site_card("mail"), _site_card("calendar", state=False) - mcp = {"name": "mail", "url": "/svc/mcp-mail/agentenv", "capabilities": {"extensions": []}} - composed = {"name": "gw", "children_environments": [mail, mcp, calendar, "junk"]} - assert [(site.name, site.url) for site in website_sites(composed)] == [ - ("mail", "/svc/mail/agentenv"), ("calendar", "/svc/calendar/agentenv")] - assert [site.card for site in website_sites(mail)] == [mail] - assert website_sites({"name": "plain", "children_environments": [mcp]}) == [] From 3184f7940028ae1722ec998a75b19b5e28cdd47a Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 20:02:32 -0800 Subject: [PATCH 18/57] fix(website): a models= site's load matches a composite foreign key whole and restores enum columns as their members (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/database.py | 36 +++++++++--- .../tests/test_website_database.py | 56 +++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/database.py b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py index dbc260f02..baed53719 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/database.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py @@ -17,6 +17,7 @@ import datetime import decimal +import enum import itertools import types import uuid @@ -136,20 +137,28 @@ def reset(self) -> None: def _parents_first(table: Any, rows: list[dict]) -> list[dict]: """``rows`` in their order, but each row that refers to another row of ``table`` through a foreign key of the table's - own after that row, so a constraint that isn't deferred holds at each insert; rows in a cycle keep their order.""" - links = [(fk.parent.name, fk.column.name) for fk in table.foreign_keys if fk.column.table is table] + own after that row, each key matched whole, every column of a composite one together, so a constraint that isn't + deferred holds at each insert; rows in a cycle keep their order.""" + links = [([element.parent.name for element in constraint.elements], + [element.column.name for element in constraint.elements]) + for constraint in table.foreign_key_constraints if constraint.referred_table is table] if not links: return rows - by_value = {(referenced, row.get(referenced)): index for index, row in enumerate(rows) - for _, referenced in links if row.get(referenced) is not None} + by_key: dict = {} + for index, row in enumerate(rows): + for n, (_, referenced) in enumerate(links): + key = tuple(row.get(column) for column in referenced) + if None not in key: + by_key[(n, key)] = index ordered, placed, visiting = [], set(), set() def place(index: int) -> None: if index in placed or index in visiting: return visiting.add(index) - for local, referenced in links: - parent = by_value.get((referenced, rows[index].get(local))) + for n, (local, _) in enumerate(links): + key = tuple(rows[index].get(column) for column in local) + parent = by_key.get((n, key)) if None not in key else None if parent is not None and parent != index: place(parent) visiting.discard(index) @@ -166,8 +175,9 @@ def place(index: int) -> None: def _row(table: Any, row: dict) -> dict: - """``row`` without its computed columns' values, and with each JSON string a date, time, decimal or UUID column - holds parsed to that type.""" + """``row`` without its computed columns' values, with each JSON string a date, time, decimal or UUID column holds + parsed to that type, and each an enum column holds made its member again, found by its value, as ``export`` writes + it, else by its name.""" parsed = dict(row) for column in table.columns: if column.computed is not None: @@ -178,9 +188,19 @@ def _row(table: Any, row: dict) -> dict: kind = _python_type(column) if kind in _PARSERS: parsed[column.name] = _PARSERS[kind](value.replace("Z", "+00:00") if kind is datetime.datetime else value) + elif isinstance(kind, type) and issubclass(kind, enum.Enum): + parsed[column.name] = _member(kind, value) return parsed +def _member(kind: type[enum.Enum], value: str) -> Any: + """The member of ``kind`` whose value is ``value``, else the one so named, else ``value`` as given.""" + try: + return kind(value) + except ValueError: + return kind.__members__.get(value, value) + + def _python_type(column: Any) -> type | None: try: return column.type.python_type diff --git a/packages/agentenv-protocol/tests/test_website_database.py b/packages/agentenv-protocol/tests/test_website_database.py index 2cc5c8b8e..36fab7fd3 100644 --- a/packages/agentenv-protocol/tests/test_website_database.py +++ b/packages/agentenv-protocol/tests/test_website_database.py @@ -5,6 +5,7 @@ from __future__ import annotations import datetime +import enum import sys import types from pathlib import Path @@ -39,6 +40,11 @@ class Ticket(Base): opened_at: Mapped[datetime.datetime] = mapped_column(DateTime) +class State(enum.Enum): + OPEN = "open" + CLOSED = "closed" + + SEED = {"current_user": "ann", "users": [{"id": "ann", "name": "Ann"}], "tickets": [{"id": 1, "owner": "ann", "title": "printer", "opened_at": "2031-01-02T03:04:05"}]} @@ -156,6 +162,56 @@ def enforce(connection, record) -> None: sqlalchemy.event.remove(sqlalchemy.engine.Engine, "connect", enforce) +def test_a_composite_self_reference_loads_back_parents_first_matching_the_whole_key(dist: Path, database: str) -> None: + class Tenants(DeclarativeBase): + pass + + class Node(Tenants): + __tablename__ = "nodes" + tenant: Mapped[str] = mapped_column(String, primary_key=True) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + parent_tenant: Mapped[str | None] = mapped_column(String, nullable=True) + parent_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + __table_args__ = (sqlalchemy.ForeignKeyConstraint(["parent_tenant", "parent_id"], + ["nodes.tenant", "nodes.id"]),) + + def enforce(connection, record) -> None: + connection.execute("PRAGMA foreign_keys=ON") + + sqlalchemy.event.listen(sqlalchemy.engine.Engine, "connect", enforce) + try: + client = TestClient(helpdesk(dist, Tenants)().create_app()) + rows = [{"tenant": t, "id": i, "parent_tenant": None, "parent_id": None} for t, i in (("b", 2), ("b", 5), ("c", 2))] + rows.append({"tenant": "a", "id": 1, "parent_tenant": "b", "parent_id": 2}) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": {"nodes": rows}}]}) + exported = rpc(client, "data/get")["parts"][0]["data"] + assert [(row["tenant"], row["id"]) for row in exported["nodes"]] == [("a", 1), ("b", 2), ("b", 5), ("c", 2)] + rpc(client, "data/reset") + rpc(client, "data/add", {"parts": [{"kind": "data", "data": exported}]}) + assert rpc(client, "data/get")["parts"][0]["data"] == exported + finally: + sqlalchemy.event.remove(sqlalchemy.engine.Engine, "connect", enforce) + + +def test_an_export_of_an_enum_column_loads_back_as_its_members(dist: Path, database: str) -> None: + class Desk(DeclarativeBase): + pass + + class Issue(Desk): + __tablename__ = "issues" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + state: Mapped[State] = mapped_column(sqlalchemy.Enum(State)) + + client = TestClient(helpdesk(dist, Desk)().create_app()) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": {"issues": [{"id": 1, "state": "open"}, + {"id": 2, "state": "CLOSED"}]}}]}) + exported = rpc(client, "data/get")["parts"][0]["data"] + assert exported == {"issues": [{"id": 1, "state": "open"}, {"id": 2, "state": "closed"}]} + rpc(client, "data/reset") + rpc(client, "data/add", {"parts": [{"kind": "data", "data": exported}]}) + assert rpc(client, "data/get")["parts"][0]["data"] == exported + + def test_a_row_the_gui_adds_is_the_mcp_servers_and_data_get_reports_it(dist: Path, database: str) -> None: env = helpdesk(dist, Base)() client = TestClient(env.create_app()) From e7590246c2cf90d4667c892edebb95d3b9b46894 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 20:54:13 -0800 Subject: [PATCH 19/57] fix(website): a models= site orders a self-referencing chain of any length parents first, without recursing (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/database.py | 33 ++++++++++++------- .../tests/test_website_database.py | 16 +++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/database.py b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py index baed53719..2e18d002b 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/database.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py @@ -138,7 +138,8 @@ def reset(self) -> None: def _parents_first(table: Any, rows: list[dict]) -> list[dict]: """``rows`` in their order, but each row that refers to another row of ``table`` through a foreign key of the table's own after that row, each key matched whole, every column of a composite one together, so a constraint that isn't - deferred holds at each insert; rows in a cycle keep their order.""" + deferred holds at each insert; rows in a cycle keep their order. The walk keeps its own stack, so a chain of any + length is ordered.""" links = [([element.parent.name for element in constraint.elements], [element.column.name for element in constraint.elements]) for constraint in table.foreign_key_constraints if constraint.referred_table is table] @@ -152,21 +153,31 @@ def _parents_first(table: Any, rows: list[dict]) -> list[dict]: by_key[(n, key)] = index ordered, placed, visiting = [], set(), set() - def place(index: int) -> None: - if index in placed or index in visiting: - return - visiting.add(index) + def parents(index: int) -> list[int]: + found = [] for n, (local, _) in enumerate(links): key = tuple(rows[index].get(column) for column in local) parent = by_key.get((n, key)) if None not in key else None if parent is not None and parent != index: - place(parent) - visiting.discard(index) - placed.add(index) - ordered.append(rows[index]) + found.append(parent) + return found - for index in range(len(rows)): - place(index) + for start in range(len(rows)): + if start in placed: + continue + visiting.add(start) + stack = [(start, iter(parents(start)))] + while stack: + index, pending = stack[-1] + parent = next((p for p in pending if p not in placed and p not in visiting), None) + if parent is not None: + visiting.add(parent) + stack.append((parent, iter(parents(parent)))) + continue + stack.pop() + visiting.discard(index) + placed.add(index) + ordered.append(rows[index]) return ordered diff --git a/packages/agentenv-protocol/tests/test_website_database.py b/packages/agentenv-protocol/tests/test_website_database.py index 36fab7fd3..1c623a482 100644 --- a/packages/agentenv-protocol/tests/test_website_database.py +++ b/packages/agentenv-protocol/tests/test_website_database.py @@ -162,6 +162,22 @@ def enforce(connection, record) -> None: sqlalchemy.event.remove(sqlalchemy.engine.Engine, "connect", enforce) +def test_a_chain_longer_than_the_recursion_limit_is_ordered_parents_first() -> None: + import sys + + from agentenv_protocol.website.database import _parents_first + + metadata = sqlalchemy.MetaData() + table = sqlalchemy.Table("notes", metadata, sqlalchemy.Column("id", Integer, primary_key=True), + sqlalchemy.Column("parent_id", Integer, ForeignKey("notes.id"), nullable=True)) + count = sys.getrecursionlimit() * 2 + rows = [{"id": i, "parent_id": i + 1 if i + 1 < count else None} for i in range(count)] + ordered = _parents_first(table, rows) + assert [row["id"] for row in ordered] == list(range(count - 1, -1, -1)) + cycle = [{"id": 1, "parent_id": 2}, {"id": 2, "parent_id": 1}] + assert sorted(row["id"] for row in _parents_first(table, cycle)) == [1, 2] + + def test_a_composite_self_reference_loads_back_parents_first_matching_the_whole_key(dist: Path, database: str) -> None: class Tenants(DeclarativeBase): pass From 7dceec22319d77a5c042858cdbf5179b4afa9929 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 13:34:08 -0800 Subject: [PATCH 20/57] feat(website): one spec declares a site, composes it and checks any server (FD-4683) This PR's change, rebased as one commit onto #444. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agentenv-protocol/docs/website-spec.md | 371 ++++++ .../examples/website/notes/Dockerfile | 2 +- .../examples/website/notes/README.md | 28 +- .../examples/website/notes/env.toml | 3 +- .../examples/website/notes/site.py | 4 +- .../examples/website/notes/website.toml | 41 + .../website/specs/components/sso.toml | 19 + .../examples/website/specs/intranet-ben.toml | 8 + .../examples/website/specs/intranet.toml | 30 + .../examples/website/specs/slack-live.toml | 23 + .../examples/website/specs/slack.toml | 11 + packages/agentenv-protocol/pyproject.toml | 1 + .../src/agentenv_protocol/website/__init__.py | 5 +- .../agentenv_protocol/website/conformance.py | 162 ++- .../agentenv_protocol/website/environment.py | 87 +- .../website/spec/__init__.py | 1053 +++++++++++++++++ .../website/spec/__main__.py | 5 + .../tests/test_website_notes_example.py | 4 +- .../tests/test_website_spec.py | 879 ++++++++++++++ src/agent_env/env/envs/website.py | 59 +- tst/unit/bundle/env_toml_test.py | 39 + uv.lock | 1 + 22 files changed, 2783 insertions(+), 52 deletions(-) create mode 100644 packages/agentenv-protocol/docs/website-spec.md create mode 100644 packages/agentenv-protocol/examples/website/notes/website.toml create mode 100644 packages/agentenv-protocol/examples/website/specs/components/sso.toml create mode 100644 packages/agentenv-protocol/examples/website/specs/intranet-ben.toml create mode 100644 packages/agentenv-protocol/examples/website/specs/intranet.toml create mode 100644 packages/agentenv-protocol/examples/website/specs/slack-live.toml create mode 100644 packages/agentenv-protocol/examples/website/specs/slack.toml create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/spec/__init__.py create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/spec/__main__.py create mode 100644 packages/agentenv-protocol/tests/test_website_spec.py diff --git a/packages/agentenv-protocol/docs/website-spec.md b/packages/agentenv-protocol/docs/website-spec.md new file mode 100644 index 000000000..ba54fa090 --- /dev/null +++ b/packages/agentenv-protocol/docs/website-spec.md @@ -0,0 +1,371 @@ +# The website spec + +A website env used to be described in three places: its `env.toml` (`type = "website"`, `health`, `backend_image`), +the decorators in its code (`@website(domains, static, health, data)` and `@api` routes with names and roles), and the +card it serves (the `urn:agentenv:website/v1` extension: domains, health, routes, session cookie). The website spec +is one declaration of a site. Each way of serving a site either produces a spec or is checked against one, and specs +compose, so a site can be assembled from parts. + +The code is `agentenv_protocol.website.spec`. The JSON Schema of a spec file is +`python -m agentenv_protocol.website.spec schema`. + +## Shape + +A spec is a TOML file. Its top-level keys are fixed and every section is optional: + +| Section | What it says | +| --- | --- | +| `extends` | The path of a spec this one overlays (see [Overlays](#overlays)). | +| `[site]` | `name`, the card's name; `domains`, the host names the site answers for, with `*.` as the leftmost label matching any subdomain; `health`, the path that answers 200 once the site is usable (`/api/health` by default), read only for a server whose card has no `website/v1` `ready`, which is deprecated: a deploy and the conformance check read readiness from the card. | +| `[source]` | What serves the site, exactly one of `image`, `sdk`, `static`, `live` or `replay` (see [Sources](#sources)). | +| `[data]` | `collections` (each with `key`, `owner` and an optional JSON `schema`), or one `schema` of the whole state, or `models = "mcp:"` to bind the site's data to an MCP server's schema; and `seed`, the files it starts with, in order. | +| `[users]` | `personas` (ids, or `{id, name, role}` tables); `acting_user`, who a browser with no session is; `sign_in`, which is `cookie` (the site's signed session cookie, by default), `none` or `live`; and, for a cookie sign-in by a server not built on the SDK, its `session_cookie` and `session_path`. | +| `[roles]` | A role name mapped to the routes it may call. These roles are added to each route's own `roles`. | +| `[routes]` | `ops`, the named operations, each `name = "METHOD /path"` or a table `{method, path, roles, served_by}`; and `openapi`, an OpenAPI document whose operations, named by `operationId` (each name once) with roles from `x-agentenv-roles`, are routes too. Triggers and records call these names `op`. | +| `[state]` | How a grader reads the site: `read = "data_plane"` (`data/get`), `"go"` (`GET `, `/go` by default, the session's `{initial_state, current_state, state_diff}`), or `"diff"` (the writes a run made, as the env's proxy recorded them). | +| `[clock]` | How the site follows the universe's time: `follow = "header"` (`X-Universe-Now` on every answer), `"date_patch"` (its pages' `Date` is shifted too), or `"none"` (wall time). | +| `[[uses]]` | Components: another spec mounted under a path prefix (see [Composition](#composition)). | + +Paths in a spec (Dockerfiles, folders, seeds, schemas, captures, the OpenAPI document and components) are relative to +the spec file. + +The Notes example's spec, `examples/website/notes/website.toml`: + +```toml +[site] +name = "notes" +domains = ["notes.test"] +health = "/healthz" + +[source] +sdk = "site:Notes" +assets = "web/dist" + +[data] +seed = ["data/seed.json"] + +[data.collections.users] + +[data.collections.notes] +owner = "owner" + +[users] +personas = [{ id = "ann", name = "Ann Lee" }, { id = "ben", name = "Ben Ortiz" }] +acting_user = "ann" +sign_in = "cookie" + +[roles] +admin = ["all_notes"] + +[routes.ops] +list_users = "GET /api/users" +sign_in = "POST /api/sign-in" +list_notes = "GET /api/notes" +create_note = "POST /api/notes" +get_note = "GET /api/notes/{note_id}" +edit_note = "PATCH /api/notes/{note_id}" +toggle_note = "POST /api/notes/{note_id}/toggle" +delete_note = "DELETE /api/notes/{note_id}" +all_notes = "GET /api/admin/notes" + +[state] +read = "go" + +[clock] +follow = "header" +``` + +## Validation + +`load(path)` reads a spec, resolves `extends` and `uses`, and validates the result. If anything is wrong, it raises +`SpecError`, whose `problems` lists every problem as `: : `. For example, +`.../shop.toml: routes.ops: b and a are both GET /x`. The checks within a section are: unknown keys, exactly one +source, lower-case host names, health and route paths outside the data plane's `/agentenv` and the card's +`/.well-known/agent-env.json`, credentials that are `secret:`/`env:` references rather than values, and an +`acting_user` that is one of the `personas`. + +The checks across sections are: + +- No two routes share a verb and path, and no GET route is served at the health path. +- `[roles]` names only routes that exist, including the namespaced routes of components. +- `sign_in = "live"` needs a `live` or `replay` source. +- A `live` or `replay` site is read as `diff`, and only such a site is. +- An `sdk` site follows the clock, so `follow = "none"` is refused for it. +- A `static` site serves no routes. +- `served_by` is allowed only on a route of a live or replayed site. +- A collection with an `owner` needs a site that signs users in. + +`python -m agentenv_protocol.website.spec validate PATH` runs these checks. `show PATH` prints the resolved spec as +JSON, and `show --card PATH` prints the card params it advertises. + +## Sources + +Every source maps onto the same spec. Each row below says how a source is described, what it produces, and what it is +checked against. + +| Source | `[source]` | Where the spec comes from | Deployable today | +| --- | --- | --- | --- | +| SDK class | `sdk = "module:Class"`, `assets = ""` | `@website(spec="website.toml")` declares the class from the spec, and the class is refused if it doesn't serve what the spec declares. `from_environment(cls)` derives a spec from the decorators of a class declared the old way. | Yes, as a website env's server image | +| Any-stack image | `image = ""` or `image = { dockerfile, context }` | Written by hand. `conformance --spec` checks the running server against it. | Yes, through `backend_image` in env.toml | +| Static | `static = ""` | Written by hand. | Modeled only: no generic static server image ships yet | +| Live real site | `live = { allow_hosts, credentials }` | Written by hand; `from_card` reads one back from a proxy that serves a card. | Modeled only; a website env refuses it | +| Replayed capture | `replay = { capture, allow_hosts }` | Usually an overlay of the live spec. | Modeled only; a website env refuses it | + +For every source, `to_card_extension()` returns the `urn:agentenv:website/v1` params a server of the spec advertises: +`domains`, `health` and `routes` (each `{method, path, name, roles}`); `session_cookie` and `session_path` for a cookie +sign-in; and `state` and `clock` when they are declared. A WebsiteEnvironment builds its card from these params, so +the card and the spec cannot drift apart. `from_card(card)` reads a running site's card back into a spec. It recovers +the site, routes, sign-in, state and clock. A card does not carry a site's source, data, personas or the +`[roles]` table; roles are folded into each route. + +### The SDK + +```python +@website(spec="website.toml") +class Notes(WebsiteEnvironment): + ... +``` + +`@website(spec=...)` takes a path relative to the class's file, or a loaded `WebsiteSpec`. The spec supplies the +domains, health path, static folder (`assets`), seed files and clock patch. If any of those are also passed to +`@website`, it is refused. When the class is declared, it is checked against the spec: + +- Its `@api` routes must match the spec's routes by name, verb, path and roles, whenever the spec declares routes. +- `source.sdk` must name the class. +- The sign-in must be the SDK's cookie. +- The state read must be how the class is read. +- `acting_user` must be the seed's `current_user`. + +If the class has no `@environment_card` name, the card is named by `site.name`. A card name that differs from +`site.name` is refused. Classes declared with `@website(domains=..., ...)` work as before; their card goes through +`from_environment(cls).to_card_extension()`. + +### env.toml + +A website env's `env.toml` can point to a spec with `spec = "website.toml"`, or carry one inline as a `[spec]` +table: + +```toml +type = "website" +spec = "website.toml" +backend_image = { dockerfile = "Dockerfile" } +``` + +The env takes its `environment_name` from `site.name` and its health path from `site.health` when the env.toml leaves +them out. If the env.toml sets one to a different value, the env is refused. The env stores the spec's domains +(`WebsiteEnv.domains`). A spec with a `live` or `replay` source is refused, because a server image can't serve it. +`backend_image` still says how the image is built. + +### Conformance + +`python -m agentenv_protocol.website.conformance --spec website.toml` runs the usual checks plus two more: + +- `spec` checks that the card's name, domains, health path, routes, session cookie and session path are the spec's, + and its `state` and `clock` too when the spec declares them. +- `spec_routes` checks that every declared route is served. An `OPTIONS` to the route's path, with each path parameter + filled in by a value the converter of the card's route of that name takes (a UUID for `uuid`, else `0`), must find + something there; a GET route without path parameters must answer neither 404 nor 405; and each + route must be an operation of the server's OpenAPI document when its card offers one. When the card offers no + document, a route only the `OPTIONS` can check (a non-GET route, or a GET route with path parameters) counts as + served only when that `OPTIONS` answers below 500 with an `Allow` listing its method. Any other answer, such as a + catch-all `OPTIONS` handler's 200 without `Allow`, a 500, or an `Allow` that doesn't list the method, leaves the + route unverified: the check is then a `warning` (a failure with `--strict`) rather than a pass, and fails only + when a route is shown missing. Only GET routes are called directly, so the check changes no state. + +The `health` check reads readiness from the card's `website/v1` `ready`. For a card without it, it reads the health path, which is deprecated: with `--spec`, the spec's rather than the card's. + +## Overlays + +`extends = ""` merges this spec over the one it names. The merge works like this: + +- Tables merge key by key, recursively. +- Arrays and scalars replace. For example, `domains = [...]` or `personas = [...]` in the overlay replace the base's. +- `[source]` replaces as a whole, so an overlay can turn a live site into a replay without leftover keys. +- `name = false` removes an inherited entry of `[routes.ops]`, `[roles]` or `[data.collections]`. A route removed + this way is removed from the operations of the `openapi` document too. +- A route written as `"METHOD /path"` is expanded to a table before merging, so an overlay can change one key of it, + such as `post_message = { served_by = "..." }`. An operation of the `openapi` document is one too: a table naming it, + such as `add_item = { roles = ["staff"] }`, is written over the document's operation. +- `[[uses]]` entries are added. An entry with the same `as` (or, without one, the same `spec`) replaces the inherited + entry. +- The base's relative paths are rewritten relative to the overlay's folder, so a base in another folder still finds + its files. + +`extends` chains are followed, and a cycle is refused. In code, `spec.overlay({...})` applies the same merge to a +loaded spec. The Notes test uses it to swap in a stand-in static folder. + +Overlays are how one site gets variants: + +- A frozen capture with one synthetic route: `examples/website/specs/slack.toml` extends `slack-live.toml`, replaces + the live source with a replay of a capture, and swaps `post_message` to an SDK class. +- The same site with another persona set: `examples/website/specs/intranet-ben.toml` signs in as `ben`, with personas + `ben` and `cy`, and drops the `editor` role. + +```toml +# slack-live.toml +[site] +name = "slack" +domains = ["slack.com", "*.slack.com"] +health = "/api/api.test" + +[source.live] +allow_hosts = ["*.slack-edge.com", "*.slack-imgs.com"] +credentials = { token = "secret:SLACK_USER_TOKEN" } + +[users] +sign_in = "live" + +[routes.ops] +list_conversations = "GET /api/conversations.list" +history = "GET /api/conversations.history" +post_message = "POST /api/chat.postMessage" +add_reaction = "POST /api/reactions.add" + +[state] +read = "diff" + +[clock] +follow = "none" +``` + +```toml +# slack.toml: the live site frozen as a replay, one route answered by a synthetic implementation +extends = "slack-live.toml" + +[source.replay] +capture = "captures/slack-workspace.har" +allow_hosts = ["*.slack-edge.com"] + +[routes.ops] +post_message = { served_by = "slack_synthetic:SlackChat" } + +[clock] +follow = "date_patch" +``` + +## Composition + +A `[[uses]]` entry mounts another spec as a component: + +```toml +[[uses]] +spec = "components/sso.toml" # relative to this spec +mount = "/sso" # the path prefix its routes are served under +as = "sso" # its namespace; the component's site.name by default +data = { accounts = "users" } # a collection of the component's, mapped onto one of this site's +``` + +When a spec is resolved, each component is resolved first, including its own `extends` and `uses`. It is then merged +into the site: + +- **Routes.** Each of the component's routes is served at `` and named `.`. The roles its own + `[roles]` grants come with it. The site's `[roles]` can name the composed routes, for example + `editor = ["sso.login"]`. +- **Data.** A collection named in `data` merges into the site's collection of that name. They must agree on `key`, + `owner` and `schema` where both set them. Any other collection is added as `.`. A schema file a + component names stays the component's file, its path made relative to the site's spec. A component's seed files + are not merged: the site seeds the shared collections. +- **Users.** The component's personas are added to the site's, merging the fields of a persona they share; fields + that contradict are a conflict. A component must sign in the way the site does, or not at all (`none`). +- **The rest.** The component's `site` (domains, health), `source`, `state` and `clock` are not merged. A mounted + component lives under the site's own domains and is served by the site's source. + +These are conflicts, each reported as `uses[]: ...`: + +- A composed route name that the site already declares. +- A composed verb and path that another route already serves. +- A route of the site's own under the component's mount prefix (unless the mount is `/`). +- A `data` mapping of a collection the component doesn't declare. +- A collection or persona that disagrees with the site's. +- A sign-in mismatch. +- A component with no name. +- A cycle of `uses`. + +A resolved spec has no `extends`, `uses` or `routes.openapi` left: the document's operations are in `routes.ops`. +`spec.components` lists what was mounted, each with its name, mount and own resolved spec. `to_dict()` of a resolved +spec reads back as the same spec. + +`examples/website/specs/intranet.toml` uses a fake SSO component, `components/sso.toml`, mounted at `/sso`. The +component's `accounts` collection is mapped onto the intranet's `users`, and its `grants` collection becomes +`sso.grants`: + +```toml +[site] +name = "intranet" +domains = ["intranet.test", "*.intranet.test"] + +[source.image] +dockerfile = "Dockerfile.intranet" + +[data.collections.users] + +[data.collections.pages] +owner = "author" + +[users] +personas = ["ann", "ben"] +acting_user = "ann" + +[roles] +editor = ["publish_page"] + +[routes.ops] +list_pages = "GET /api/pages" +publish_page = "POST /api/pages" + +[[uses]] +spec = "components/sso.toml" +mount = "/sso" +data = { accounts = "users" } + +[state] +read = "go" +``` + +Resolved, its routes are `list_pages`, `publish_page`, `sso.authorize` (`GET /sso/authorize`), `sso.login`, +`sso.token` and `sso.userinfo`, and its collections are `users`, `pages` and `sso.grants`. + +## The name table + +A multi env routes every host name its sites answer for through one table. `name_table(specs)` builds it: + +- **Kinds.** A site's `domains` are `synthetic` when the env serves the site (an image, sdk, static or replay source) + and `live` when the site is the real one. A live or replay source's `allow_hosts` are `allowlist`: hosts let through + to the internet, not served. +- **Precedence.** When two kinds claim one pattern, the higher kind wins: synthetic, then live, then allowlist. The + loser is kept in `shadowed` beside the entry it lost to. This is how a synthetic overlay takes over a name a live + site or an allowlist also claims. +- **Resolution.** `table.resolve(host)` looks first at the highest kind of the entries matching the host, then at the + most specific pattern of that kind: an exact name, then the longest wildcard. So a synthetic `*.slack.com` beats a + live `status.slack.com`. A synthetic `inbox.example.test` beats a synthetic `*.example.test` of another site. +- **Wildcards.** `*.example.test` matches every subdomain at any depth, but not `example.test` itself. + +These are conflicts, each a `SpecError`: + +- Two sites claim the same pattern at the same kind, synthetic or live, whichever kind wins the pattern. A host that + two different patterns match (`*.example.test` and `inbox.example.test`) is not a conflict, because specificity + decides it. +- A site has no `site.name`. +- Two sites share a name. + +Two sites allowlisting the same host is not a conflict, because an allowlist only lets traffic through. + +## What is wired and what is only modeled + +These parts are wired: + +- The spec model, loader, validation, `extends`, `uses`, the card params, `from_card`, `from_environment`, the name + table, the JSON Schema and the command line. +- `@website(spec=...)` and the SDK card generated through the spec. +- `spec` in a website env's env.toml (name, health and domains). +- `conformance --spec`. + +These parts are only modeled: they are declared and validated, but nothing serves them yet. + +- The `static`, `live` and `replay` sources, and route `served_by`. +- `[data] models` and collection schemas. +- `state.read = "diff"`. +- Mounting a component's implementation. An SDK class that uses a component must serve its routes itself. +- The name table's use by the gateway. +- Component references other than file paths. +- The env's stored domains: deploy doesn't yet check them against the card. diff --git a/packages/agentenv-protocol/examples/website/notes/Dockerfile b/packages/agentenv-protocol/examples/website/notes/Dockerfile index 461e886b2..f8896a549 100644 --- a/packages/agentenv-protocol/examples/website/notes/Dockerfile +++ b/packages/agentenv-protocol/examples/website/notes/Dockerfile @@ -14,7 +14,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* COPY packages/agentenv-protocol /tmp/agentenv-protocol RUN pip install --no-cache-dir /tmp/agentenv-protocol "uvicorn>=0.30" && rm -rf /tmp/agentenv-protocol -COPY ${NOTES}/site.py /app/site.py +COPY ${NOTES}/site.py ${NOTES}/website.toml /app/ COPY ${NOTES}/data /app/data COPY --from=web /web/dist /app/web/dist ENV PORT=8000 diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index 4f0c590d3..6bd558790 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -6,19 +6,23 @@ them. The page shows the universe's time. | File | What it is | | --- | --- | -| `site.py` | The server: the `Notes` WebsiteEnvironment, its `@api` routes, and `serve()` on `PORT` (8000). | +| `website.toml` | The site's spec: its name, domains, health path, source, seed, collections, personas, roles, routes, state read and clock (see `docs/website-spec.md`). | +| `site.py` | The server: the `Notes` WebsiteEnvironment, declared from `website.toml`, its `@api` routes, and `serve()` on `PORT` (8000). | | `data/seed.json` | The seed data: two users (`ann`, `ben`), their notes, and `current_user`, the acting user. | | `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike; opened at a path without its final slash, such as `/website/notes`, the page first reloads itself with it. | | `Dockerfile` | The server image: Node builds `web/`, then Python installs this repo's `packages/agentenv-protocol` with uvicorn. It installs `curl`, which the gateway's compose health check runs. | -| `env.toml` | The website env as a bundle entry. | +| `env.toml` | The website env as a bundle entry, pointing at `website.toml`. | ## What it exercises -- **Site declaration.** `@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json")`. - The card advertises website/v1 with the domains, the health path and every route by name. +- **Site declaration.** `@website(spec="website.toml")`. The spec gives the name, domains, static folder, health path + and seed, and the class is refused when its `@api` routes aren't the spec's. The card's website/v1 params are the + spec's `to_card_extension()`: the domains, the health path, every route by name, the session, the state read and + the clock. - **Routes.** `list_users`, `sign_in`, `list_notes` (with an optional `?done=`), `create_note` (201), `get_note`, `edit_note`, `toggle_note` and `delete_note`. Errors are raised as `NotFound`, `BadRequest` and `Unauthorized`. -- **Roles.** `all_notes` (`GET /api/admin/notes`) is declared with `roles=["admin"]`. It answers only a caller that +- **Roles.** `all_notes` (`GET /api/admin/notes`) is declared with `roles=["admin"]`, and the spec's `[roles]` grants + it to `admin`. It answers only a caller that sends `AgentEnv-Role: admin` with `AgentEnv-Role-Key` equal to the container's `WEBSITE_ROLE_KEY`; any other caller gets 403. A deploy with `keyed_roles` gives the site its own role key as `WEBSITE_ROLE_KEY` and its gateway sends it on only beside a role it verified with the same key, so a page cannot claim `admin` through it. Any other deploy sets @@ -71,9 +75,11 @@ python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-h curl -s http://localhost:18080/api/notes ``` -Use `--clock-host` so the container can reach the checker's clock. `--strict` fails on a recommended check's warning -too (the card, `X-Universe-Now`, the clock, forwarded headers, cookies); without it only the contract's required checks -fail the run, and Notes passes every check either way. The clock check leaves the server following a +Use `--clock-host` so the container can reach the checker's clock, and add +`--spec packages/agentenv-protocol/examples/website/notes/website.toml` to check the server against its spec too. +`--strict` fails on a recommended check's warning too (the card, `X-Universe-Now`, the clock, forwarded headers, +cookies); without it only the contract's required checks, and the spec's when given, fail the run, and Notes passes +every check either way. The clock check leaves the server following a clock that is gone once the check ends: later timestamps start from 2031 until the server is synced again, so restart the container before using it. @@ -85,10 +91,10 @@ agent-env env website put --id notes-site \ --server-docker-context . --health-path /healthz ``` -Run it from the repo root. `environment_name` comes from the card (`@environment_card(name="notes")`). The stored env +Run it from the repo root. `environment_name` comes from the card, which the spec names `notes`. The stored env has a server image and no frontend image. Pass `--skip-validation` to register without deploying. -`env.toml` describes the same env for a bundle. Its keys are `type`, `environment_name`, `health` and -`backend_image`. Domains are not one of them: they come from the card. A bundle builds `backend_image` with this +`env.toml` describes the same env for a bundle. Its keys are `type`, `spec` and `backend_image`; the env takes its +`environment_name`, health path and domains from the spec. A bundle builds `backend_image` with this folder as the build context, but this Dockerfile needs the repo root. Until the protocol package is installed from a published release instead, register the site with `put` as above. diff --git a/packages/agentenv-protocol/examples/website/notes/env.toml b/packages/agentenv-protocol/examples/website/notes/env.toml index a1d019251..483d79d77 100644 --- a/packages/agentenv-protocol/examples/website/notes/env.toml +++ b/packages/agentenv-protocol/examples/website/notes/env.toml @@ -1,4 +1,3 @@ type = "website" -environment_name = "notes" -health = "/healthz" +spec = "website.toml" backend_image = { dockerfile = "Dockerfile" } diff --git a/packages/agentenv-protocol/examples/website/notes/site.py b/packages/agentenv-protocol/examples/website/notes/site.py index 5eaafda0e..777c83c40 100644 --- a/packages/agentenv-protocol/examples/website/notes/site.py +++ b/packages/agentenv-protocol/examples/website/notes/site.py @@ -11,7 +11,6 @@ from pydantic import BaseModel -from agentenv_protocol import environment_card from agentenv_protocol.website import ( ApiContext, BadRequest, @@ -43,8 +42,7 @@ class SignIn(BaseModel): user: str -@environment_card(name="notes") -@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json") +@website(spec="website.toml") class Notes(WebsiteEnvironment): @ready_check def seeded(self) -> bool: diff --git a/packages/agentenv-protocol/examples/website/notes/website.toml b/packages/agentenv-protocol/examples/website/notes/website.toml new file mode 100644 index 000000000..9edc2e635 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/website.toml @@ -0,0 +1,41 @@ +[site] +name = "notes" +domains = ["notes.test"] +health = "/healthz" + +[source] +sdk = "site:Notes" +assets = "web/dist" + +[data] +seed = ["data/seed.json"] + +[data.collections.users] + +[data.collections.notes] +owner = "owner" + +[users] +personas = [{ id = "ann", name = "Ann Lee" }, { id = "ben", name = "Ben Ortiz" }] +acting_user = "ann" +sign_in = "cookie" + +[roles] +admin = ["all_notes"] + +[routes.ops] +list_users = "GET /api/users" +sign_in = "POST /api/sign-in" +list_notes = "GET /api/notes" +create_note = "POST /api/notes" +get_note = "GET /api/notes/{note_id}" +edit_note = "PATCH /api/notes/{note_id}" +toggle_note = "POST /api/notes/{note_id}/toggle" +delete_note = "DELETE /api/notes/{note_id}" +all_notes = "GET /api/admin/notes" + +[state] +read = "go" + +[clock] +follow = "header" diff --git a/packages/agentenv-protocol/examples/website/specs/components/sso.toml b/packages/agentenv-protocol/examples/website/specs/components/sso.toml new file mode 100644 index 000000000..5d5594985 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/specs/components/sso.toml @@ -0,0 +1,19 @@ +[site] +name = "sso" + +[source] +sdk = "fake_sso:FakeSso" + +[data.collections.accounts] + +[data.collections.grants] +owner = "user" + +[users] +sign_in = "cookie" + +[routes.ops] +authorize = "GET /authorize" +login = "POST /login" +token = "POST /token" +userinfo = "GET /userinfo" diff --git a/packages/agentenv-protocol/examples/website/specs/intranet-ben.toml b/packages/agentenv-protocol/examples/website/specs/intranet-ben.toml new file mode 100644 index 000000000..7349d418e --- /dev/null +++ b/packages/agentenv-protocol/examples/website/specs/intranet-ben.toml @@ -0,0 +1,8 @@ +extends = "intranet.toml" + +[users] +personas = ["ben", "cy"] +acting_user = "ben" + +[roles] +editor = false diff --git a/packages/agentenv-protocol/examples/website/specs/intranet.toml b/packages/agentenv-protocol/examples/website/specs/intranet.toml new file mode 100644 index 000000000..dbf7ae1ed --- /dev/null +++ b/packages/agentenv-protocol/examples/website/specs/intranet.toml @@ -0,0 +1,30 @@ +[site] +name = "intranet" +domains = ["intranet.test", "*.intranet.test"] + +[source.image] +dockerfile = "Dockerfile.intranet" + +[data.collections.users] + +[data.collections.pages] +owner = "author" + +[users] +personas = ["ann", "ben"] +acting_user = "ann" + +[roles] +editor = ["publish_page"] + +[routes.ops] +list_pages = "GET /api/pages" +publish_page = "POST /api/pages" + +[[uses]] +spec = "components/sso.toml" +mount = "/sso" +data = { accounts = "users" } + +[state] +read = "go" diff --git a/packages/agentenv-protocol/examples/website/specs/slack-live.toml b/packages/agentenv-protocol/examples/website/specs/slack-live.toml new file mode 100644 index 000000000..54b9784d2 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/specs/slack-live.toml @@ -0,0 +1,23 @@ +[site] +name = "slack" +domains = ["slack.com", "*.slack.com"] +health = "/api/api.test" + +[source.live] +allow_hosts = ["*.slack-edge.com", "*.slack-imgs.com"] +credentials = { token = "secret:SLACK_USER_TOKEN" } + +[users] +sign_in = "live" + +[routes.ops] +list_conversations = "GET /api/conversations.list" +history = "GET /api/conversations.history" +post_message = "POST /api/chat.postMessage" +add_reaction = "POST /api/reactions.add" + +[state] +read = "diff" + +[clock] +follow = "none" diff --git a/packages/agentenv-protocol/examples/website/specs/slack.toml b/packages/agentenv-protocol/examples/website/specs/slack.toml new file mode 100644 index 000000000..77635c391 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/specs/slack.toml @@ -0,0 +1,11 @@ +extends = "slack-live.toml" + +[source.replay] +capture = "captures/slack-workspace.har" +allow_hosts = ["*.slack-edge.com"] + +[routes.ops] +post_message = { served_by = "slack_synthetic:SlackChat" } + +[clock] +follow = "date_patch" diff --git a/packages/agentenv-protocol/pyproject.toml b/packages/agentenv-protocol/pyproject.toml index 17492f651..91e2985b0 100644 --- a/packages/agentenv-protocol/pyproject.toml +++ b/packages/agentenv-protocol/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "pydantic>=2.11", "starlette>=0.37", "httpx>=0.27", + "tomli>=1.1; python_version < '3.11'", ] [project.optional-dependencies] diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py index 1ffa4d454..455f9010c 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py @@ -1,6 +1,7 @@ """The website SDK: a site's server, meeting ``urn:agentenv:website/v1``. Subclass WebsiteEnvironment, declare the site with ``@website(domains=[...], static="dist/")``, mark its API with ``@api.get/post/put/patch/delete(path)``, and -``serve()`` it; it needs only the plain package, and uvicorn to serve. The class is optional: a server in any language +``serve()`` it, or declare it from a website spec with ``@website(spec="website.toml")``; it needs only the plain +package, and uvicorn to serve. The class is optional: a server in any language that meets the contract works, which ``python -m agentenv_protocol.website.conformance `` checks. Whether a site is ready is its ``WebsiteReadiness`` answer to ``ready``, which ``website_readiness`` reads of one site and ``env_readiness`` of every site of an env. What a task left a site with is its data plane's ``data/get``.""" @@ -20,12 +21,14 @@ from .readiness import READY_TIMEOUT_S, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness, env_readiness, website_readiness from .routes import ROLE_KEY_ENV, ROLE_KEY_HEADER, ApiContext, ApiRoute, api, reserved_path from .session import SESSION_COOKIE, SessionSigner +from .spec import NameTable, SpecError, WebsiteSpec, from_card, from_environment, load as load_spec, name_table from .store import Collections __all__ = [ "WebsiteEnvironment", "WebsiteDeclaration", "website", "api", "ApiContext", "ApiRoute", "ready_check", "ApiError", "BadRequest", "Unauthorized", "Forbidden", "NotFound", "Conflict", "Collections", "UniverseClock", "SessionSigner", "reserved_path", + "WebsiteSpec", "SpecError", "NameTable", "load_spec", "from_card", "from_environment", "name_table", "WEBSITE_EXTENSION_URI", "CLOCK_EXTENSION_URI", "UNIVERSE_NOW_HEADER", "DEFAULT_HEALTH_PATH", "SESSION_PATH", "SESSION_COOKIE", "ROLE_KEY_ENV", "ROLE_KEY_HEADER", "WEBSITE_READY_OP", "READY_TIMEOUT_S", "WebsiteReadiness", "website_readiness", "env_readiness", diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py index d5bc89c26..e88d3280c 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -13,6 +13,13 @@ headers wherever an answer shows them (on ``/`` and on a GET route with its trailing slash toggled, which a server may redirect), and cookies tied to no domain, the session path's the one the card names. A failed one is a ``warning``. +With ``--spec``, a website spec, the server is checked against it too, and those checks are required: its card's name, +domains, health path, routes and session against the spec's, and every route the spec declares served (an ``OPTIONS`` +that finds something at its path, and a GET route without path parameters answering neither 404 nor 405), each in the +server's OpenAPI document when its card offers one; for a card without one, a route only the ``OPTIONS`` can check is +unverified, a ``warning``, unless that ``OPTIONS`` answers with an ``Allow`` listing its method. The health check +then reads the spec's health path for a card without ``website/v1`` ``ready``. + The round trip leaves the server's data as it found it, adding the export back when a step after the reset fails too. The clock check leaves the server following a clock that is gone once the check ends, so a server it ran against keeps extrapolating from that clock's time until it's synced again. The checker's clock starts at ``CLOCK_START``, far @@ -25,6 +32,7 @@ import dataclasses import datetime import json +import re import sys import threading import time @@ -40,11 +48,12 @@ from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, iso, parse_time from .environment import DEFAULT_HEALTH_PATH from .readiness import WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness +from .spec import SpecError, WebsiteSpec, load Status = Literal["passed", "failed", "warning", "skipped"] CLOCK_START = datetime.datetime(2031, 5, 6, 7, 8, 9, tzinfo=datetime.timezone.utc) CLOCK_SLACK_S = 120 -REQUIRED = ("any_host", "health", "reserved_paths", "data_plane") +REQUIRED = ("any_host", "health", "reserved_paths", "data_plane", "spec", "spec_routes") @dataclasses.dataclass(frozen=True) @@ -67,21 +76,22 @@ def fails(self, strict: bool = False) -> bool: def check(base_url: str, *, client: httpx.Client | None = None, clock_host: str = "127.0.0.1", - timeout: float = 30) -> list[Result]: + timeout: float = 30, spec: WebsiteSpec | None = None) -> list[Result]: """Each check's result against the server at ``base_url``. ``clock_host`` is the address the server reaches the - checker's clock at. Raises httpx's error when the server can't be reached at all.""" + checker's clock at; with ``spec``, the server is checked against it too. Raises httpx's error when the server + can't be reached at all.""" own = client is None client = client or httpx.Client(timeout=timeout) try: - return _Checker(base_url.rstrip("/"), client, clock_host).run() + return _Checker(base_url.rstrip("/"), client, clock_host, spec).run() finally: if own: client.close() class _Checker: - def __init__(self, base_url: str, client: httpx.Client, clock_host: str) -> None: - self.base_url, self.client, self.clock_host = base_url, client, clock_host + def __init__(self, base_url: str, client: httpx.Client, clock_host: str, spec: WebsiteSpec | None = None) -> None: + self.base_url, self.client, self.clock_host, self.spec = base_url, client, clock_host, spec self.rpc_url = base_url + RPC_PATH self.responses: list[httpx.Response] = [] @@ -102,11 +112,15 @@ def run(self) -> list[Result]: self.rpc_url = _rpc_url(self.base_url, card) site = (find_extension(card, WEBSITE_EXTENSION_URI) or {}).get("params") or {} health = site.get("health") if isinstance(site.get("health"), str) else DEFAULT_HEALTH_PATH - steps = (("card", lambda: self._card(response, site)), ("any_host", self._any_host), + if self.spec is not None: + health = self.spec.health + steps = [("card", lambda: self._card(response, site)), ("any_host", self._any_host), ("health", lambda: self._health(card, health)), ("universe_now", self._universe_now), ("reserved_paths", self._reserved_paths), ("data_plane", lambda: self._data_plane(card)), ("clock", lambda: self._clock(card, health)), ("forwarded", lambda: self._forwarded(site)), - ("cookies", lambda: self._cookies(site))) + ("cookies", lambda: self._cookies(site))] + if self.spec is not None: + steps += [("spec", lambda: self._spec_card(card, site)), ("spec_routes", lambda: self._spec_routes(card, site))] outcomes = {name: _outcome(step) for name, step in steps if name != "universe_now"} outcomes["universe_now"] = _outcome(self._universe_now) results = [] @@ -271,6 +285,130 @@ def _cookies(self, site: dict) -> tuple[Status, str]: return "passed", f"{len(cookies)} cookie(s), none tied to a domain" + def _spec_card(self, card: dict, site: dict) -> tuple[Status, str]: + """The card's name, domains, health path, routes and session cookie and path against the spec's, and its + state and clock when the spec declares them.""" + spec, declared = self.spec, self.spec.to_card_extension() + problems = [] + if spec.site.name is not None and card.get("name") != spec.site.name: + problems.append(f"the card is named {card.get('name')!r}, the spec's site {spec.site.name!r}") + if set(site.get("domains") or []) != set(declared["domains"]): + problems.append(f"the card's domains are {site.get('domains')}, the spec's {declared['domains']}") + if site.get("health") != declared["health"]: + problems.append(f"the card's health is {site.get('health')!r}, the spec's {declared['health']!r}") + theirs = {r.get("name"): _route_key(r) for r in site.get("routes") or [] if isinstance(r, dict)} + for route in declared["routes"]: + if route["name"] not in theirs: + problems.append(f"the card lacks route {route['name']}") + elif theirs[route["name"]] != _route_key(route): + problems.append(f"the card's {route['name']} is {_shown(theirs[route['name']])}, the spec's " + f"{_shown(_route_key(route))}") + if extra := sorted(set(theirs) - {r["name"] for r in declared["routes"]}): + problems.append(f"the card has routes the spec doesn't declare: {', '.join(map(str, extra))}") + for key in ("session_cookie", "session_path", "state", "clock"): + if key in declared and site.get(key) != declared[key]: + problems.append(f"the card's {key} is {site.get(key)!r}, the spec's {declared[key]!r}") + if problems: + return "failed", "; ".join(problems) + return "passed", f"name, {len(declared['domains'])} domain(s), health and {len(declared['routes'])} route(s) match" + + def _spec_routes(self, card: dict, site: dict) -> tuple[Status, str]: + """Every route the spec declares served: an OPTIONS finding something at its path, a GET route without path + parameters answering neither 404 nor 405, and an operation of the server's OpenAPI document. A path + parameter is probed with a value its converter on the card's route of that name takes (``_PROBES``), ``0`` + when the card gives it none. When the card offers no document, a route the OPTIONS alone can check is + served only when the OPTIONS answers below 500 with an ``Allow`` listing its method; any other answer, such + as a catch-all's 200 without ``Allow``, a 500, or an ``Allow`` naming only the methods of another route at + that path, leaves it unverified, which, when nothing failed, is a ``warning``.""" + routes = self.spec.route_table() + served = {r.get("name"): r.get("path") for r in site.get("routes") or [] + if isinstance(r, dict) and isinstance(r.get("path"), str)} + if not routes: + return "skipped", "the spec declares no routes" + problems, unverified = [], [] + offered = find_extension_method(card, WEBSITE_EXTENSION_URI, "openapi") is not None + try: + document = self._openapi(card) + except RuntimeError as e: + problems.append(str(e)) + document = None + for name, route in routes.items(): + card_path = served.get(name) + template = card_path if card_path and _CONVERTER.sub(r"{\1}", card_path) == route.path else route.path + path = _PARAM.sub(_probe, template) + options = self.client.options(self.base_url + path) + if options.status_code == 404: + problems.append(f"{name}: nothing is at {route.method} {route.path}") + continue + if route.method == "GET" and path == route.path: + status = self.get(path).status_code + if status in (404, 405) or status >= 500: + problems.append(f"{name}: GET {route.path} answered {status}") + elif not offered: + allowed = _allowed(options) + if allowed is None or options.status_code >= 500 or route.method not in allowed: + shown = " without an Allow" if allowed is None else f" allowing {', '.join(sorted(allowed))}" + unverified.append(f"{name}: OPTIONS {path} answered {options.status_code}{shown}, which doesn't " + f"show {route.method} {route.path} is served") + if document is not None: + operation = ((document.get("paths") or {}).get(route.path) or {}).get(route.method.lower()) + if not isinstance(operation, dict) or operation.get("operationId") != name: + problems.append(f"{name}: the OpenAPI document has no operation {name} at {route.method} {route.path}") + if problems: + return "failed", "; ".join(problems + unverified) + if unverified: + return "warning", "unverified: " + "; ".join(unverified) + return "passed", f"all {len(routes)} route(s) served" + (", each in the OpenAPI document" if document else "") + + def _openapi(self, card: dict) -> dict | None: + """The OpenAPI document the card's ``website/v1`` ``openapi`` serves, None when it offers none; raises + RuntimeError when one it offers can't be read.""" + method = find_extension_method(card, WEBSITE_EXTENSION_URI, "openapi") + if method is None or not method.get("endpoint"): + return None + response = self.client.request(method["method"], self.base_url + method["endpoint"], + **({"json": {}} if method["method"] != "GET" else {})) + try: + document = response.json() if response.status_code == 200 else None + except ValueError: + document = None + if not isinstance(document, dict): + raise RuntimeError(f"the card's openapi, {method['method']} {method['endpoint']}, answered " + f"{response.status_code} and no OpenAPI document") + return document + + +def _allowed(response: httpx.Response) -> set[str] | None: + """The methods ``response``'s ``Allow`` lists, None when it has none.""" + allow = response.headers.get("allow") + return None if allow is None else {m.strip().upper() for m in allow.split(",") if m.strip()} + + +_PARAM = re.compile(r"{[^}]+}") +_PROBES = {"uuid": "00000000-0000-0000-0000-000000000000"} + + +def _probe(param: re.Match) -> str: + """A value the path parameter ``param`` matches: its converter's from ``_PROBES``, else ``0``, which an int, + float, str or path parameter all take.""" + _, _, converter = param.group(0)[1:-1].partition(":") + return _PROBES.get(converter, "0") + + +_CONVERTER = re.compile(r"{(\w+):[^}]+}") + + +def _route_key(route: dict) -> tuple: + """A route as the card and the spec can both say it: its method, its path with any converter dropped + (``{rest:path}`` is ``{rest}``), and its roles.""" + return route.get("method"), _CONVERTER.sub(r"{\1}", route.get("path") or ""), tuple(sorted(route.get("roles") or [])) + + +def _shown(key: tuple) -> str: + method, path, roles = key + return f"{method} {path}" + (f" for {list(roles)}" if roles else "") + + def _rpc_url(base_url: str, card: dict) -> str: """Where the card says its data plane is: its ``url``, a path joined onto ``base_url`` or a full URL, else ``RPC_PATH``.""" @@ -352,9 +490,15 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--clock-host", default="127.0.0.1", help="the address the server reaches this checker at") parser.add_argument("--timeout", type=float, default=30, help="seconds for each request") parser.add_argument("--strict", action="store_true", help="fail on a recommended check's warning too") + parser.add_argument("--spec", help="a website spec file to check the server against too") args = parser.parse_args(argv) try: - results = check(args.url, clock_host=args.clock_host, timeout=args.timeout) + spec = load(args.spec) if args.spec else None + except SpecError as e: + print("\n".join(e.problems), file=sys.stderr) + return 2 + try: + results = check(args.url, clock_host=args.clock_host, timeout=args.timeout, spec=spec) except httpx.TransportError as e: print(f"couldn't reach {args.url}: {type(e).__name__}: {e}", file=sys.stderr) return 2 diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py index 5c3b1eda8..230ab28f4 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py @@ -47,10 +47,10 @@ from .routes import (ApiContext, ApiRoute, endpoint, error_response, forwarded_prefix, openapi, openapi_path, request_role, reserved_path, routes_of) from .session import SessionSigner, expires_at, session_cookie_name +from .spec import (DEFAULT_HEALTH_PATH, SESSION_PATH, SpecError, WebsiteSpec, from_environment, load, + problems_with_environment) from .store import Collections -DEFAULT_HEALTH_PATH = "/api/health" -SESSION_PATH = "/api/session" _WEBSITE_ATTR = "_aee_website" _READY_ATTR = "_aee_website_ready" _DEFAULT_HOOK_ATTR = "_aee_default_store_hook" @@ -60,8 +60,8 @@ class WebsiteDeclaration(NamedTuple): """What ``@website`` declares: the site's domains, its static folder and seed data file (relative to the class's - file), its health path, whether its HTML pages read the universe's time, and the models whose database keeps its - state instead of the default store.""" + file), its health path, whether its HTML pages read the universe's time, the models whose database keeps its state + instead of the default store, and the spec it was declared from.""" domains: tuple[str, ...] = () static: Optional[str] = None @@ -69,16 +69,23 @@ class WebsiteDeclaration(NamedTuple): clock_patch: bool = False data: Optional[str] = None models: Any = None + spec: Optional[WebsiteSpec] = None def website(_cls: type | None = None, *, domains: list[str] | tuple[str, ...] = (), static: str | None = None, health: str = DEFAULT_HEALTH_PATH, clock_patch: bool = False, data: str | None = None, - models: Any = None) -> Any: + models: Any = None, spec: str | Path | WebsiteSpec | None = None) -> Any: """Declare a WebsiteEnvironment's site. ``data`` names a JSON file the default store starts with; ``models`` keeps the site's data in a database instead, an MCP server's (see ``website.database``), and the site's - data plane is that database's.""" + data plane is that database's. With ``spec``, a website spec file (relative to the class's file) or a loaded one, + the site is the spec's, and the class must serve what it declares; the other arguments but ``models`` are then + the spec's to give.""" if _cls is not None: raise TypeError("use @website(...) with parentheses") + if spec is not None and (domains or static is not None or health != DEFAULT_HEALTH_PATH or clock_patch + or data is not None): + raise TypeError("@website(spec=...) takes the site from its spec; set domains, health, assets, seed and " + "clock there") if not health.startswith("/") or health == "/" or reserved_path(health): raise ValueError(f"health is a path of its own under '/', not {health!r}") if models is not None and data is not None: @@ -89,7 +96,20 @@ def website(_cls: type | None = None, *, domains: list[str] | tuple[str, ...] = def deco(cls: type) -> type: if not (isinstance(cls, type) and issubclass(cls, WebsiteEnvironment)): raise TypeError(f"@website declares a WebsiteEnvironment subclass, not {cls!r}") - setattr(cls, _WEBSITE_ATTR, WebsiteDeclaration(tuple(domains), static, health, clock_patch, data, models)) + if spec is None: + setattr(cls, _WEBSITE_ATTR, WebsiteDeclaration(tuple(domains), static, health, clock_patch, data, models)) + else: + declared = spec if isinstance(spec, WebsiteSpec) else load(cls._site_path(str(spec))) + if models is not None and declared.data.seed: + raise ValueError("the spec's data.seed seeds the default store; a site of models= is seeded through " + "data/add") + source = declared.source + assets = str(declared.path(source.assets)) if source is not None and source.assets else None + setattr(cls, _WEBSITE_ATTR, WebsiteDeclaration( + declared.site.domains, assets, declared.health, + declared.clock is not None and declared.clock.follow == "date_patch", None, models, declared)) + if problems := problems_with_environment(declared, cls): + raise SpecError([f"{cls.__name__} doesn't serve its spec: {p}" for p in problems]) _check_health(cls) return cls @@ -122,13 +142,12 @@ def __init_subclass__(cls, **kwargs: Any) -> None: setattr(cls, name, None) def __init__(self) -> None: - declaration = self.declaration() self.store = Collections() - if declaration.data is not None: - self.store.load(json.loads(self._site_path(declaration.data).read_text(encoding="utf-8"))) + for seed in self._seed_files(): + self.store.load(json.loads(seed.read_text(encoding="utf-8"))) self.clock = UniverseClock() self._signer = SessionSigner(cookie_name=session_cookie_name(self.environment_name())) - models = declaration.models + models = self.declaration().models self._tables = (None if models is None or is_hooks(models) else Tables(metadata_of(models), lambda: self.database_url)) self._models = None if models is None else ModelState(models if self._tables is None else self._tables) @@ -144,15 +163,31 @@ def routes(cls) -> list[ApiRoute]: def environment_name(self) -> str: """The site's name, as its card names it: ``ENVIRONMENT_NAME``, else the card's declared name, else the - class's.""" + spec's, else the class's.""" declared = (getattr(self, _CARD_CONFIG_ATTR, None) or {}).get("name") - return os.environ.get("ENVIRONMENT_NAME") or declared or type(self).__name__ + spec = self.declaration().spec + return (os.environ.get("ENVIRONMENT_NAME") or declared or (spec.site.name if spec is not None else None) + or type(self).__name__) @property def session_cookie(self) -> str: """The name of the site's session cookie, ``agentenv_session_``.""" return self._session_signer().cookie_name + @classmethod + def site_spec(cls) -> WebsiteSpec: + """The site's spec: the one ``@website(spec=...)`` names with what the class serves filled in, else the one + its decorators declare. Its card's website/v1 params are this spec's ``to_card_extension()``, with the + session cookie the running site's.""" + return from_environment(cls) + + @classmethod + def _seed_files(cls) -> list[Path]: + declaration = cls.declaration() + if declaration.spec is not None: + return [declaration.spec.path(seed) for seed in declaration.spec.data.seed] + return [cls._site_path(declaration.data)] if declaration.data is not None else [] + @property def database_url(self) -> str | None: """Where the site keeps its task state: its own schema of the env's database.""" @@ -219,13 +254,31 @@ def serve(self) -> None: port=int(os.environ.get("PORT", "8000"))) def extension_params(self, uri: str) -> dict: + """``website/v1``'s params are the spec's card params, each route named by the path the class serves it at, + converters and all (``/files/{rest:path}``), so a gateway matches the requests it answers, nested paths + included; a spec names a path by its template.""" if uri == WEBSITE_EXTENSION_URI: - declaration = self.declaration() - return {"domains": list(declaration.domains), "health": declaration.health, - "routes": [r.advertised() for r in self.routes()], "session_cookie": self.session_cookie, - "session_path": SESSION_PATH} + params = {**self.site_spec().to_card_extension(), "session_cookie": self.session_cookie, + "session_path": SESSION_PATH} + served = {r.name: r.path for r in self.routes()} + params["routes"] = [{**r, "path": served.get(r["name"], r["path"])} for r in params["routes"]] + return params return super().extension_params(uri) + def _build_card(self) -> Any: + """The card, named by the spec's site name when neither ``ENVIRONMENT_NAME`` nor ``@environment_card`` + names it; a card name and a spec name that differ are refused.""" + card = super()._build_card() + spec = self.declaration().spec + named = (getattr(self, _CARD_CONFIG_ATTR, None) or {}).get("name") + if spec is None or spec.site.name is None: + return card + if named is not None and named != spec.site.name: + raise SpecError([f"{type(self).__name__}: @environment_card names it {named!r}, its spec " + f"{spec.site.name!r}"]) + return card if named is not None or os.environ.get("ENVIRONMENT_NAME") else card.model_copy( + update={"name": spec.site.name}) + @extension(CLOCK_EXTENSION_URI, description="Sync this site to the gateway's virtual clock.") async def sync_time(self, env_get_time_url: str, timezone: Optional[str] = None) -> dict: """Follow the gateway's virtual time at ``env_get_time_url`` from now on, in ``timezone`` when the task names diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/spec/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/website/spec/__init__.py new file mode 100644 index 000000000..edcfbccc2 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/spec/__init__.py @@ -0,0 +1,1053 @@ +"""The website spec: one declarative description of a site that every way of serving one produces or is checked +against. A spec is a TOML file of fixed sections, each optional: ``[site]`` (name, domains, health path), +``[source]`` (exactly one of ``image``, ``sdk``, ``static``, ``live``, ``replay``), ``[data]``, ``[users]``, +``[roles]``, ``[routes]``, ``[state]``, ``[clock]`` and ``[[uses]]``, with ``extends`` naming a spec it overlays. + +``load(path)`` reads one, resolves ``extends`` (a deep merge: tables merge key by key, arrays and scalars replace, +``[source]`` replaces whole, ``false`` removes an inherited route, role or collection, and ``[[uses]]`` entries are +added, one with the same ``as`` replacing the inherited one) and composes ``[[uses]]`` (each component's routes +mounted under its path prefix and named ``.``, its collections namespaced ``.`` unless mapped +into one of the site's, its personas merged), and validates the result, raising ``SpecError`` with every problem. +A resolved spec has no ``extends``, ``uses`` or ``routes.openapi`` left, the document's operations being in +``routes.ops`` (less any an overlay removed); ``components`` says what was mounted. + +``to_card_extension()`` is the ``urn:agentenv:website/v1`` params a server of the spec advertises, ``from_card`` +reads a running site's card back into a spec, ``from_environment`` derives one from a WebsiteEnvironment class's +decorators, and ``name_table`` builds a multi env's one table of host names, synthetic before live before +allowlisted. ``python -m agentenv_protocol.website.spec validate|show|schema`` is its command line.""" +from __future__ import annotations +from ..readiness import WEBSITE_EXTENSION_URI + +import argparse +import copy +import dataclasses +import inspect +import json +import os +import re +import sys +from pathlib import Path +from typing import Any, Iterable, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, ValidationError, field_validator, model_validator + +from ...agent_env_environment import _CARD_CONFIG_ATTR +from ...client import find_extension +from ..routes import openapi_path, reserved_path +from ..session import session_cookie_name + +try: + import tomllib +except ImportError: + try: + import tomli as tomllib + except ImportError: + tomllib = None + +DEFAULT_HEALTH_PATH = "/api/health" +SESSION_PATH = "/api/session" +STATE_PATH = "/go" +METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE") +SOURCE_KINDS = ("image", "sdk", "static", "live", "replay") +NameKind = Literal["synthetic", "live", "allowlist"] +_PRECEDENCE: dict[str, int] = {"synthetic": 0, "live": 1, "allowlist": 2} +_HOST = re.compile(r"(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?") +_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z_][A-Za-z0-9_-]*)*") +_SDK_REF = re.compile(r"[A-Za-z_][\w.]*:[A-Za-z_<][\w.<>]*") +_SECRET_REF = re.compile(r"(secret|env):[A-Za-z_][A-Za-z0-9_./-]*") +_PATH = re.compile(r"/(?:[A-Za-z0-9._~!$&'()*+,;=:@/{}-]|%[0-9A-Fa-f]{2})*") +_PATH_FIELDS: tuple[tuple[str, ...], ...] = ( + ("source", "image", "dockerfile"), ("source", "image", "context"), ("source", "static"), ("source", "assets"), + ("source", "replay", "capture"), ("data", "schema"), ("routes", "openapi")) +_REMOVABLE = {("routes", "ops"), ("roles",), ("data", "collections")} + + +class SpecError(ValueError): + """A spec that can't be read or doesn't hold together; ``problems`` lists each, ``: ``.""" + + def __init__(self, problems: Iterable[str]) -> None: + self.problems = list(problems) + super().__init__("\n".join(self.problems)) + + +class _Section(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True) + + +class Site(_Section): + """Who the site is: its name (the card's), the host names it answers for, ``*.`` as a leftmost label matching + any subdomain, and the path that answers 200 once it's usable.""" + + name: Optional[str] = None + domains: tuple[str, ...] = () + health: Optional[str] = None + + @field_validator("domains") + @classmethod + def _domains(cls, domains: tuple[str, ...]) -> tuple[str, ...]: + bad = [d for d in domains if not _HOST.fullmatch(d)] + if bad: + raise ValueError(f"{bad} aren't host names in lower case, '*.' only as the leftmost label") + return domains + + @field_validator("health") + @classmethod + def _health(cls, health: Optional[str]) -> Optional[str]: + if health is not None and (not _PATH.fullmatch(health) or health == "/" or reserved_path(health)): + raise ValueError(f"health is a path of its own under '/', not {health!r}") + return health + + +class ImageSource(_Section): + """A server image built from a Dockerfile, both relative to the spec.""" + + dockerfile: str + context: str = "." + + +class LiveSource(_Section): + """The real site, reached through the env: the hosts besides its domains it may call, and the credentials it + signs in with, each a ``secret:`` or ``env:`` reference, never a value.""" + + allow_hosts: tuple[str, ...] = () + credentials: dict[str, str] = {} + + @field_validator("allow_hosts") + @classmethod + def _hosts(cls, hosts: tuple[str, ...]) -> tuple[str, ...]: + return Site._domains(hosts) + + @field_validator("credentials") + @classmethod + def _credentials(cls, credentials: dict[str, str]) -> dict[str, str]: + bad = sorted(name for name, ref in credentials.items() if not _SECRET_REF.fullmatch(ref)) + if bad: + raise ValueError(f"credentials {bad} must be secret: or env: references, not values") + return credentials + + +class ReplaySource(_Section): + """A capture of the site served back: the artifact, relative to the spec, and the hosts still let through.""" + + capture: str + allow_hosts: tuple[str, ...] = () + + @field_validator("allow_hosts") + @classmethod + def _hosts(cls, hosts: tuple[str, ...]) -> tuple[str, ...]: + return Site._domains(hosts) + + +class Source(_Section): + """What serves the site, exactly one of: an ``image`` (a reference, or a table building one), an ``sdk`` + WebsiteEnvironment class as ``module:Class`` with its static ``assets`` folder, a ``static`` folder, the + ``live`` real site, or a ``replay`` of a capture.""" + + image: Union[str, ImageSource, None] = None + sdk: Optional[str] = None + static: Optional[str] = None + live: Optional[LiveSource] = None + replay: Optional[ReplaySource] = None + assets: Optional[str] = None + + @model_validator(mode="after") + def _one(self) -> Source: + given = [kind for kind in SOURCE_KINDS if getattr(self, kind) is not None] + if len(given) != 1: + raise ValueError(f"names exactly one of {', '.join(SOURCE_KINDS)}, not {given or 'none'}") + if self.sdk is not None and not _SDK_REF.fullmatch(self.sdk): + raise ValueError(f"sdk is module:Class, not {self.sdk!r}") + if self.assets is not None and self.sdk is None: + raise ValueError("assets is the static folder of an sdk source") + return self + + @property + def kind(self) -> str: + return next(kind for kind in SOURCE_KINDS if getattr(self, kind) is not None) + + +class Collection(_Section): + """One collection of the site's data: the field naming each object (``key``), the field naming the user who + owns one (``owner``), and a JSON Schema of an object, inline or a file relative to the spec.""" + + key: str = "id" + owner: Optional[str] = None + schema_: Union[str, dict, None] = Field(default=None, alias="schema") + + +class Data(_Section): + """The site's data: its collections, or one JSON Schema of the whole state, or ``models = "mcp:"`` + binding it to an MCP server's schema; and the seed files it starts with, in order.""" + + collections: dict[str, Collection] = {} + schema_: Union[str, dict, None] = Field(default=None, alias="schema") + models: Optional[str] = None + seed: tuple[str, ...] = () + + @field_validator("seed", mode="before") + @classmethod + def _seed(cls, seed: Any) -> Any: + return (seed,) if isinstance(seed, str) else seed + + @field_validator("models") + @classmethod + def _models(cls, models: Optional[str]) -> Optional[str]: + if models is not None and not re.fullmatch(r"mcp:[A-Za-z0-9_.-]+", models): + raise ValueError(f"models is mcp:, not {models!r}") + return models + + @model_validator(mode="after") + def _schema_or_models(self) -> Data: + if self.schema_ is not None and self.models is not None: + raise ValueError("data takes a schema or models, not both") + if bad := sorted(name for name in self.collections if not _NAME.fullmatch(name)): + raise ValueError(f"collection names {bad} aren't names") + return self + + +class Persona(_Section): + id: str + name: Optional[str] = None + role: Optional[str] = None + + +class Users(_Section): + """Who uses the site: its personas, the acting user a browser with no session is signed in as, and how a + browser signs in: ``cookie`` (the site's signed session cookie, as ``session_path`` hands it out), ``none``, or + ``live``, the real site's own sign-in.""" + + personas: tuple[Persona, ...] = () + acting_user: Optional[str] = None + sign_in: Literal["cookie", "none", "live"] = "cookie" + session_cookie: Optional[str] = None + session_path: Optional[str] = None + + @field_validator("personas", mode="before") + @classmethod + def _personas(cls, personas: Any) -> Any: + return [{"id": p} if isinstance(p, str) else p for p in personas] if isinstance(personas, list) else personas + + @model_validator(mode="after") + def _consistent(self) -> Users: + ids = [p.id for p in self.personas] + if twice := sorted({i for i in ids if ids.count(i) > 1}): + raise ValueError(f"personas {twice} are declared twice") + if self.acting_user is not None and ids and self.acting_user not in ids: + raise ValueError(f"acting_user {self.acting_user!r} is none of the personas {ids}") + if self.sign_in != "cookie" and (self.session_cookie or self.session_path): + raise ValueError("session_cookie and session_path are a cookie sign-in's") + return self + + +class Route(_Section): + """One named operation: its verb, its OpenAPI-style path, the roles it answers (any when empty), and, for a site + served by a live or replayed source, the ``module:Class`` whose route of the same name answers it instead.""" + + method: str + path: str + roles: tuple[str, ...] = () + served_by: Optional[str] = None + + @model_validator(mode="before") + @classmethod + def _shorthand(cls, value: Any) -> Any: + if isinstance(value, str): + method, _, path = value.strip().partition(" ") + value = {"method": method, "path": path.strip()} + if isinstance(value, dict): + value = dict(value) + if isinstance(value.get("method"), str): + value["method"] = value["method"].upper() + if isinstance(value.get("path"), str): + value["path"] = openapi_path(value["path"]) + return value + + @model_validator(mode="after") + def _route(self) -> Route: + if self.method not in METHODS: + raise ValueError(f"method is one of {', '.join(METHODS)}, not {self.method!r}") + if not _PATH.fullmatch(self.path): + raise ValueError(f"path starts with '/', not {self.path!r}") + if reserved_path(self.path): + raise ValueError(f"path {self.path!r} is the data plane's or the card's") + if self.served_by is not None and not _SDK_REF.fullmatch(self.served_by): + raise ValueError(f"served_by is module:Class, not {self.served_by!r}") + return self + + def advertised(self, name: str) -> dict: + return {"method": self.method, "path": self.path, "name": name, "roles": list(self.roles) or None} + + +class Routes(_Section): + """The site's named operations, ``name = "METHOD /path"`` or a table, and an OpenAPI document whose operations, + by ``operationId``, are routes too; the names are what triggers and records call ``op``.""" + + openapi: Optional[str] = None + ops: dict[str, Route] = {} + + @field_validator("ops") + @classmethod + def _names(cls, ops: dict[str, Route]) -> dict[str, Route]: + if bad := sorted(name for name in ops if not _NAME.fullmatch(name)): + raise ValueError(f"route names {bad} aren't names") + return ops + + +class State(_Section): + """How a grader reads the site's state: ``data_plane`` (``data/get``), ``go`` (``GET ``, the + ``{initial_state, current_state, state_diff}`` of a session), or ``diff`` (the writes a run made, as the env's + proxy recorded them, for a site whose data plane isn't its own).""" + + read: Literal["data_plane", "go", "diff"] = "go" + path: Optional[str] = None + + @model_validator(mode="after") + def _path(self) -> State: + if self.path is not None and self.read != "go": + raise ValueError("path is where a go read reads") + if self.path is not None and not _PATH.fullmatch(self.path): + raise ValueError(f"path starts with '/', not {self.path!r}") + return self + + +class Clock(_Section): + """How the site follows the universe's time: ``header`` (its answers carry ``X-Universe-Now`` and its pages read + it), ``date_patch`` (its pages' ``Date`` is shifted too), or ``none`` (wall time).""" + + follow: Literal["header", "date_patch", "none"] = "header" + + +class Use(_Section): + """A component: another spec, relative to this one, mounted under ``mount``, named ``as`` (its site's name when + left out), with ``data`` mapping a collection of the component's onto one of this site's.""" + + spec: str + mount: str = "/" + as_: Optional[str] = Field(default=None, alias="as") + data: dict[str, str] = {} + + @field_validator("mount") + @classmethod + def _mount(cls, mount: str) -> str: + if not _PATH.fullmatch(mount) or "{" in mount or reserved_path(mount): + raise ValueError(f"mount is a plain path under '/', not {mount!r}") + return mount.rstrip("/") or "/" + + +@dataclasses.dataclass(frozen=True) +class Component: + """A component a resolved spec mounted: its name, its path prefix, and its own resolved spec.""" + + name: str + mount: str + spec: WebsiteSpec + + +class WebsiteSpec(_Section): + """A site as one declaration. ``load`` reads and resolves one; a spec built from a dict is resolved by + ``resolve``. Paths in it are relative to ``base_dir``.""" + + extends: Optional[str] = None + site: Site = Site() + source: Optional[Source] = None + data: Data = Data() + users: Users = Users() + roles: dict[str, tuple[str, ...]] = {} + routes: Routes = Routes() + state: Optional[State] = None + clock: Optional[Clock] = None + uses: tuple[Use, ...] = () + + _base_dir: Path = PrivateAttr(default_factory=Path.cwd) + _components: tuple[Component, ...] = PrivateAttr(default=()) + _declared: Optional[dict] = PrivateAttr(default=None) + + def __eq__(self, other: object) -> bool: + """Two specs are equal when they resolve to the same site in the same folder, however each was declared.""" + if not isinstance(other, WebsiteSpec): + return NotImplemented + return (type(self), self.__dict__, self._base_dir, self._components) == ( + type(other), other.__dict__, other._base_dir, other._components) + + __hash__ = None + + @property + def base_dir(self) -> Path: + return self._base_dir + + @property + def components(self) -> tuple[Component, ...]: + return self._components + + @property + def name(self) -> Optional[str]: + return self.site.name + + @property + def health(self) -> str: + return self.site.health or DEFAULT_HEALTH_PATH + + def path(self, relative: str) -> Path: + """``relative``, a path the spec names, as a path on disk.""" + given = Path(relative) + return given if given.is_absolute() else self._base_dir / given + + def route_table(self) -> dict[str, Route]: + """Every route by name, each with the roles ``[roles]`` grants it added to its own.""" + granted: dict[str, list[str]] = {} + for role, names in self.roles.items(): + for name in names: + granted.setdefault(name, []).append(role) + return {name: route.model_copy(update={"roles": tuple(dict.fromkeys([*route.roles, *granted.get(name, [])]))}) + for name, route in self.routes.ops.items()} + + def to_dict(self) -> dict: + """The spec as a JSON-able dict, empty sections left out, which ``resolve`` reads back as the same spec.""" + return _pruned(self.model_dump(mode="json", by_alias=True, exclude_none=True)) + + def overlay(self, changes: dict, *, base_dir: Path | str | None = None) -> WebsiteSpec: + """This spec with ``changes`` merged over it as an ``extends`` overlay merges, then resolved again; paths in + ``changes`` are relative to ``base_dir``, this spec's own when left out. A spec ``resolve`` or ``load`` made + is merged into as it was declared, its ``[[uses]]`` not yet mounted, so a component of the same ``as`` + replaces the mounted one as it would in a file overlay.""" + here = Path(base_dir) if base_dir is not None else self._base_dir + declared = self._declared if self._declared is not None else self.to_dict() + merged = _merge(declared, _expanded(_rebased(changes, here, self._base_dir))) + return resolve(merged, self._base_dir) + + def to_card_extension(self) -> dict: + """The ``urn:agentenv:website/v1`` params a server of this spec advertises: its domains, health path and + routes by name, its session cookie and path for a cookie sign-in, and how its state is read and its clock + followed, when declared.""" + params: dict = {"domains": list(self.site.domains), "health": self.health, + "routes": [route.advertised(name) for name, route in self.route_table().items()]} + if self.users.sign_in == "cookie": + params["session_cookie"] = self.users.session_cookie or session_cookie_name(self.site.name) + params["session_path"] = self.users.session_path or SESSION_PATH + if self.state is not None: + params["state"] = {"read": self.state.read, **({"path": self.state_path} if self.state.read == "go" else {})} + if self.clock is not None: + params["clock"] = {"follow": self.clock.follow} + return params + + @property + def state_path(self) -> Optional[str]: + if self.state is None or self.state.read != "go": + return None + return self.state.path or STATE_PATH + + +def load(path: str | Path) -> WebsiteSpec: + """The spec in the TOML file at ``path``, resolved: its ``extends`` merged, its ``uses`` composed, checked.""" + return _load(Path(path).resolve(), ()) + + +def resolve(raw: dict, base_dir: str | Path, *, origin: str = "spec") -> WebsiteSpec: + """The spec ``raw`` declares, paths in it relative to ``base_dir``, resolved as ``load`` resolves a file's.""" + return _resolve(copy.deepcopy(raw), Path(base_dir).resolve(), origin, ()) + + +def _load(path: Path, stack: tuple[Path, ...]) -> WebsiteSpec: + return _resolve(_read(path), path.parent, str(path), stack + (path,)) + + +def _read(path: Path) -> dict: + if tomllib is None: + raise SpecError([f"{path}: reading a TOML spec needs Python 3.11's tomllib, or tomli before it"]) + try: + return tomllib.loads(path.read_text(encoding="utf-8")) + except OSError as e: + raise SpecError([f"{path}: can't be read: {e.strerror or e}"]) from None + except tomllib.TOMLDecodeError as e: + raise SpecError([f"{path}: isn't TOML: {e}"]) from None + + +def _resolve(raw: dict, base_dir: Path, origin: str, stack: tuple[Path, ...]) -> WebsiteSpec: + """The spec ``raw`` declares, resolved. A spec that uses components has its acting user checked only once their + personas are merged into its own, since the acting user may be one only a component declares.""" + raw = _extended(raw, base_dir, origin, stack) + declared = _expanded(copy.deepcopy(raw)) + removed = _removals(raw) + problems: list[str] = [] + imported = _imported(raw, base_dir, removed, problems) + users = raw.get("users") + spec = _validated({**raw, "users": {k: v for k, v in users.items() if k != "acting_user"}} + if raw.get("uses") and isinstance(users, dict) else raw, origin) + spec._base_dir = base_dir + ops = dict(imported) + for name, route in spec.routes.ops.items(): + if name in ops and (ops[name].method, ops[name].path) != (route.method, route.path): + problems.append(f"routes.ops.{name}: {route.method} {route.path} isn't the openapi document's " + f"{ops[name].method} {ops[name].path}") + ops[name] = route + raw_routes = raw.setdefault("routes", {}) + raw_routes["ops"] = {name: route.model_dump(mode="json", exclude_none=True, exclude_defaults=True) | { + "method": route.method, "path": route.path} for name, route in ops.items()} + raw_routes.pop("openapi", None) + components = [] + for index, use in enumerate(spec.uses): + component = _component(use, index, spec, stack, problems) + if component is not None: + components.append(component) + _mount(raw, component, use, base_dir, f"uses[{index}]", problems) + raw.pop("uses", None) + if problems: + raise SpecError(f"{origin}: {p}" for p in problems) + resolved = _validated(raw, origin) + resolved._base_dir, resolved._components, resolved._declared = base_dir, tuple(components), declared + problems = _check(resolved) + if problems: + raise SpecError(f"{origin}: {p}" for p in problems) + return resolved + + +def _extended(raw: dict, base_dir: Path, origin: str, stack: tuple[Path, ...]) -> dict: + """``raw`` merged over the spec its ``extends`` names, recursively, that spec's paths made relative to + ``base_dir``.""" + parent = raw.pop("extends", None) + if parent is None: + return raw + if not isinstance(parent, str): + raise SpecError([f"{origin}: extends is the path of a spec, not {parent!r}"]) + path = (base_dir / parent).resolve() + if path in stack: + raise SpecError([f"{origin}: extends {parent} is a cycle: {' -> '.join(str(p) for p in (*stack, path))}"]) + base = _extended(_read(path), path.parent, str(path), stack + (path,)) + return _merge(_expanded(_rebased(base, path.parent, base_dir)), _expanded(raw)) + + +def _expanded(raw: dict) -> dict: + """``raw`` with each ``"METHOD /path"`` route written as its table, so an overlay can change one key of it.""" + ops = (raw.get("routes") or {}).get("ops") if isinstance(raw.get("routes"), dict) else None + if isinstance(ops, dict): + for name, route in ops.items(): + if isinstance(route, str): + method, _, path = route.strip().partition(" ") + ops[name] = {"method": method, "path": path.strip()} + return raw + + +def _removals(raw: dict) -> set[str]: + """The names of the routes ``raw``'s merged overlays removed, each a ``false`` left in ``routes.ops`` so it + removes an operation of the OpenAPI document too, taken out of ``raw``.""" + routes = raw.get("routes") + ops = routes.get("ops") if isinstance(routes, dict) else None + if not isinstance(ops, dict): + return set() + removed = {name for name, route in ops.items() if route is False} + for name in removed: + del ops[name] + return removed + + +def _validated(raw: dict, origin: str) -> WebsiteSpec: + try: + return WebsiteSpec.model_validate(raw) + except ValidationError as e: + raise SpecError(f"{origin}: {_where(err['loc'])}: {_why(err)}" for err in e.errors()) from None + + +def _where(loc: tuple) -> str: + where = "" + for part in loc: + if isinstance(part, int): + where += f"[{part}]" + elif part not in ("str", "dict", "ImageSource") and "[" not in part: + where += f".{part}" if where else part + return where or "spec" + + +def _why(err: dict) -> str: + message = err["msg"] + return message.removeprefix("Value error, ") if err["type"] != "extra_forbidden" else "isn't a key the spec takes" + + +def _merge(base: Any, over: Any, at: tuple[str, ...] = ()) -> Any: + """``over`` merged over ``base``: tables key by key, ``[source]`` whole, ``false`` removing an entry of + routes, roles or collections, ``[[uses]]`` added by ``as``, and anything else replaced. A removed route stays a + ``false``, so that resolving the merge removes it from the OpenAPI document's operations as well.""" + if not (isinstance(base, dict) and isinstance(over, dict)) or at == ("source",): + return copy.deepcopy(over) + merged = copy.deepcopy(base) + for key, value in over.items(): + if value is False and at == ("routes", "ops"): + merged[key] = False + elif value is False and at in _REMOVABLE: + merged.pop(key, None) + elif key == "uses" and not at: + kept = {_use_id(u): u for u in merged.get("uses", [])} + kept.update({_use_id(u): copy.deepcopy(u) for u in value}) + merged["uses"] = list(kept.values()) + elif key in merged: + merged[key] = _merge(merged[key], value, (*at, key)) + else: + merged[key] = copy.deepcopy(value) + return merged + + +def _use_id(use: Any) -> str: + return str(use.get("as") or use.get("spec")) if isinstance(use, dict) else repr(use) + + +def _rebased(raw: dict, from_dir: Path, to_dir: Path) -> dict: + """``raw`` with each relative path it names, relative to ``from_dir``, made relative to ``to_dir``; an image + source's context is moved even when left to its default, so it stays the folder its own spec meant.""" + if from_dir == to_dir: + return copy.deepcopy(raw) + raw = copy.deepcopy(raw) + + def moved(value: Any) -> Any: + return _moved(value, from_dir, to_dir) + + image = raw.get("source", {}).get("image") if isinstance(raw.get("source"), dict) else None + if isinstance(image, dict): + image.setdefault("context", ImageSource.model_fields["context"].default) + for keys in _PATH_FIELDS: + holder = raw + for key in keys[:-1]: + holder = holder.get(key) if isinstance(holder, dict) else None + if isinstance(holder, dict) and keys[-1] in holder: + holder[keys[-1]] = moved(holder[keys[-1]]) + data = raw.get("data") if isinstance(raw.get("data"), dict) else {} + if isinstance(data.get("seed"), (str, list)): + data["seed"] = moved(data["seed"]) if isinstance(data["seed"], str) else [moved(s) for s in data["seed"]] + for collection in (data.get("collections") or {}).values(): + if isinstance(collection, dict) and "schema" in collection: + collection["schema"] = moved(collection["schema"]) + for use in raw.get("uses") or []: + if isinstance(use, dict) and "spec" in use: + use["spec"] = moved(use["spec"]) + return raw + + +def _moved(value: Any, from_dir: Path, to_dir: Path) -> Any: + """``value``, a path relative to ``from_dir``, made relative to ``to_dir``; anything else as it is.""" + if not isinstance(value, str) or Path(value).is_absolute(): + return value + return os.path.relpath(from_dir / value, to_dir) + + +def _imported(raw: dict, base_dir: Path, removed: set[str], problems: list[str]) -> dict[str, Route]: + """The routes of ``raw``'s OpenAPI document that no overlay removed, each of ``raw``'s ``routes.ops`` tables + naming one of them written over it, so an overlay can change one key of an imported operation as it can of a + declared one.""" + routes = raw.get("routes") if isinstance(raw.get("routes"), dict) else {} + document = routes.get("openapi") + if not isinstance(document, str): + return {} + imported = {name: route for name, route in _openapi_routes(document, base_dir, problems).items() + if name not in removed} + ops = routes.get("ops") + for name, route in (ops.items() if isinstance(ops, dict) else ()): + if isinstance(route, dict) and name in imported: + ops[name] = imported[name].model_dump(mode="json", exclude_none=True) | route + return imported + + +def _openapi_routes(document: str, base_dir: Path, problems: list[str]) -> dict[str, Route]: + """The routes of the OpenAPI document at ``document``, relative to ``base_dir``, by operationId, roles from + ``x-agentenv-roles``; a document of another shape, an operation without a name and a name two operations share + are problems.""" + try: + parsed = json.loads((base_dir / document).read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + problems.append(f"routes.openapi: {document} isn't a readable JSON document: {e}") + return {} + paths = (parsed.get("paths") or {}) if isinstance(parsed, dict) else None + if not isinstance(paths, dict): + problems.append(f"routes.openapi: {document} isn't an OpenAPI document, an object whose paths " + "are an object") + return {} + routes: dict[str, Route] = {} + for path, operations in paths.items(): + if not isinstance(operations, dict): + problems.append(f"routes.openapi: path {path} isn't an object of operations") + continue + for method, operation in operations.items(): + if method.upper() not in METHODS or not isinstance(operation, dict): + continue + name = operation.get("operationId") + if not name or not isinstance(name, str): + problems.append(f"routes.openapi: {method.upper()} {path} has no operationId to name it by") + continue + if name in routes: + problems.append(f"routes.openapi: {name} names both {routes[name].method} {routes[name].path} and " + f"{method.upper()} {path}") + continue + try: + routes[name] = Route(method=method, path=path, roles=operation.get("x-agentenv-roles") or ()) + except ValidationError as e: + problems.append(f"routes.openapi: {name}: {_why(e.errors()[0])}") + return routes + + +def _component(use: Use, index: int, host: WebsiteSpec, stack: tuple[Path, ...], + problems: list[str]) -> Optional[Component]: + path = host.path(use.spec).resolve() + if path in stack: + problems.append(f"uses[{index}]: {use.spec} is a cycle: {' -> '.join(str(p) for p in (*stack, path))}") + return None + try: + spec = _load(path, stack) + except SpecError as e: + problems.extend(f"uses[{index}]: {p}" for p in e.problems) + return None + name = use.as_ or spec.site.name + if name is None: + problems.append(f"uses[{index}]: {use.spec} has no site.name; name it with as") + return None + return Component(name, use.mount, spec) + + +def _mount(raw: dict, component: Component, use: Use, base_dir: Path, where: str, problems: list[str]) -> None: + """Merge ``component`` into ``raw``, whose paths are relative to ``base_dir``: its routes prefixed and + namespaced, its collections namespaced or mapped, their schema files made relative to ``base_dir``, its personas + added; every clash is a problem.""" + ns, spec = component.name, component.spec + ops = raw["routes"]["ops"] + taken = {(r["method"], r["path"]): name for name, r in ops.items()} + if use.mount != "/": + for name, r in ops.items(): + if r["path"] == use.mount or r["path"].startswith(use.mount + "/"): + problems.append(f"{where}: route {name} ({r['method']} {r['path']}) is under {use.mount}, where " + f"{ns} is mounted") + for name, route in spec.route_table().items(): + full, path = f"{ns}.{name}", _prefixed(use.mount, route.path) + if full in ops: + problems.append(f"{where}: route {full} is declared by the site and by component {ns}") + elif (route.method, path) in taken: + problems.append(f"{where}: {ns}'s route {name} is {route.method} {path}, which {taken[(route.method, path)]} " + "already serves") + else: + ops[full] = {**route.model_dump(mode="json", exclude_none=True, exclude_defaults=True), + "method": route.method, "path": path} + taken[(route.method, path)] = full + data = raw.setdefault("data", {}) + collections = data.setdefault("collections", {}) + if missing := sorted(set(use.data) - set(spec.data.collections)): + problems.append(f"{where}: data maps {missing}, which {ns} doesn't declare") + for name, collection in spec.data.collections.items(): + target = use.data.get(name, f"{ns}.{name}") + theirs = collection.model_dump(mode="json", by_alias=True, exclude_none=True) + if "schema" in theirs: + theirs["schema"] = _moved(theirs["schema"], spec.base_dir, base_dir) + ours = collections.get(target) + if ours is None: + collections[target] = theirs + continue + merged = {**theirs, **ours} + clash = [k for k in ("key", "owner", "schema") if k in ours and k in theirs and ours[k] != theirs[k]] + clash += ["key"] if "key" not in ours and theirs.get("key", "id") != "id" else [] + if clash: + problems.append(f"{where}: {ns}'s collection {name} maps onto {target}, whose {', '.join(clash)} differ") + collections[target] = merged + users = raw.setdefault("users", {}) + personas = {(p["id"] if isinstance(p, dict) else p): ({"id": p} if isinstance(p, str) else p) + for p in users.get("personas", [])} + for persona in spec.users.personas: + theirs, ours = persona.model_dump(mode="json", exclude_none=True), personas.get(persona.id, {}) + if any(key in ours and ours[key] != value for key, value in theirs.items()): + problems.append(f"{where}: persona {persona.id} is declared otherwise by {ns}") + personas[persona.id] = {**theirs, **ours} + if personas: + users["personas"] = list(personas.values()) + sign_in = users.get("sign_in", "cookie") + if spec.users.sign_in not in ("none", sign_in): + problems.append(f"{where}: {ns} signs in by {spec.users.sign_in}, the site by {sign_in}") + + +def _prefixed(mount: str, path: str) -> str: + return path if mount == "/" else mount + path + + +def _check(spec: WebsiteSpec) -> list[str]: + """What doesn't hold together across sections of a resolved spec.""" + problems = [] + table = spec.route_table() + seen: dict[tuple[str, str], str] = {} + for name, route in table.items(): + if (route.method, route.path) in seen: + problems.append(f"routes.ops: {name} and {seen[route.method, route.path]} are both {route.method} {route.path}") + seen.setdefault((route.method, route.path), name) + if ("GET", spec.health) in seen: + problems.append(f"site.health: {spec.health} is served by route {seen['GET', spec.health]}") + for role, names in spec.roles.items(): + if unknown := [n for n in names if n not in table]: + problems.append(f"roles.{role}: names no route {unknown}") + kind = spec.source.kind if spec.source is not None else None + if spec.users.sign_in == "live" and kind not in ("live", "replay"): + problems.append("users.sign_in: live is the sign-in of a live or replayed source") + if spec.state is not None and spec.state.read == "diff" and kind not in ("live", "replay", None): + problems.append(f"state.read: diff is how a live or replayed site is read, not a site of source {kind}") + if kind in ("live", "replay") and spec.state is not None and spec.state.read != "diff": + problems.append(f"state.read: a {kind} site has no data plane of its own; its state is read as diff") + if kind == "sdk" and spec.clock is not None and spec.clock.follow == "none": + problems.append("clock.follow: an sdk site follows the universe's clock; none is a live site's") + if kind == "static" and table: + problems.append("routes: a static site serves no routes") + if served := [n for n, r in table.items() if r.served_by is not None and kind not in ("live", "replay")]: + problems.append(f"routes.ops: served_by swaps a route of a live or replayed site, not of {kind or 'no'} " + f"source ({served})") + for name, collection in spec.data.collections.items(): + if collection.owner is not None and spec.users.sign_in == "none": + problems.append(f"data.collections.{name}: owner names a user, and the site signs nobody in") + return problems + + +def _pruned(value: Any, at: tuple[str, ...] = ()) -> Any: + """``value`` without its empty tables and arrays, but for a JSON Schema, which is kept as it is.""" + if _is_schema(at): + return value + if isinstance(value, dict): + pruned = {k: _pruned(v, (*at, k)) for k, v in value.items()} + return {k: v for k, v in pruned.items() if _is_schema((*at, k)) or v not in ({}, [], ())} + if isinstance(value, list): + return [_pruned(v, at) for v in value] + return value + + +def _is_schema(at: tuple[str, ...]) -> bool: + return at == ("data", "schema") or (len(at) == 4 and at[:2] == ("data", "collections") and at[3] == "schema") + + +def from_card(card: dict) -> WebsiteSpec: + """The spec a running site's card declares: its name, domains, health path, routes and sign-in, and its state + and clock when the card says them. A card says nothing of a site's source, data or personas.""" + extension = find_extension(card, WEBSITE_EXTENSION_URI) + if extension is None: + raise SpecError([f"the card declares no {WEBSITE_EXTENSION_URI}"]) + params = extension.get("params") or {} + raw: dict = {"site": {"name": card.get("name"), "domains": params.get("domains") or [], + "health": params.get("health")}, + "routes": {"ops": {r["name"]: {"method": r["method"], "path": r["path"], "roles": r.get("roles") or []} + for r in params.get("routes") or []}}} + if params.get("session_cookie"): + raw["users"] = {"sign_in": "cookie"} + if params["session_cookie"] != session_cookie_name(card.get("name")): + raw["users"]["session_cookie"] = params["session_cookie"] + if params.get("session_path") not in (None, SESSION_PATH): + raw["users"]["session_path"] = params["session_path"] + else: + raw["users"] = {"sign_in": "none"} + if isinstance(params.get("state"), dict): + raw["state"] = {k: v for k, v in params["state"].items() if k != "path" or v != STATE_PATH} + if isinstance(params.get("clock"), dict): + raw["clock"] = params["clock"] + return resolve(_pruned(raw), Path.cwd(), origin=f"card {card.get('name')!r}") + + +def from_environment(cls: type) -> WebsiteSpec: + """The spec a WebsiteEnvironment class's decorators declare: ``@environment_card``'s name, ``@website``'s + domains, health path, static folder, seed and clock patch, its ``@api`` routes, a cookie sign-in, and its state + read at ``/go`` when it keeps the default store or serves ``/go`` itself, else through the data plane.""" + declaration = cls.declaration() + if declaration.spec is not None: + base = declaration.spec + raw = base.to_dict() + else: + base, raw = None, {} + card_name = (getattr(cls, _CARD_CONFIG_ATTR, None) or {}).get("name") + routes = cls.routes() + serves_go = any(r.method == "GET" and openapi_path(r.path) == STATE_PATH for r in routes) + site_file = Path(inspect.getfile(cls)).resolve().parent + raw["site"] = {**raw.get("site", {}), "domains": list(declaration.domains), "health": declaration.health} + if card_name or raw["site"].get("name") is None: + raw["site"]["name"] = card_name or cls.__name__ + raw["source"] = {"sdk": f"{cls.__module__}:{cls.__qualname__}", + **({"assets": str(cls._site_path(declaration.static))} if declaration.static else {})} + seeds = [str(path) for path in cls._seed_files()] + if seeds: + raw.setdefault("data", {})["seed"] = seeds + acting = _seed_user(seeds) + if acting is not None and "acting_user" not in raw.get("users", {}): + raw.setdefault("users", {})["acting_user"] = acting + raw.setdefault("users", {})["sign_in"] = "cookie" + raw["routes"] = {"ops": {r.name: {"method": r.method, "path": openapi_path(r.path), "roles": list(r.roles)} + for r in routes}} + raw.pop("roles", None) + raw["state"] = {"read": "go"} if cls._store_get is not None or serves_go else {"read": "data_plane"} + raw["clock"] = {"follow": "date_patch" if declaration.clock_patch else "header"} + return resolve(raw, base.base_dir if base is not None else site_file, origin=cls.__qualname__) + + +def _seed_user(paths: Iterable[str | Path]) -> Optional[str]: + """The ``current_user`` the store is left with once the seed files at ``paths`` are loaded in order, each that + has one taking its place.""" + user = None + for path in paths: + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + if isinstance(data, dict) and "current_user" in data: + user = data["current_user"] if isinstance(data["current_user"], str) else None + return user + + +def problems_with_environment(spec: WebsiteSpec, cls: type) -> list[str]: + """Where a WebsiteEnvironment class doesn't serve what ``spec`` declares: its source, routes by name, verb, + path and roles, sign-in, state read and clock.""" + problems = [] + if spec.source is not None and spec.source.kind != "sdk": + problems.append(f"source: a WebsiteEnvironment is an sdk source, and the spec names {spec.source.kind}") + elif spec.source is not None and spec.source.sdk.rsplit(":", 1)[1] not in (cls.__qualname__, cls.__name__): + problems.append(f"source.sdk: {spec.source.sdk} names another class than {cls.__name__}") + derived = from_environment(cls) + ours = {name: (r.method, r.path, tuple(sorted(r.roles))) for name, r in spec.route_table().items()} + theirs = {name: (r.method, r.path, tuple(sorted(r.roles))) for name, r in derived.route_table().items()} + if spec.routes.ops: + for name in sorted(set(ours) | set(theirs)): + if name not in theirs: + problems.append(f"routes.ops.{name}: {' '.join(ours[name][:2])} isn't an @api route of {cls.__name__}") + elif name not in ours: + problems.append(f"routes.ops: {cls.__name__} serves {name} ({' '.join(theirs[name][:2])}), " + "which the spec doesn't declare") + elif ours[name] != theirs[name]: + problems.append(f"routes.ops.{name}: the spec says {_shown(ours[name])}, the class serves " + f"{_shown(theirs[name])}") + if spec.users.sign_in != "cookie": + problems.append(f"users.sign_in: a WebsiteEnvironment signs in by cookie, not {spec.users.sign_in}") + cookie = session_cookie_name(spec.site.name) + if spec.users.session_cookie not in (None, cookie) or spec.users.session_path not in (None, SESSION_PATH): + problems.append(f"users: a WebsiteEnvironment's session is {cookie} at {SESSION_PATH}") + if spec.state is not None and (spec.state.read, spec.state_path) != (derived.state.read, derived.state_path): + problems.append(f"state: the spec reads {spec.state.read} {spec.state_path or ''}".rstrip() + + f", the class is read by {derived.state.read}") + acting = _seed_user(cls._seed_files()) + if spec.users.acting_user is not None and acting is not None and spec.users.acting_user != acting: + problems.append(f"users.acting_user: the spec says {spec.users.acting_user}, the seed's current_user {acting}") + return problems + + +def _shown(route: tuple) -> str: + method, path, roles = route + return f"{method} {path}" + (f" for {list(roles)}" if roles else "") + + +@dataclasses.dataclass(frozen=True) +class NameEntry: + """One host name pattern of a multi env's name table: the site it routes to and how that site is reached.""" + + pattern: str + site: str + kind: NameKind + + def matches(self, host: str) -> bool: + host = host.lower().rstrip(".") + if self.pattern.startswith("*."): + return host.endswith(self.pattern[1:]) + return host == self.pattern + + @property + def specificity(self) -> tuple[int, int]: + return (0, len(self.pattern)) if self.pattern.startswith("*.") else (1, len(self.pattern)) + + +@dataclasses.dataclass(frozen=True) +class NameTable: + """A multi env's host names: each pattern's winning entry, and each entry a higher kind shadowed, beside the + entry it lost to.""" + + entries: tuple[NameEntry, ...] + shadowed: tuple[tuple[NameEntry, NameEntry], ...] = () + + def resolve(self, host: str) -> Optional[NameEntry]: + """The entry ``host`` routes to: of those matching it, the highest kind's (synthetic, live, allowlist), + and among those the most specific pattern (an exact name, then the longest wildcard).""" + matching = [e for e in self.entries if e.matches(host)] + if not matching: + return None + return min(matching, key=lambda e: (_PRECEDENCE[e.kind], tuple(-n for n in e.specificity))) + + def as_dict(self) -> dict[str, dict]: + return {e.pattern: {"site": e.site, "kind": e.kind} for e in self.entries} + + +def name_table(specs: Iterable[WebsiteSpec]) -> NameTable: + """The one table of host names of a multi env's sites: each site's domains, ``synthetic`` for one the env + serves (image, sdk, static, replay) and ``live`` for a live source, and its source's ``allow_hosts`` as + ``allowlist``. A pattern two kinds claim goes to the higher, synthetic before live before allowlist; two sites + claiming one pattern at one kind is a conflict, but for an allowlist, which only lets traffic through. Raises + SpecError listing every conflict.""" + claims: dict[str, list[NameEntry]] = {} + problems = [] + names: set[str] = set() + for spec in specs: + if spec.site.name is None: + problems.append(f"a site with domains {list(spec.site.domains)} has no site.name") + continue + if spec.site.name in names: + problems.append(f"two sites are named {spec.site.name}") + names.add(spec.site.name) + source = spec.source + kind: NameKind = "live" if source is not None and source.kind == "live" else "synthetic" + allowed = (source.live.allow_hosts if source is not None and source.live is not None + else source.replay.allow_hosts if source is not None and source.replay is not None else ()) + for pattern in spec.site.domains: + claims.setdefault(pattern, []).append(NameEntry(pattern, spec.site.name, kind)) + for pattern in allowed: + claims.setdefault(pattern, []).append(NameEntry(pattern, spec.site.name, "allowlist")) + entries, shadowed = [], [] + for pattern, claimed in claims.items(): + clashes = [(kind, sorted({e.site for e in claimed if e.kind == kind})) for kind in ("synthetic", "live")] + clashes = [(kind, sites) for kind, sites in clashes if len(sites) > 1] + problems.extend(f"{pattern}: claimed as {kind} by {', '.join(sites)}" for kind, sites in clashes) + if clashes: + continue + best = min(_PRECEDENCE[e.kind] for e in claimed) + top = list(dict.fromkeys(e for e in claimed if _PRECEDENCE[e.kind] == best)) + winner = top[0] + entries.append(winner) + shadowed.extend((e, winner) for e in dict.fromkeys(claimed) if e != winner and e.site != winner.site) + if problems: + raise SpecError(problems) + return NameTable(tuple(entries), tuple(shadowed)) + + +def json_schema() -> dict: + """The JSON Schema of a spec file, with the string shorthands a route and a persona take, the partial route + table an overlay changes an inherited route's keys with, and the ``false`` that removes an inherited route, role + or collection.""" + schema = WebsiteSpec.model_json_schema(by_alias=True) + defs = schema.get("$defs", {}) + route_ref = {"$ref": "#/$defs/Route"} + shorthand = {"type": "string", "pattern": f"^({'|'.join(METHODS)}) /", "description": "METHOD /path"} + removed = {"const": False, "description": "removes the inherited entry"} + partial = {**{k: v for k, v in defs["Route"].items() if k != "required"}, + "title": "RouteChange", "description": "keys of an inherited route an overlay changes"} + defs["Routes"]["properties"]["ops"]["additionalProperties"] = {"anyOf": [route_ref, shorthand, removed, partial]} + for holder in (schema["properties"]["roles"], defs["Data"]["properties"]["collections"]): + holder["additionalProperties"] = {"anyOf": [holder["additionalProperties"], removed]} + defs["Users"]["properties"]["personas"]["items"] = {"anyOf": [{"$ref": "#/$defs/Persona"}, {"type": "string"}]} + defs["Data"]["properties"]["seed"] = {"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}], + "default": []} + return {"$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:agentenv:website/v1:spec", + **schema} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python -m agentenv_protocol.website.spec", + description="Validate, show or describe a website/v1 spec.") + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("validate", help="check a spec and everything it extends and uses").add_argument("path") + show = commands.add_parser("show", help="print the resolved spec as JSON") + show.add_argument("path") + show.add_argument("--card", action="store_true", help="print the website/v1 card params it advertises instead") + commands.add_parser("schema", help="print the JSON Schema of a spec file") + args = parser.parse_args(argv) + if args.command == "schema": + print(json.dumps(json_schema(), indent=2)) + return 0 + try: + spec = load(args.path) + except SpecError as e: + print("\n".join(e.problems), file=sys.stderr) + return 1 + if args.command == "show": + print(json.dumps(spec.to_card_extension() if args.card else spec.to_dict(), indent=2)) + return 0 + source = spec.source.kind if spec.source is not None else "no source" + mounted = f", {len(spec.components)} component(s)" if spec.components else "" + print(f"{args.path}: ok: {spec.site.name or 'unnamed site'}, {source}, {len(spec.routes.ops)} route(s), " + f"{len(spec.data.collections)} collection(s){mounted}") + return 0 diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/spec/__main__.py b/packages/agentenv-protocol/src/agentenv_protocol/website/spec/__main__.py new file mode 100644 index 000000000..15170e59a --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/spec/__main__.py @@ -0,0 +1,5 @@ +import sys + +from . import main + +sys.exit(main()) diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index ad92a50df..2b4308a65 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -48,8 +48,8 @@ def rpc(client: TestClient, method: str, params: dict | None = None) -> dict: def notes_class(tmp_path: Path) -> type[WebsiteEnvironment]: """The example's class, serving a stand-in page instead of ``web/dist``, which only a Vite build makes.""" (tmp_path / "index.html").write_text("Notes") - declared = notes_site.Notes.declaration()._replace(static=str(tmp_path), data=str(NOTES_DIR / "data" / "seed.json")) - return website(**declared._asdict())(type("Notes", (notes_site.Notes,), {})) + spec = notes_site.Notes.declaration().spec.overlay({"source": {"sdk": "site:Notes", "assets": str(tmp_path)}}) + return website(spec=spec)(type("Notes", (notes_site.Notes,), {})) @pytest.fixture diff --git a/packages/agentenv-protocol/tests/test_website_spec.py b/packages/agentenv-protocol/tests/test_website_spec.py new file mode 100644 index 000000000..74a457035 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_spec.py @@ -0,0 +1,879 @@ +"""The website spec: loading and validating one, ``extends`` overlays, ``uses`` composition and its conflicts, a multi +env's name table, the card round trip, a spec derived from a WebsiteEnvironment class and a class declared from one, +and the conformance checker run against a spec.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import httpx +import pytest +from starlette.testclient import TestClient + +from agentenv_protocol import environment_card +from agentenv_protocol.website import WebsiteEnvironment, api, conformance, website +from agentenv_protocol.website import spec as website_spec +from agentenv_protocol.website.spec import SpecError, from_card, from_environment, load, name_table, resolve + +EXAMPLES = Path(__file__).resolve().parents[1] / "examples" / "website" +NOTES_DIR = EXAMPLES / "notes" +SPECS_DIR = EXAMPLES / "specs" + + +def _notes_module(): + spec = importlib.util.spec_from_file_location("notes_spec_example_site", NOTES_DIR / "site.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +notes_site = _notes_module() + + +def write(folder: Path, files: dict[str, str]) -> Path: + for name, text in files.items(): + (folder / name).parent.mkdir(parents=True, exist_ok=True) + (folder / name).write_text(text) + return folder / next(iter(files)) + + +def problems(path_or_raw, base_dir: Path | None = None) -> list[str]: + with pytest.raises(SpecError) as caught: + load(path_or_raw) if base_dir is None else resolve(path_or_raw, base_dir) + return caught.value.problems + + +def card_of(client: TestClient) -> dict: + return client.get("/.well-known/agent-env.json").json() + + +def website_params(card: dict) -> dict: + params = next(e for e in card["capabilities"]["extensions"] if e["uri"] == website_spec.WEBSITE_EXTENSION_URI)["params"] + return {k: v for k, v in params.items() if k != "methods"} + + +@pytest.fixture +def notes_client(tmp_path: Path) -> TestClient: + (tmp_path / "index.html").write_text("") + spec = notes_site.Notes.declaration().spec.overlay({"source": {"sdk": "site:Notes", "assets": str(tmp_path)}}) + return TestClient(website(spec=spec)(type("Notes", (notes_site.Notes,), {}))().create_app()) + + +# Loading + + +def test_the_notes_spec_loads_with_its_routes_roles_personas_and_data() -> None: + spec = load(NOTES_DIR / "website.toml") + assert (spec.site.name, spec.site.domains, spec.health) == ("notes", ("notes.test",), "/healthz") + assert (spec.source.kind, spec.source.sdk, spec.path(spec.source.assets)) == ("sdk", "site:Notes", + NOTES_DIR / "web" / "dist") + assert [p.id for p in spec.users.personas] == ["ann", "ben"] and spec.users.acting_user == "ann" + assert spec.data.collections["notes"].owner == "owner" and spec.data.seed == ("data/seed.json",) + table = spec.route_table() + assert list(table)[:3] == ["list_users", "sign_in", "list_notes"] + assert (table["get_note"].method, table["get_note"].path) == ("GET", "/api/notes/{note_id}") + assert table["all_notes"].roles == ("admin",) and table["list_notes"].roles == () + assert (spec.state.read, spec.state_path, spec.clock.follow) == ("go", "/go", "header") + + +def test_every_section_is_optional_and_a_route_takes_a_table_or_a_shorthand(tmp_path: Path) -> None: + assert load(write(tmp_path, {"empty.toml": ""})).to_card_extension() == { + "domains": [], "health": "/api/health", "routes": [], "session_cookie": "agentenv_session", + "session_path": "/api/session"} + spec = load(write(tmp_path, {"r.toml": '[routes.ops]\na = "get /x/{id:int}"\n' + 'b = { method = "post", path = "/x", roles = ["admin"] }\n'})) + assert [r.advertised(n) for n, r in spec.route_table().items()] == [ + {"method": "GET", "path": "/x/{id}", "name": "a", "roles": None}, + {"method": "POST", "path": "/x", "name": "b", "roles": ["admin"]}] + + +def test_routes_come_from_an_openapi_document_by_operation_id(tmp_path: Path) -> None: + write(tmp_path, {"openapi.json": json.dumps({"paths": { + "/api/items": {"get": {"operationId": "list_items"}, "post": {"operationId": "add_item", + "x-agentenv-roles": ["admin"]}}}})}) + spec = load(write(tmp_path, {"s.toml": '[routes]\nopenapi = "openapi.json"\n[routes.ops]\nping = "GET /ping"\n'})) + assert {n: (r.method, r.path, r.roles) for n, r in spec.route_table().items()} == { + "list_items": ("GET", "/api/items", ()), "add_item": ("POST", "/api/items", ("admin",)), + "ping": ("GET", "/ping", ())} + clash = write(tmp_path, {"c.toml": '[routes]\nopenapi = "openapi.json"\n[routes.ops]\nlist_items = "GET /other"\n'}) + assert any("list_items: GET /other isn't the openapi document's GET /api/items" in p for p in problems(clash)) + + +def test_an_openapi_document_of_another_shape_or_with_a_name_twice_is_refused(tmp_path: Path) -> None: + def found(document) -> list[str]: + write(tmp_path, {"openapi.json": json.dumps(document)}) + return problems(write(tmp_path, {"s.toml": '[routes]\nopenapi = "openapi.json"\n'})) + + shape = "openapi.json isn't an OpenAPI document, an object whose paths are an object" + assert any(shape in p for p in found([])) + assert any(shape in p for p in found({"paths": ["/a"]})) + assert any("path /a isn't an object of operations" in p for p in found({"paths": {"/a": ["get"]}})) + assert any("routes.openapi: items names both GET /a and GET /b" in p for p in found( + {"paths": {"/a": {"get": {"operationId": "items"}}, "/b": {"get": {"operationId": "items"}}}})) + + +def test_false_removes_an_operation_of_the_openapi_document(tmp_path: Path) -> None: + write(tmp_path, {"openapi.json": json.dumps({"paths": {"/api/items": { + "get": {"operationId": "list_items"}, "post": {"operationId": "add_item"}}}}), + "base.toml": '[routes]\nopenapi = "openapi.json"\n'}) + spec = load(tmp_path / "base.toml") + assert set(spec.route_table()) == {"list_items", "add_item"} and spec.routes.openapi is None + assert set(spec.overlay({"routes": {"ops": {"add_item": False}}}).route_table()) == {"list_items"} + child = load(write(tmp_path, {"child.toml": 'extends = "base.toml"\n[routes.ops]\nadd_item = false\n'})) + assert set(child.route_table()) == {"list_items"} + assert set(resolve(child.to_dict(), tmp_path).route_table()) == {"list_items"} + + +@pytest.mark.parametrize("toml, problem", [ + ("[site]\ncolor = 1\n", "site.color: isn't a key the spec takes"), + ("[pages]\n", "pages: isn't a key the spec takes"), + ('[source]\nsdk = "a:B"\nstatic = "dist"\n', "source: names exactly one of image, sdk, static, live, replay, " + "not ['sdk', 'static']"), + ("[source]\n", "source: names exactly one of image, sdk, static, live, replay, not none"), + ('[source]\nstatic = "dist"\nassets = "x"\n', "source: assets is the static folder of an sdk source"), + ('[source]\nsdk = "nocolon"\n', "source: sdk is module:Class, not 'nocolon'"), + ('[site]\ndomains = ["ok.test", "Bad.test", "a.*.test"]\n', "site.domains: ['Bad.test', 'a.*.test'] aren't host"), + ('[site]\nhealth = "/agentenv"\n', "site.health: health is a path of its own under '/', not '/agentenv'"), + ('[source.live]\ncredentials = { token = "xoxp-123" }\n', "source.live.credentials: credentials ['token'] must be"), + ('[users]\npersonas = ["ann"]\nacting_user = "zed"\n', "users: acting_user 'zed' is none of the personas ['ann']"), + ('[users]\npersonas = ["ann", "ann"]\n', "users: personas ['ann'] are declared twice"), + ('[users]\nsign_in = "sso"\n', "users.sign_in: Input should be 'cookie', 'none' or 'live'"), + ('[routes.ops]\na = "FETCH /x"\n', "routes.ops.a: method is one of GET, POST, PUT, PATCH, DELETE, not 'FETCH'"), + ('[routes.ops]\na = "GET /agentenv/x"\n', "routes.ops.a: path '/agentenv/x' is the data plane's or the card's"), + ('[routes.ops]\na = "GET /x"\nb = "get /x"\n', "routes.ops: b and a are both GET /x"), + ('[site]\nhealth = "/up"\n[routes.ops]\nup = "GET /up"\n', "site.health: /up is served by route up"), + ('[roles]\nadmin = ["nope"]\n', "roles.admin: names no route ['nope']"), + ('[source]\nsdk = "a:B"\n[users]\nsign_in = "live"\n', "users.sign_in: live is the sign-in of a live or replayed"), + ('[source]\nsdk = "a:B"\n[clock]\nfollow = "none"\n', "clock.follow: an sdk site follows the universe's clock"), + ('[source.live]\n[users]\nsign_in = "live"\n[state]\nread = "go"\n', "state.read: a live site has no data plane"), + ('[source]\nstatic = "dist"\n[routes.ops]\na = "GET /a"\n', "routes: a static site serves no routes"), + ('[source]\nsdk = "a:B"\n[routes.ops]\na = { method = "GET", path = "/a", served_by = "x:Y" }\n', + "routes.ops: served_by swaps a route of a live or replayed site"), + ('[data]\nschema = "s.json"\nmodels = "mcp:mail"\n', "data: data takes a schema or models, not both"), + ('[data]\nmodels = "mail"\n', "data.models: models is mcp:, not 'mail'"), + ('[users]\nsign_in = "none"\n[data.collections.notes]\nowner = "owner"\n', + "data.collections.notes: owner names a user, and the site signs nobody in"), + ('[state]\nread = "data_plane"\npath = "/x"\n', "state: path is where a go read reads"), + ('[[uses]]\nspec = "c.toml"\nmount = "/.well-known/agent-env.json"\n', "uses[0].mount: mount is a plain path"), + ("[site\n", "isn't TOML"), +], ids=lambda value: value[:24] if isinstance(value, str) else value) +def test_a_spec_that_does_not_hold_together_is_refused_naming_where(tmp_path: Path, toml: str, problem: str) -> None: + found = problems(write(tmp_path, {"s.toml": toml})) + assert any(problem in p for p in found), found + assert all(p.startswith(str(tmp_path / "s.toml")) for p in found) + + +def test_a_missing_spec_file_is_a_problem(tmp_path: Path) -> None: + assert "can't be read" in problems(tmp_path / "nope.toml")[0] + + +def test_to_dict_reads_back_as_the_same_spec() -> None: + spec = load(NOTES_DIR / "website.toml") + again = resolve(spec.to_dict(), NOTES_DIR) + assert again == spec and again.to_card_extension() == spec.to_card_extension() + + +def test_to_dict_and_an_overlay_keep_a_json_schema_as_it_is(tmp_path: Path) -> None: + closed = {"type": "object", "additionalProperties": False, "properties": {"id": {}}, "required": []} + spec = resolve({"data": {"schema": {"properties": {}}, "collections": {"items": {"schema": closed}, + "any": {"schema": {}}}}}, tmp_path) + data = spec.to_dict()["data"] + assert (data["schema"], data["collections"]["items"]["schema"], data["collections"]["any"]["schema"]) == ( + {"properties": {}}, closed, {}) + assert resolve(spec.to_dict(), tmp_path) == spec + assert spec.overlay({"site": {"name": "x"}}).data.collections["items"].schema_ == closed + + +# extends + + +BASE = """ +[site] +name = "shop" +domains = ["shop.test", "www.shop.test"] +health = "/healthz" + +[source] +sdk = "shop:Shop" +assets = "dist" + +[data] +seed = ["seed.json"] + +[data.collections.orders] +owner = "buyer" + +[data.collections.carts] + +[users] +personas = ["ann", "ben"] +acting_user = "ann" + +[roles] +admin = ["refund"] +staff = ["refund"] + +[routes.ops] +list_orders = "GET /api/orders" +refund = "POST /api/orders/{id}/refund" +""" + + +def test_an_overlay_changes_one_key_of_an_operation_of_the_openapi_document(tmp_path: Path) -> None: + write(tmp_path, {"openapi.json": json.dumps({"paths": {"/api/items": { + "get": {"operationId": "list_items"}, "post": {"operationId": "add_item", "x-agentenv-roles": ["admin"]}}}}), + "base.toml": '[routes]\nopenapi = "openapi.json"\n'}) + child = load(write(tmp_path, {"child.toml": 'extends = "base.toml"\n[source.live]\n[routes.ops]\n' + 'add_item = { roles = ["staff"] }\n' + 'list_items = { served_by = "shop:Shop" }\n'})) + routes = child.route_table() + assert (routes["add_item"].method, routes["add_item"].path, routes["add_item"].roles) == ( + "POST", "/api/items", ("staff",)) + assert (routes["list_items"].method, routes["list_items"].path, routes["list_items"].served_by) == ( + "GET", "/api/items", "shop:Shop") + assert load(tmp_path / "base.toml").overlay({"routes": {"ops": {"add_item": {"roles": ["staff"]}}}}).route_table()[ + "add_item"].roles == ("staff",) + moved = write(tmp_path, {"moved.toml": 'extends = "base.toml"\n[routes.ops]\nadd_item = { path = "/api/other" }\n'}) + assert any("add_item: POST /api/other isn't the openapi document's POST /api/items" in p for p in problems(moved)) + + +def test_extends_merges_tables_replaces_arrays_and_source_and_false_removes(tmp_path: Path) -> None: + write(tmp_path, {"base/shop.toml": BASE}) + child = write(tmp_path, {"variants/ben.toml": """ +extends = "../base/shop.toml" + +[site] +domains = ["shop.example"] + +[source.image] +dockerfile = "Dockerfile" + +[data.collections] +orders = false + +[data.collections.carts] +key = "cart_id" + +[users] +personas = ["ben", "cy"] +acting_user = "ben" + +[roles] +staff = false + +[routes.ops] +refund = { roles = ["manager"] } +list_orders = false +place_order = "POST /api/orders" +"""}) + spec = load(child) + assert (spec.site.name, spec.site.domains, spec.health) == ("shop", ("shop.example",), "/healthz") + assert spec.source.kind == "image" and spec.source.assets is None + assert spec.source.image.dockerfile == "Dockerfile" and spec.base_dir == (tmp_path / "variants").resolve() + assert spec.path(spec.data.seed[0]).resolve() == (tmp_path / "base" / "seed.json").resolve() + assert list(spec.data.collections) == ["carts"] and spec.data.collections["carts"].key == "cart_id" + assert [p.id for p in spec.users.personas] == ["ben", "cy"] and spec.users.acting_user == "ben" + assert list(spec.roles) == ["admin"] + assert {n: (r.method, r.path, r.roles) for n, r in spec.route_table().items()} == { + "refund": ("POST", "/api/orders/{id}/refund", ("manager", "admin")), + "place_order": ("POST", "/api/orders", ())} + assert spec.extends is None + + +def test_an_inherited_image_keeps_its_default_context_in_its_own_folder(tmp_path: Path) -> None: + write(tmp_path, {"base/site.toml": '[site]\nname = "shop"\n[source.image]\ndockerfile = "Dockerfile"\n'}) + implicit = load(write(tmp_path, {"variants/ben.toml": 'extends = "../base/site.toml"\n'})) + write(tmp_path, {"base/explicit.toml": '[site]\nname = "shop"\n[source.image]\ndockerfile = "Dockerfile"\n' + 'context = "."\n'}) + explicit = load(write(tmp_path, {"variants/cy.toml": 'extends = "../base/explicit.toml"\n'})) + assert implicit.path(implicit.source.image.context).resolve() == (tmp_path / "base").resolve() + assert implicit.source.image == explicit.source.image + + +def test_an_extends_cycle_is_refused(tmp_path: Path) -> None: + write(tmp_path, {"a.toml": 'extends = "b.toml"\n', "b.toml": 'extends = "a.toml"\n'}) + assert "is a cycle" in problems(tmp_path / "a.toml")[0] + + +def test_overlay_merges_over_a_loaded_spec_as_extends_does(tmp_path: Path) -> None: + spec = load(write(tmp_path, {"shop.toml": BASE})) + changed = spec.overlay({"users": {"acting_user": "ben"}, "routes": {"ops": {"refund": {"roles": []}}}}) + assert changed.users.acting_user == "ben" and changed.route_table()["refund"].roles == ("admin", "staff") + assert spec.users.acting_user == "ann" + + +# uses + + +SSO = """ +[site] +name = "sso" +domains = ["sso.test"] + +[source] +sdk = "sso:FakeSso" + +[data.collections.accounts] + +[data.collections.grants] +owner = "user" + +[users] +personas = [{ id = "ann", name = "Ann" }] + +[roles] +admin = ["revoke"] + +[routes.ops] +login = "POST /login" +userinfo = "GET /userinfo" +revoke = "POST /revoke" +""" + +HOST = """ +[site] +name = "intranet" +domains = ["intranet.test"] + +[source.image] +dockerfile = "Dockerfile" + +[data.collections.users] + +[users] +personas = ["ann", "ben"] + +[roles] +editor = ["sso.login"] + +[routes.ops] +pages = "GET /api/pages" +""" + + +def test_uses_mounts_a_component_prefixing_its_routes_and_namespacing_its_data(tmp_path: Path) -> None: + write(tmp_path, {"components/sso.toml": SSO}) + spec = load(write(tmp_path, {"site.toml": HOST + '[[uses]]\nspec = "components/sso.toml"\nmount = "/auth"\n' + 'data = { accounts = "users" }\n'})) + table = spec.route_table() + assert {n: (r.method, r.path, r.roles) for n, r in table.items()} == { + "pages": ("GET", "/api/pages", ()), "sso.login": ("POST", "/auth/login", ("editor",)), + "sso.userinfo": ("GET", "/auth/userinfo", ()), "sso.revoke": ("POST", "/auth/revoke", ("admin",))} + assert list(spec.data.collections) == ["users", "sso.grants"] + assert spec.data.collections["sso.grants"].owner == "user" + assert [p.id for p in spec.users.personas] == ["ann", "ben"] + assert spec.site.domains == ("intranet.test",) and spec.uses == () + (component,) = spec.components + assert (component.name, component.mount, component.spec.site.name) == ("sso", "/auth", "sso") + assert [r["name"] for r in spec.to_card_extension()["routes"]] == ["pages", "sso.login", "sso.userinfo", + "sso.revoke"] + + +def test_uses_names_a_component_by_as_and_mounts_it_at_the_root(tmp_path: Path) -> None: + write(tmp_path, {"sso.toml": SSO}) + spec = load(write(tmp_path, {"site.toml": HOST.replace("sso.login", "auth.login") + + '[[uses]]\nspec = "sso.toml"\nas = "auth"\n'})) + assert spec.route_table()["auth.login"].path == "/login" and "auth.accounts" in spec.data.collections + + +def test_a_mounted_route_keeps_its_trailing_slash(tmp_path: Path) -> None: + write(tmp_path, {"sso.toml": SSO.replace('revoke = "POST /revoke"', 'revoke = "POST /revoke"\nhome = "GET /"\n' + 'items = "GET /items/"')}) + table = load(write(tmp_path, {"site.toml": HOST + '[[uses]]\nspec = "sso.toml"\nmount = "/auth"\n'})).route_table() + assert (table["sso.home"].path, table["sso.items"].path, table["sso.login"].path) == ("/auth/", "/auth/items/", + "/auth/login") + + +def test_a_sites_acting_user_may_be_a_persona_only_its_component_declares(tmp_path: Path) -> None: + write(tmp_path, {"sso.toml": SSO}) + host = HOST.replace('personas = ["ann", "ben"]', 'personas = ["ben"]\nacting_user = "ann"') + spec = load(write(tmp_path, {"site.toml": host + '[[uses]]\nspec = "sso.toml"\nmount = "/auth"\n'})) + assert ([p.id for p in spec.users.personas], spec.users.acting_user) == (["ben", "ann"], "ann") + write(tmp_path, {"site.toml": host.replace('acting_user = "ann"', 'acting_user = "zed"') + + '[[uses]]\nspec = "sso.toml"\nmount = "/auth"\n'}) + assert problems(tmp_path / "site.toml") == [ + f"{tmp_path / 'site.toml'}: users: acting_user 'zed' is none of the personas ['ben', 'ann']"] + + +@pytest.mark.parametrize("host, component, problem", [ + (HOST + '[routes.ops.mine]\nmethod = "POST"\npath = "/auth/login"\n', SSO, + "uses[0]: route mine (POST /auth/login) is under /auth, where sso is mounted"), + (HOST.replace('pages = "GET /api/pages"', 'pages = "GET /userinfo"'), SSO.replace("[site]", "[site]\n"), + "uses[0]: sso's route userinfo is GET /userinfo, which pages already serves"), + (HOST + '[routes.ops."sso.login"]\nmethod = "GET"\npath = "/x"\n', SSO, + "uses[0]: route sso.login is declared by the site and by component sso"), + (HOST + '[data.collections.people]\nkey = "email"\n', SSO, + "uses[0]: sso's collection accounts maps onto people, whose key differ"), + (HOST, SSO.replace('[data.collections.grants]\nowner = "user"', '[data.collections.grants]\nowner = "owner"'), + "uses[0]: sso's collection grants maps onto sso.grants"), + (HOST.replace('personas = ["ann", "ben"]', 'personas = ["ann", "ben"]\nsign_in = "none"'), SSO, + "uses[0]: sso signs in by cookie, the site by none"), + (HOST.replace('personas = ["ann", "ben"]', 'personas = [{ id = "ann", name = "Annie" }]'), SSO, + "uses[0]: persona ann is declared otherwise by sso"), + (HOST.replace('editor = ["sso.login"]', 'editor = ["sso.nope"]'), SSO, "roles.editor: names no route ['sso.nope']"), + (HOST, SSO.replace('name = "sso"\n', ""), "uses[0]: components/sso.toml has no site.name; name it with as"), + (HOST, SSO.replace('login = "POST /login"', 'login = "FETCH /login"'), "routes.ops.login: method is one of"), +], ids=["host-route-under-mount", "same-path", "same-name", "collection-key", "collection-owner", "sign-in", + "persona", "role-of-component-route", "unnamed", "component-invalid"]) +def test_a_composition_that_clashes_is_refused(tmp_path: Path, host: str, component: str, problem: str) -> None: + mount = "/" if "already serves" in problem else "/auth" + mapping = 'data = { accounts = "people" }\n' if "people" in problem else "" + if "grants maps onto sso.grants" in problem: + host += '[data.collections."sso.grants"]\nowner = "user"\n' + write(tmp_path, {"components/sso.toml": component}) + found = problems(write(tmp_path, {"site.toml": host + f'[[uses]]\nspec = "components/sso.toml"\nmount = "{mount}"\n' + + mapping})) + assert any(problem in p for p in found), found + + +def test_a_components_schema_file_stays_its_own_once_mounted(tmp_path: Path) -> None: + write(tmp_path, {"components/sso.toml": SSO.replace("[data.collections.accounts]", + '[data.collections.accounts]\nschema = "accounts.json"')}) + spec = load(write(tmp_path, {"site.toml": HOST + '[[uses]]\nspec = "components/sso.toml"\n'})) + assert spec.path(spec.data.collections["sso.accounts"].schema_).resolve() == tmp_path / "components" / "accounts.json" + mapped = load(write(tmp_path, {"mapped.toml": HOST.replace("[data.collections.users]", + '[data.collections.users]\nschema = "components/accounts.json"') + + '[[uses]]\nspec = "components/sso.toml"\ndata = { accounts = "users" }\n'})) + assert mapped.data.collections["users"].schema_ == "components/accounts.json" + + +def test_a_mapping_of_a_collection_the_component_lacks_and_a_uses_cycle_are_refused(tmp_path: Path) -> None: + write(tmp_path, {"sso.toml": SSO}) + assert any("data maps ['nope'], which sso doesn't declare" in p for p in problems( + write(tmp_path, {"a.toml": HOST + '[[uses]]\nspec = "sso.toml"\ndata = { nope = "users" }\n'}))) + write(tmp_path, {"x.toml": '[site]\nname = "x"\n[[uses]]\nspec = "y.toml"\n', + "y.toml": '[site]\nname = "y"\n[[uses]]\nspec = "x.toml"\n'}) + assert any("is a cycle" in p for p in problems(tmp_path / "x.toml")) + + +def test_an_overlay_adds_a_component_and_replaces_one_of_the_same_name(tmp_path: Path) -> None: + write(tmp_path, {"sso.toml": SSO, "base.toml": HOST + '[[uses]]\nspec = "sso.toml"\nmount = "/a"\nas = "sso"\n'}) + spec = load(write(tmp_path, {"child.toml": 'extends = "base.toml"\n[[uses]]\nspec = "sso.toml"\nmount = "/b"\n' + 'as = "sso"\n[[uses]]\nspec = "sso.toml"\nmount = "/c"\nas = "backup"\n'})) + assert [(c.name, c.mount) for c in spec.components] == [("sso", "/b"), ("backup", "/c")] + assert (spec.route_table()["sso.login"].path, spec.route_table()["backup.login"].path) == ("/b/login", "/c/login") + + +def test_overlay_replaces_a_mounted_component_as_a_file_overlay_does(tmp_path: Path) -> None: + write(tmp_path, {"sso.toml": SSO, "base.toml": HOST + '[[uses]]\nspec = "sso.toml"\nmount = "/a"\nas = "sso"\n'}) + by_file = load(write(tmp_path, {"child.toml": 'extends = "base.toml"\n[[uses]]\nspec = "sso.toml"\nmount = "/b"\n' + 'as = "sso"\n'})) + by_code = load(tmp_path / "base.toml").overlay({"uses": [{"spec": "sso.toml", "mount": "/b", "as": "sso"}]}) + assert [(c.name, c.mount) for c in by_code.components] == [("sso", "/b")] + assert by_code.to_dict() == by_file.to_dict() + + +# The name table + + +def _site(name: str, domains: list[str], source: dict | None = None) -> website_spec.WebsiteSpec: + raw: dict = {"site": {"name": name, "domains": domains}} + if source is not None: + raw["source"] = source + if "live" in source or "replay" in source: + raw["users"], raw["state"] = {"sign_in": "live"}, {"read": "diff"} + return resolve(raw, Path.cwd()) + + +def test_the_name_table_puts_synthetic_before_live_before_allowlisted() -> None: + slack = load(SPECS_DIR / "slack.toml") + status = _site("status", ["status.slack.com", "*.slack-edge.com"], {"live": {"allow_hosts": ["cdn.status.io"]}}) + notes = load(NOTES_DIR / "website.toml") + table = name_table([slack, status, notes]) + assert table.as_dict() == { + "slack.com": {"site": "slack", "kind": "synthetic"}, "*.slack.com": {"site": "slack", "kind": "synthetic"}, + "*.slack-edge.com": {"site": "status", "kind": "live"}, "status.slack.com": {"site": "status", "kind": "live"}, + "cdn.status.io": {"site": "status", "kind": "allowlist"}, "notes.test": {"site": "notes", "kind": "synthetic"}} + assert [(lost.site, lost.kind, won.site) for lost, won in table.shadowed] == [("slack", "allowlist", "status")] + assert table.resolve("status.slack.com").site == "slack" + assert table.resolve("files.slack-edge.com").kind == "live" + assert table.resolve("NOTES.test.").site == "notes" and table.resolve("slack.org") is None + + +def test_the_name_table_prefers_the_most_specific_pattern_of_one_kind() -> None: + table = name_table([_site("mail", ["*.example.test"]), _site("inbox", ["inbox.example.test"]), + _site("deep", ["*.b.example.test"])]) + assert [table.resolve(h).site for h in ("x.example.test", "inbox.example.test", "a.b.example.test")] == [ + "mail", "inbox", "deep"] + assert table.resolve("example.test") is None + + +def test_the_name_table_refuses_two_sites_claiming_one_name_at_one_kind() -> None: + with pytest.raises(SpecError) as caught: + name_table([_site("a", ["shop.test", "*.x.test"]), _site("b", ["shop.test"]), _site("c", ["*.x.test"]), + _site("a", []), resolve({"site": {"domains": ["q.test"]}}, Path.cwd()), + _site("l1", ["live.test"], {"live": {}}), _site("l2", ["live.test"], {"live": {}})]) + assert caught.value.problems == [ + "two sites are named a", "a site with domains ['q.test'] has no site.name", + "shop.test: claimed as synthetic by a, b", "*.x.test: claimed as synthetic by a, c", + "live.test: claimed as live by l1, l2"] + + + +def test_the_name_table_refuses_two_live_sites_on_one_name_a_synthetic_site_also_claims() -> None: + with pytest.raises(SpecError) as caught: + name_table([_site("l1", ["shop.test"], {"live": {}}), _site("l2", ["shop.test"], {"live": {}}), + _site("s", ["shop.test"])]) + assert caught.value.problems == ["shop.test: claimed as live by l1, l2"] + + +def test_two_sites_may_allowlist_one_host() -> None: + table = name_table([_site("a", [], {"live": {"allow_hosts": ["cdn.test"]}}), + _site("b", [], {"replay": {"capture": "c.har", "allow_hosts": ["cdn.test"]}})]) + assert table.as_dict() == {"cdn.test": {"site": "a", "kind": "allowlist"}} + + +# The card + + +def test_a_spec_round_trips_through_the_card_it_advertises() -> None: + spec = load(NOTES_DIR / "website.toml") + card = {"name": "notes", "capabilities": {"extensions": [ + {"uri": website_spec.WEBSITE_EXTENSION_URI, "params": spec.to_card_extension()}]}} + back = from_card(card) + assert back.to_card_extension() == spec.to_card_extension() + assert (back.site, back.users.sign_in, back.state, back.clock) == (spec.site, "cookie", spec.state, spec.clock) + assert back.source is None and back.data.collections == {} + + +def test_from_card_reads_a_running_sites_card_and_a_card_without_a_session() -> None: + card = {"name": "docs", "capabilities": {"extensions": [{"uri": website_spec.WEBSITE_EXTENSION_URI, "params": { + "domains": ["docs.test"], "health": "/up", "routes": [{"method": "GET", "path": "/a", "name": "a"}]}}]}} + spec = from_card(card) + assert (spec.site.name, spec.health, spec.users.sign_in, spec.state) == ("docs", "/up", "none", None) + assert spec.to_card_extension() == card["capabilities"]["extensions"][0]["params"] | { + "routes": [{"method": "GET", "path": "/a", "name": "a", "roles": None}]} + with pytest.raises(SpecError, match="declares no urn:agentenv:website/v1"): + from_card({"name": "x"}) + + +def test_the_notes_sites_card_is_its_spec(notes_client: TestClient) -> None: + from agentenv_protocol.website.routes import openapi_path + + card = card_of(notes_client) + params = website_params(card) + assert card["name"] == "notes" + assert {r["name"]: r["path"] for r in params["routes"]}["get_note"] == "/api/notes/{note_id:int}" + templated = [{**r, "path": openapi_path(r["path"])} for r in params["routes"]] + assert {**params, "routes": templated} == load(NOTES_DIR / "website.toml").to_card_extension() + assert from_card(card).to_card_extension() == {**params, "routes": templated} + + +# WebsiteEnvironment + + +def test_from_environment_derives_the_notes_spec_from_its_class() -> None: + derived = from_environment(notes_site.Notes) + declared = load(NOTES_DIR / "website.toml") + assert derived.to_card_extension() == declared.to_card_extension() + assert (derived.source.kind, derived.source.sdk) == ("sdk", f"{notes_site.__name__}:Notes") + assert derived.users.acting_user == "ann" and derived.path(derived.data.seed[0]) == NOTES_DIR / "data" / "seed.json" + + +def test_from_environment_derives_a_spec_from_decorators_alone(tmp_path: Path) -> None: + (tmp_path / "seed.json").write_text('{"current_user": "zoe", "items": []}') + + @environment_card(name="shop") + @website(domains=["shop.test"], health="/up", data=str(tmp_path / "seed.json"), clock_patch=True) + class Shop(WebsiteEnvironment): + @api.get("/api/items", roles=["admin"]) + def items(self) -> list: + return [] + + spec = from_environment(Shop) + assert (spec.site.name, spec.site.domains, spec.health, spec.users.acting_user) == ("shop", ("shop.test",), "/up", + "zoe") + assert (spec.state.read, spec.clock.follow, spec.route_table()["items"].roles) == ("go", "date_patch", ("admin",)) + assert website_params(card_of(TestClient(Shop().create_app()))) == spec.to_card_extension() + + +def test_from_environment_takes_any_url_path_a_route_serves() -> None: + @environment_card(name="chat") + @website(domains=["chat.test"]) + class Chat(WebsiteEnvironment): + @api.get("/api/users/@me") + def me(self) -> dict: + return {"id": "me"} + + @api.get("/api/tags/c++,%2F") + def tags(self) -> list: + return [] + + assert {r.path for r in from_environment(Chat).route_table().values()} == {"/api/users/@me", "/api/tags/c++,%2F"} + assert TestClient(Chat().create_app()).get("/api/users/@me").json() == {"id": "me"} + + +def test_a_class_declared_from_a_spec_must_serve_what_it_declares(tmp_path: Path) -> None: + path = write(tmp_path, {"site.toml": '[site]\nname = "wiki"\ndomains = ["wiki.test"]\n[source]\nsdk = "m:Wiki"\n' + '[routes.ops]\npages = "GET /api/pages"\nedit = "PUT /api/pages/{id}"\n' + '[roles]\nadmin = ["edit"]\n'}) + + class Wiki(WebsiteEnvironment): + @api.get("/api/pages") + def pages(self) -> list: + return [] + + @api.put("/api/pages/{id}", name="edit") + def edit_page(self, id: str) -> dict: + return {} + + @api.get("/api/extra") + def extra(self) -> dict: + return {} + + with pytest.raises(SpecError) as caught: + website(spec=path)(Wiki) + assert [p.split(": ", 1)[1] for p in caught.value.problems] == [ + "routes.ops.edit: the spec says PUT /api/pages/{id} for ['admin'], the class serves PUT /api/pages/{id}", + "routes.ops: Wiki serves extra (GET /api/extra), which the spec doesn't declare"] + with pytest.raises(SpecError, match="source.sdk: m:Wiki names another class than Other"): + website(spec=load(path).overlay({"routes": {"ops": {"edit": False}}, "roles": {"admin": False}}))( + type("Other", (WebsiteEnvironment,), {})) + with pytest.raises(TypeError, match="takes the site from its spec"): + website(spec=path, domains=["x.test"]) + + +def test_a_class_declared_from_a_spec_is_named_and_seeded_by_it(tmp_path: Path) -> None: + write(tmp_path, {"a.json": '{"current_user": "ann", "pages": [{"id": 1}]}', "b.json": '{"pages": [{"id": 2}]}', + "dist/index.html": ""}) + path = write(tmp_path, {"site.toml": '[site]\nname = "wiki"\ndomains = ["wiki.test"]\nhealth = "/up"\n' + '[source]\nsdk = "m:Wiki"\nassets = "dist"\n[data]\nseed = ["a.json", "b.json"]\n' + '[clock]\nfollow = "date_patch"\n'}) + + @website(spec=path) + class Wiki(WebsiteEnvironment): + pass + + env = Wiki() + client = TestClient(env.create_app()) + assert card_of(client)["name"] == "wiki" and client.get("/up").status_code == 200 + assert env.store["pages"] == [{"id": 1}, {"id": 2}] and env.store.current_user == "ann" + assert Wiki.declaration().clock_patch and " + + diff --git a/src/agent_env/cli/env/website_template/web/package.json.tmpl b/src/agent_env/cli/env/website_template/web/package.json.tmpl new file mode 100644 index 000000000..a4af53d1d --- /dev/null +++ b/src/agent_env/cli/env/website_template/web/package.json.tmpl @@ -0,0 +1,14 @@ +{ + "name": "{{name}}-site-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "5.4.10" + } +} diff --git a/src/agent_env/cli/env/website_template/web/src/main-tools.js.tmpl b/src/agent_env/cli/env/website_template/web/src/main-tools.js.tmpl new file mode 100644 index 000000000..d0e5926fd --- /dev/null +++ b/src/agent_env/cli/env/website_template/web/src/main-tools.js.tmpl @@ -0,0 +1,68 @@ +import "./style.css"; +import { api, followUniverseClock, session, signIn, universeNow } from "./site-client.js"; + +// The routes `agent-env env website new` made from the MCP server's tools. Each answers 501 until site.py writes it. +const ROUTES = {{routes_json}}; + +const $ = (id) => document.getElementById(id); + +function showError(error) { + $("error").textContent = error ? error.error || error.message : ""; + $("error").hidden = !error; +} + +function tick() { + const now = universeNow(); + $("universe-now").textContent = `${now.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC`; + $("universe-now").dateTime = now.toISOString(); +} + +function routeItem(route) { + const item = document.createElement("li"); + item.className = "card"; + const title = document.createElement("code"); + title.textContent = `${route.method} ${route.path}`; + const about = document.createElement("p"); + about.textContent = route.description || route.name; + const params = document.createElement("textarea"); + params.rows = 2; + params.value = JSON.stringify(route.example, null, 0); + params.setAttribute("aria-label", `${route.name} parameters`); + const call = document.createElement("button"); + call.textContent = "Call"; + const out = document.createElement("pre"); + call.addEventListener("click", async () => { + try { + const values = JSON.parse(params.value || "{}"); + const query = new URLSearchParams(Object.entries(values).map(([k, v]) => [k, String(v)])).toString(); + const answer = route.method === "GET" + ? await api(query ? `${route.path}?${query}` : route.path) + : await api(route.path, { method: route.method, body: values }); + out.textContent = JSON.stringify(answer, null, 2); + showError(null); + } catch (error) { + out.textContent = ""; + showError(error); + } + }); + item.append(title, about, params, call, out); + return item; +} + +async function start() { + const [signedIn, users] = await Promise.all([session(), api("api/users")]); + $("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id))); + $("user").value = signedIn.user ?? ""; + $("user").addEventListener("change", (event) => signIn(event.target.value).then(() => showError(null), showError)); + + const list = document.createElement("ul"); + list.id = "routes"; + list.append(...ROUTES.map(routeItem)); + $("app").append(list); + + tick(); + setInterval(tick, 1000); + followUniverseClock({ path: "healthz" }); +} + +start().catch(showError); diff --git a/src/agent_env/cli/env/website_template/web/src/main.js.tmpl b/src/agent_env/cli/env/website_template/web/src/main.js.tmpl new file mode 100644 index 000000000..06c34f466 --- /dev/null +++ b/src/agent_env/cli/env/website_template/web/src/main.js.tmpl @@ -0,0 +1,85 @@ +import "./style.css"; +import { api, followUniverseClock, session, signIn, universeNow } from "./site-client.js"; + +const $ = (id) => document.getElementById(id); + +function showError(error) { + $("error").textContent = error ? error.error || error.message : ""; + $("error").hidden = !error; +} + +function formatTime(value) { + return new Date(value).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }); +} + +function tick() { + $("universe-now").textContent = `${formatTime(universeNow())} UTC`; + $("universe-now").dateTime = universeNow().toISOString(); +} + +async function render() { + const items = await api("api/items"); + const list = document.createElement("ul"); + list.id = "items"; + for (const item of items) { + const row = document.createElement("li"); + row.className = "card"; + row.textContent = item.title; + const meta = document.createElement("small"); + meta.textContent = ` · ${formatTime(item.created_at)}`; + row.append(meta); + list.append(row); + } + if (!items.length) { + const empty = document.createElement("li"); + empty.className = "empty"; + empty.textContent = "Nothing here yet."; + list.append(empty); + } + $("items")?.remove(); + $("app").append(list); +} + +async function run(action) { + try { + await action(); + showError(null); + await render(); + } catch (error) { + showError(error); + } +} + +async function start() { + const [signedIn, users] = await Promise.all([session(), api("api/users")]); + $("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id))); + $("user").value = signedIn.user ?? ""; + $("user").addEventListener("change", (event) => run(() => signIn(event.target.value))); + + const form = document.createElement("form"); + form.id = "new-item"; + form.className = "card"; + const title = document.createElement("input"); + title.placeholder = "New item"; + title.required = true; + title.setAttribute("aria-label", "New item"); + const add = document.createElement("button"); + add.type = "submit"; + add.textContent = "Add"; + form.append(title, add); + form.addEventListener("submit", (event) => { + event.preventDefault(); + run(async () => { + await api("api/items", { method: "POST", body: { title: title.value } }); + form.reset(); + }); + }); + $("app").append(form); + + tick(); + setInterval(tick, 1000); + followUniverseClock({ path: "healthz" }); + await render(); +} + +start().catch(showError); diff --git a/src/agent_env/cli/env/website_template/web/src/style.css.tmpl b/src/agent_env/cli/env/website_template/web/src/style.css.tmpl new file mode 100644 index 000000000..ba2c7a4fa --- /dev/null +++ b/src/agent_env/cli/env/website_template/web/src/style.css.tmpl @@ -0,0 +1,46 @@ +:root { + --bg: #f6f6f3; + --card: #ffffff; + --text: #1d1d1b; + --muted: #6b6b66; + --line: #deded8; + --accent: #2f5bd3; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + color: var(--text); + background: var(--bg); +} + +* { box-sizing: border-box; } +body { margin: 0; } + +.bar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--line); + background: var(--card); +} +.bar h1 { margin: 0; font-size: 1.25rem; } +.who { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; color: var(--muted); font-size: 0.9rem; } +.who time { font-variant-numeric: tabular-nums; } + +main { max-width: 720px; margin: 0 auto; padding: 16px; } + +.card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px; } + +input, textarea, select, button { font: inherit; color: inherit; } +input, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: transparent; } +button { border: 1px solid var(--line); border-radius: 6px; padding: 6px 12px; background: var(--card); cursor: pointer; } +button[type="submit"] { background: var(--accent); border-color: var(--accent); color: #fff; } + +form.card { display: flex; gap: 8px; margin-bottom: 16px; } +ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +li small { color: var(--muted); } +li pre { margin: 8px 0 0; white-space: pre-wrap; font-size: 0.85rem; } +li textarea { margin: 8px 0; font-family: ui-monospace, monospace; font-size: 0.85rem; } +.empty { color: var(--muted); text-align: center; padding: 24px; } + +#error { color: #b3261e; margin: 0 0 12px; } diff --git a/src/agent_env/cli/env/website_template/web/vite.config.js.tmpl b/src/agent_env/cli/env/website_template/web/vite.config.js.tmpl new file mode 100644 index 000000000..79f723c86 --- /dev/null +++ b/src/agent_env/cli/env/website_template/web/vite.config.js.tmpl @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + base: "./", + server: { + proxy: { + "/api": process.env.SITE_URL || "http://127.0.0.1:8000", + "/healthz": process.env.SITE_URL || "http://127.0.0.1:8000", + }, + }, +}); diff --git a/tst/unit/cli/website_dev_cli_test.py b/tst/unit/cli/website_dev_cli_test.py new file mode 100644 index 000000000..0fdcd7bb9 --- /dev/null +++ b/tst/unit/cli/website_dev_cli_test.py @@ -0,0 +1,310 @@ +"""agent-env env website dev's pieces, each without a long-running process: the site class it serves, the boot every +answer of a server carries, the local clock standing in for the gateway's, when web/ is rebuilt, priming a server +(clock/v1's sync_time and the seed through the data plane) once per boot with one conformance check, and the command +wiring uvicorn's reload, the clock and the supervisor together.""" + +import datetime +import json +import logging +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import httpx +import pytest +from click.testing import CliRunner +from starlette.testclient import TestClient + +from agent_env.cli.env import website_dev +from agent_env.cli.env.website import website +from agent_env.cli.env.website_scaffold import scaffold +from agentenv_protocol.types import WELL_KNOWN_PATH +from agentenv_protocol.website import UNIVERSE_NOW_HEADER, conformance, website as declare +from agentenv_protocol.website.clock import parse_time + +TWO_SITES = ''' +from agentenv_protocol.website import WebsiteEnvironment, website + + +class Base(WebsiteEnvironment): + pass + + +@website(domains=["a.test"]) +class Site(Base): + pass +''' + + +@pytest.fixture +def site_dir(tmp_path: Path) -> Path: + scaffold(tmp_path / "shop", "shop") + page = tmp_path / "shop" / "web" / "dist" + page.mkdir() + (page / "index.html").write_text("shop") + return tmp_path / "shop" + + +@pytest.fixture +def clock(socket_enabled): + started = website_dev.DevClock(datetime.datetime(2030, 1, 1, 9, tzinfo=datetime.timezone.utc)).start() + yield started + started.close() + + +def _app(site_dir: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(website_dev.SITE_ENV, str(site_dir / "site.py")) + return website_dev.create_app() + + +def test_the_site_class_is_the_one_its_file_defines_or_the_one_declared_with_website(tmp_path, site_dir): + assert website_dev.site_class(site_dir / "site.py").__name__ == "ShopSite" + (tmp_path / "two.py").write_text(TWO_SITES) + assert website_dev.site_class(tmp_path / "two.py").__name__ == "Site" + (tmp_path / "none.py").write_text("x = 1\n") + with pytest.raises(website_dev.DevError, match="defines 0 WebsiteEnvironment classes"): + website_dev.site_class(tmp_path / "none.py") + + +def test_a_site_imports_the_files_beside_it(tmp_path, monkeypatch): + monkeypatch.setattr(sys, "path", list(sys.path)) + (tmp_path / "shop").mkdir() + (tmp_path / "shop" / "shop_models_beside.py").write_text("DOMAIN = 'shop.test'\n") + (tmp_path / "shop" / "site.py").write_text( + "from agentenv_protocol.website import WebsiteEnvironment, website\n" + "from shop_models_beside import DOMAIN\n\n\n@website(domains=[DOMAIN])\nclass Shop(WebsiteEnvironment):\n pass\n") + monkeypatch.chdir(tmp_path) + assert website_dev.site_class(Path("shop") / "site.py").__name__ == "Shop" + + +def test_every_answer_carries_the_servers_boot_and_the_supervisors_polls_leave_the_access_log(site_dir, monkeypatch): + first, second = TestClient(_app(site_dir, monkeypatch)), TestClient(_app(site_dir, monkeypatch)) + boot = first.get("/healthz").headers[website_dev.BOOT_HEADER] + assert first.get("/api/items").headers[website_dev.BOOT_HEADER] == boot + assert second.get("/healthz").headers[website_dev.BOOT_HEADER] != boot + + def line(path): + return logging.LogRecord("uvicorn.access", logging.INFO, "", 0, '%s - "%s %s HTTP/%s" %d', + ("127.0.0.1:1", "GET", path, "1.1", 200), None) + assert not website_dev._unpolled(line(f"/.well-known/agent-env.json?{website_dev.POLL_QUERY}")) + assert website_dev._unpolled(line("/api/items")) + + +def test_the_local_clock_answers_as_the_gateways_and_is_set_and_advanced(clock): + base = clock.url.rsplit("/clock/", 1)[0] + assert parse_time(httpx.get(clock.url).json()["virtual_time"]) - parse_time("2030-01-01T09:00:00Z") < datetime.timedelta(seconds=5) + state = httpx.put(base + "/clock/set-time", json={"virtual_time": "2031-02-03T04:05:06Z", + "virtual_seconds_per_real_second": 0}).json() + assert (state["virtual_time"], state["virtual_seconds_per_real_second"]) == ("2031-02-03T04:05:06.000Z", 0.0) + assert httpx.put(base + "/clock/advance", json={"seconds": 3600}).json()["virtual_time"] == "2031-02-03T05:05:06.000Z" + assert httpx.put(base + "/clock/advance", json={"virtual_time": "2031-02-04T00:00:00Z"}).status_code == 200 + assert httpx.get(clock.url).json() == {"virtual_time": "2031-02-04T00:00:00.000Z", + "virtual_seconds_per_real_second": 0.0} + assert httpx.get(base + "/clock/state").json()["env_get_time_url"] == clock.url + backwards = httpx.put(base + "/clock/advance", json={"virtual_time": "2031-01-01T00:00:00Z"}) + assert backwards.status_code == 400 and "forward" in backwards.json()["error"] + assert httpx.put(base + "/clock/advance", json={}).status_code == 400 + assert httpx.put(base + "/clock/set-time", content=b"[1]").status_code == 400 + assert httpx.get(base + "/clock/other").status_code == 404 + + +def test_web_is_installed_when_it_has_no_node_modules_and_built_when_dist_is_older_than_a_source(tmp_path): + web = tmp_path / "web" + (web / "src").mkdir(parents=True) + (web / "package.json").write_text("{}") + (web / "src" / "main.js").write_text("") + assert website_dev.web_plan(web, watch=False) == ( + [["npm", "install", "--no-audit", "--no-fund"], ["npm", "run", "build"]], None) + (web / "package-lock.json").write_text("{}") + (web / "node_modules" / "vite").mkdir(parents=True) + (web / "dist").mkdir() + (web / "dist" / "index.html").write_text("") + for path in [*web.rglob("*")]: + os.utime(path, (1000, 1000)) + os.utime(web / "dist" / "index.html", (2000, 2000)) + (web / "node_modules" / "vite" / "new.js").write_text("") + assert not website_dev.web_stale(web) + assert website_dev.web_plan(web, watch=True) == ([], ["npm", "run", "build", "--", "--watch"]) + os.utime(web / "src" / "main.js", (3000, 3000)) + assert website_dev.web_stale(web) + assert website_dev.web_plan(web, watch=False)[0] == [["npm", "run", "build"]] + (web / "node_modules").rename(web / "gone") + assert website_dev.web_plan(web, watch=False)[0][0] == ["npm", "ci", "--no-audit", "--no-fund"] + + +def test_priming_syncs_the_site_to_the_clock_and_loads_the_seed_in_place_of_its_state(site_dir, clock, monkeypatch): + client = TestClient(_app(site_dir, monkeypatch)) + client.post("/api/items", json={"title": "made before priming"}) + seed = site_dir / "data" / "seed.json" + boot = website_dev.prime(client, "http://testserver", clock.url, seed) + assert boot == client.get("/healthz").headers[website_dev.BOOT_HEADER] + assert client.get("/healthz").headers[UNIVERSE_NOW_HEADER].startswith("2030-01-01T09:0") + assert [i["title"] for i in client.get("/api/items").json()] == ["Try the site"] + assert client.post("/api/items", json={"title": "now"}).json()["created_at"].startswith("2030-01-01T09:0") + + +def _card_site(card: dict, sent: list) -> httpx.Client: + """A client of a site that serves ``card`` and records each request it is sent, answering every other one.""" + def answer(request: httpx.Request) -> httpx.Response: + sent.append(request) + if request.url.path == WELL_KNOWN_PATH: + return httpx.Response(200, json=card) + return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": {}}) + + return httpx.Client(transport=httpx.MockTransport(answer)) + + +def test_priming_loads_the_seed_at_the_data_plane_the_card_names_and_refuses_one_it_doesnt_serve(site_dir): + seed = site_dir / "data" / "seed.json" + clock_ext = {"uri": website_dev.CLOCK_EXTENSION_URI, + "params": {"methods": {"sync_time": {"endpoint": "/agentenv/ext/clock/sync"}}}} + card = {"name": "shop", "url": "/svc/shop/rpc", "capabilities": {"extensions": [clock_ext], + "operations": ["data/reset", "data/add"]}} + sent: list[httpx.Request] = [] + website_dev.prime(_card_site(card, sent), "http://site", "http://clock", seed) + rpcs = [(str(r.url), json.loads(r.content)["method"]) for r in sent + if r.url.path not in (WELL_KNOWN_PATH, "/agentenv/ext/clock/sync")] + assert rpcs == [("http://site/svc/shop/rpc", "data/reset"), ("http://site/svc/shop/rpc", "data/add")] + + sent.clear() + website_dev.prime(_card_site({**card, "url": "http://elsewhere/rpc"}, sent), "http://site", "http://clock", seed) + assert {str(r.url) for r in sent if r.method == "POST" and "clock" not in r.url.path} == {"http://elsewhere/rpc"} + + sent.clear() + unserving = {**card, "capabilities": {**card["capabilities"], "operations": ["data/get"]}} + with pytest.raises(website_dev.DevError, match="doesn't serve data/reset, data/add"): + website_dev.prime(_card_site(unserving, sent), "http://site", "http://clock", seed) + assert not any("rpc" in r.url.path for r in sent) + + +def test_the_supervisor_primes_each_boot_once_and_runs_the_conformance_check_after_the_first(site_dir, clock, + monkeypatch): + first = TestClient(_app(site_dir, monkeypatch)) + said = [] + supervisor = website_dev.Supervisor("http://testserver", clock.url, site_dir / "data" / "seed.json", + echo=said.append, client=first) + with patch.object(website_dev, "prime", wraps=website_dev.prime) as primed: + assert supervisor.step() + assert "[dev] conformance: 9 passed or skipped, 0 failed" in said and primed.call_count == 2 + assert first.get("/healthz").headers[UNIVERSE_NOW_HEADER].startswith("2030-01-01T09:0") + assert supervisor.step() and primed.call_count == 2 + + reloaded = TestClient(_app(site_dir, monkeypatch)) + supervisor.client = reloaded + assert supervisor.step() and primed.call_count == 3 + assert sum("conformance:" in line for line in said) == 1 + assert reloaded.get("/healthz").headers[UNIVERSE_NOW_HEADER].startswith("2030-01-01T09:0") + + +def test_the_supervisor_shows_a_warning_and_syncs_the_site_again_even_when_the_check_raises(site_dir, clock, + monkeypatch): + client = TestClient(_app(site_dir, monkeypatch)) + said = [] + supervisor = website_dev.Supervisor("http://testserver", clock.url, None, echo=said.append, client=client) + warned = conformance.Result("clock", "warning", "interrupted by a reload", False) + with patch.object(conformance, "check", return_value=[warned]), \ + patch.object(website_dev, "prime", wraps=website_dev.prime) as primed: + assert supervisor.step() + assert "[dev] WARN clock: interrupted by a reload" in said and primed.call_count == 2 + + supervisor.checked = False + with patch.object(conformance, "check", side_effect=RuntimeError("boom")), \ + patch.object(website_dev, "prime", wraps=website_dev.prime) as primed, pytest.raises(RuntimeError): + supervisor._conformance() + assert primed.call_count == 1 + + with patch.object(conformance, "check", side_effect=httpx.RemoteProtocolError("reloaded")), \ + patch.object(website_dev, "prime", wraps=website_dev.prime) as primed: + supervisor._conformance() + assert "[dev] the conformance check lost the site, likely to a reload: reloaded" in said and primed.call_count == 1 + + +def test_the_supervisor_primes_again_on_the_next_poll_when_priming_after_the_check_fails(site_dir, clock, + monkeypatch): + client = TestClient(_app(site_dir, monkeypatch)) + said = [] + supervisor = website_dev.Supervisor("http://testserver", clock.url, None, echo=said.append, client=client) + real_prime = website_dev.prime + with patch.object(conformance, "check", return_value=[]), \ + patch.object(website_dev, "prime", side_effect=[real_prime(client, "http://testserver", clock.url, None), + httpx.ConnectError("refused")]): + assert supervisor.step() + assert "[dev] couldn't sync the site again after the check: refused" in said and supervisor.primed is None + with patch.object(website_dev, "prime", wraps=website_dev.prime) as primed: + assert supervisor.step() and primed.call_count == 1 + assert supervisor.primed == client.get("/healthz").headers[website_dev.BOOT_HEADER] + + +def test_the_supervisor_reports_a_seed_it_cant_read_and_primes_once_the_seed_is_back(site_dir, clock, monkeypatch): + client = TestClient(_app(site_dir, monkeypatch)) + said = [] + seed = site_dir / "data" / "seed.json" + kept = seed.read_text(encoding="utf-8") + seed.unlink() + supervisor = website_dev.Supervisor("http://testserver", clock.url, seed, echo=said.append, client=client) + with patch.object(conformance, "check", return_value=[]): + assert supervisor.step() + assert "couldn't sync the site and load its seed" in said[-1] and supervisor.primed is None + + seed.write_text(kept, encoding="utf-8") + real_prime = website_dev.prime + with patch.object(website_dev, "prime", side_effect=[real_prime(client, "http://testserver", clock.url, seed), + FileNotFoundError(str(seed))]): + assert supervisor.step() + assert "couldn't sync the site again after the check" in said[-1] and supervisor.primed is None + + assert supervisor.step() + assert supervisor.primed == client.get("/healthz").headers[website_dev.BOOT_HEADER] + assert [i["title"] for i in client.get("/api/items").json()] == ["Try the site"] + + +def test_the_supervisor_waits_for_a_server_that_isnt_up_and_reports_one_it_cant_prime(): + down = MagicMock(spec=httpx.Client) + down.get.side_effect = httpx.ConnectError("refused") + assert not website_dev.Supervisor("http://127.0.0.1:1", "http://clock", None, client=down).step() + + app = declare(domains=["x.test"])(type("Bare", (website_dev.WebsiteEnvironment,), {}))().create_app() + said = [] + supervisor = website_dev.Supervisor("http://testserver", "http://clock", None, client=TestClient(app), + echo=said.append, check=False) + with patch.object(website_dev, "find_extension_method", return_value=None): + assert supervisor.step() + assert "couldn't sync the site" in said[0] + + +def test_dev_runs_uvicorn_with_reload_beside_the_clock_and_the_supervisor(site_dir, monkeypatch): + monkeypatch.setenv(website_dev.SITE_ENV, "unset") + clock, supervisor = MagicMock(url="http://127.0.0.1:5555/clock/time"), MagicMock() + supervisor.start.return_value = supervisor + clock.start.return_value = clock + with patch.object(website_dev, "DevClock", return_value=clock) as made_clock, \ + patch.object(website_dev, "Supervisor", return_value=supervisor) as made_supervisor, \ + patch.object(website_dev, "npm_available", return_value=True), \ + patch.object(website_dev, "run_npm") as npm, \ + patch("uvicorn.run") as run: + result = CliRunner().invoke(website, ["dev", "--dir", str(site_dir), "--port", "8123", "--no-watch", + "--clock-time", "2030-01-01T09:00:00Z"]) + assert os.environ[website_dev.SITE_ENV] == str(site_dir.resolve() / "site.py") + assert result.exit_code == 0, result.output + assert [c.args[0] for c in npm.call_args_list] == [["npm", "install", "--no-audit", "--no-fund"]] + assert made_clock.call_args.args[0] == parse_time("2030-01-01T09:00:00Z") + assert made_supervisor.call_args.args == ("http://127.0.0.1:8123", clock.url, site_dir.resolve() / "data" / "seed.json") + assert run.call_args.args == ("agent_env.cli.env.website_dev:create_app",) + options = run.call_args.kwargs + assert (options["factory"], options["reload"], options["port"]) == (True, True, 8123) + assert options["reload_dirs"] == [str(site_dir.resolve())] + assert options["reload_excludes"] == [str(site_dir.resolve() / "web")] + assert "http://127.0.0.1:8123/" in result.output and "/clock/advance" in result.output + supervisor.stop.assert_called_once() + clock.close.assert_called_once() + + +def test_dev_refuses_a_folder_without_its_site_and_a_clock_time_that_isnt_one(tmp_path, site_dir): + missing = CliRunner().invoke(website, ["dev", "--dir", str(tmp_path)]) + assert missing.exit_code == 1 and "no site.py" in missing.output + bad = CliRunner().invoke(website, ["dev", "--dir", str(site_dir), "--clock-time", "soon"]) + assert bad.exit_code == 1 and "not an RFC3339 time" in bad.output + (tmp_path / "site.py").write_text("raise RuntimeError('broken')\n") + broken = CliRunner().invoke(website, ["dev", "--dir", str(tmp_path)]) + assert broken.exit_code == 1 and "broken" in broken.output diff --git a/tst/unit/cli/website_new_cli_test.py b/tst/unit/cli/website_new_cli_test.py new file mode 100644 index 000000000..383f18547 --- /dev/null +++ b/tst/unit/cli/website_new_cli_test.py @@ -0,0 +1,252 @@ +"""agent-env env website new: a site like the Notes example, which imports and passes the website/v1 conformance +check as it is written; refused over a non-empty folder without --force; and, from an MCP server's source or a tools +JSON, one stub @api route per tool with a seed collection for each.""" + +import importlib.util +import json +import sys +import uuid +from pathlib import Path + +import pytest +from click.testing import CliRunner +from starlette.testclient import TestClient + +from agent_env.cli.env.website import website +from agent_env.cli.env.website_scaffold import CLIENT_DIR, tools_from_json, tools_from_mcp +from agentenv_protocol.website import SESSION_PATH, WebsiteEnvironment, conformance +from agentenv_protocol.website import website as declare + +MAIL_SERVER = Path(__file__).resolve().parents[3] / "src" / "agent_env" / "examples" / "mail-mcp" / "envs" / "mail" +FILES = {".gitignore", "Dockerfile", "Dockerfile.dockerignore", "README.md", "env.toml", "site.py", "data/seed.json", + "web/index.html", "web/package.json", "web/vite.config.js", "web/src/main.js", "web/src/style.css", + "web/src/site-client.js", "web/src/site-client.d.ts"} +FASTMCP_SERVER = ''' +from typing import Annotated, Literal, Optional +from mcp.server.fastmcp import Context, FastMCP +from pydantic import Field + +mcp = FastMCP("calendar") + + +@mcp.tool(name="list_events") +async def list_events(day: str, limit: int = 10, ctx: Context = None) -> list: + """The events of a day.""" + + +@mcp.tool() +def create_event( + title: Annotated[str, Field(description="What the event is.")], + attendees: list[str], + kind: Literal["meeting", "focus"] = "meeting", + notes: Optional[str] = None, + room: str = Field(None, description="Where."), +) -> dict: + """Add an event.""" + + +def helper(x: int) -> int: + return x +''' + + +def _new(*args): + return CliRunner().invoke(website, ["new", *args]) + + +def _load(site_dir: Path) -> type[WebsiteEnvironment]: + """The generated ``site.py``'s site, serving a stand-in page instead of ``web/dist``, which only a Vite build makes.""" + spec = importlib.util.spec_from_file_location(f"generated_site_{uuid.uuid4().hex}", site_dir / "site.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + cls = next(v for v in vars(module).values() if isinstance(v, type) and issubclass(v, WebsiteEnvironment) + and v is not WebsiteEnvironment) + page = site_dir / "stand-in" + page.mkdir() + (page / "index.html").write_text("site") + declared = cls.declaration()._replace(static=str(page), data=str(site_dir / "data" / "seed.json")) + return declare(**declared._asdict())(type(cls.__name__, (cls,), {})) + + +def _passes_conformance(client: TestClient) -> None: + """The conformance check, whose clock check serves a clock on 127.0.0.1 for the site to read.""" + results = conformance.check("http://testserver", client=client) + assert {r.name: r.status for r in results} == dict.fromkeys( + ["card", "any_host", "health", "universe_now", "reserved_paths", "data_plane", "clock", "forwarded", "cookies"], "passed"), results + + +def test_a_new_site_has_notes_layout_and_the_sdks_browser_client(tmp_path): + result = _new("shop", "--dir", str(tmp_path / "shop"), "--domain", "shop.example") + assert result.exit_code == 0, result.output + assert "agent-env env website dev" in result.output and "--health-path" not in result.output + site = tmp_path / "shop" + assert {str(p.relative_to(site)) for p in site.rglob("*") if p.is_file()} == FILES + assert (site / "env.toml").read_text() == ('type = "website"\nenvironment_name = "shop"\n' + 'backend_image = { dockerfile = "Dockerfile" }\n') + assert "--health-path" not in (site / "README.md").read_text() + package = json.loads((site / "web" / "package.json").read_text()) + assert package["devDependencies"] == {"vite": "5.4.10"} and "dependencies" not in package + vite = (site / "web" / "vite.config.js").read_text() + assert 'base: "./"' in vite and '"/api":' in vite and '"/healthz":' in vite + assert 'from "./site-client.js"' in (site / "web" / "src" / "main.js").read_text() + for client in ("site-client.js", "site-client.d.ts"): + assert (site / "web" / "src" / client).read_text() == (CLIENT_DIR / client).read_text() + assert "{{" not in "".join(p.read_text() for p in site.rglob("*") if p.is_file()) + assert 'domains=["shop.example"]' in (site / "site.py").read_text() + assert "COPY site.py /app/site.py" in (site / "Dockerfile").read_text() + + +def test_the_new_site_imports_and_passes_the_conformance_check(tmp_path, socket_enabled): + assert _new("shop", "--dir", str(tmp_path / "shop")).exit_code == 0 + app = _load(tmp_path / "shop")().create_app() + client = TestClient(app) + _passes_conformance(client) + card = client.get("/.well-known/agent-env.json").json() + site = next(e for e in card["capabilities"]["extensions"] if e["uri"] == "urn:agentenv:website/v1")["params"] + assert (card["name"], site["domains"], site["health"]) == ("shop", ["shop.test"], "/healthz") + assert client.get("/api/session").json()["user"] == "ada" + created = client.post("/api/items", json={"title": " Pack "}) + assert created.status_code == 201 and created.json()["title"] == "Pack" + assert [i["title"] for i in client.get("/api/items").json()] == ["Pack", "Try the site"] + assert client.post("/api/sign-in", json={"user": "grace"}).json() == {"user": "grace"} + assert [i["title"] for i in client.get("/api/items").json()] == ["Write the first task"] + + +def test_a_non_empty_folder_is_refused_unless_forced_and_a_bad_name_always(tmp_path): + (tmp_path / "keep.txt").write_text("mine") + refused = _new("shop", "--dir", str(tmp_path)) + assert refused.exit_code == 1 and "not empty; pass --force" in refused.output + assert not (tmp_path / "site.py").exists() + forced = _new("shop", "--dir", str(tmp_path), "--force") + assert forced.exit_code == 0, forced.output + assert (tmp_path / "site.py").is_file() and (tmp_path / "keep.txt").read_text() == "mine" + bad = _new("My Shop", "--dir", str(tmp_path / "other")) + assert bad.exit_code == 1 and "lowercase letters, digits and hyphens" in bad.output + both = _new("shop", "--dir", str(tmp_path / "x"), "--from-mcp", str(MAIL_SERVER), + "--from-tools-json", str(tmp_path / "keep.txt")) + assert both.exit_code == 1 and "mutually exclusive" in both.output + + +def test_an_mcp_servers_tools_are_read_from_its_source_without_running_it(tmp_path): + assert tools_from_mcp(MAIL_SERVER) == [{ + "name": "search_emails", + "description": "Search the inbox: the emails holding every word of the query, ignoring case, as a JSON list.", + "inputSchema": {"type": "object", "required": ["query"], "properties": {"query": { + "type": "string", "description": "Words each email returned holds, in its sender, subject or body."}}}}] + (tmp_path / "server.py").write_text(FASTMCP_SERVER) + listed, created = tools_from_mcp(tmp_path) + assert listed["name"] == "list_events" and listed["description"] == "The events of a day." + assert listed["inputSchema"] == {"type": "object", "required": ["day"], "properties": { + "day": {"type": "string"}, "limit": {"type": "integer", "default": 10}}} + assert created["name"] == "create_event" + assert created["inputSchema"]["required"] == ["title", "attendees"] + assert created["inputSchema"]["properties"] == { + "title": {"type": "string", "description": "What the event is."}, + "attendees": {"type": "array", "items": {"type": "string"}}, + "kind": {"enum": ["meeting", "focus"], "default": "meeting"}, + "notes": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None}, + "room": {"type": "string", "description": "Where.", "default": None}} + empty = tmp_path / "empty" + empty.mkdir() + (empty / "x.py").write_text("def f():\n pass\n") + refused = _new("cal", "--dir", str(tmp_path / "cal"), "--from-mcp", str(empty)) + assert refused.exit_code == 1 and "--from-tools-json" in refused.output + + +def test_a_site_from_an_mcp_server_has_a_stub_route_per_tool_and_passes_the_check(tmp_path, socket_enabled): + (tmp_path / "mcp").mkdir() + (tmp_path / "mcp" / "server.py").write_text(FASTMCP_SERVER) + result = _new("calendar", "--dir", str(tmp_path / "site"), "--from-mcp", str(tmp_path / "mcp")) + assert result.exit_code == 0, result.output + assert "2 stub route(s)" in result.output + seed = json.loads((tmp_path / "site" / "data" / "seed.json").read_text()) + assert seed == {"current_user": "user", "users": [{"id": "user", "name": "Sample User"}], "events": []} + cls = _load(tmp_path / "site") + assert [(r.method, r.path, r.name) for r in cls.routes()] == [ + ("GET", "/api/users", "list_users"), ("POST", "/api/sign-in", "sign_in"), + ("GET", "/api/list-events", "list_events"), ("POST", "/api/create-event", "create_event")] + client = TestClient(cls().create_app()) + _passes_conformance(client) + listed = client.get("/api/list-events", params={"day": "2026-01-05"}) + assert (listed.status_code, listed.json()["code"]) == (501, "not_implemented") + assert client.get("/api/list-events", params={"limit": "x"}).status_code == 400 + created = client.post("/api/create-event", json={"title": "Standup", "attendees": ["ann"], "kind": "focus"}) + assert created.status_code == 501, created.text + assert client.post("/api/create-event", json={"title": "Standup", "kind": "party"}).status_code == 400 + main = (tmp_path / "site" / "web" / "src" / "main.js").read_text() + assert '"path": "api/create-event"' in main and '"name": "list_events"' in main + + +@pytest.mark.parametrize("shape", ["tools/list", "rpc", "list", "card"]) +def test_tools_json_in_any_of_its_shapes_makes_stub_routes(tmp_path, shape, socket_enabled): + tools = [{"name": "send_email", "description": "Send an email.", "inputSchema": {"type": "object", "properties": { + "to": {"type": "array", "items": {"type": "string"}}, "from": {"type": "string"}, + "urgent": {"type": "boolean", "default": False}}, "required": ["to"]}}, + {"name": "list_users", "description": "A tool named like a built-in route.", "inputSchema": {}}] + data = {"tools/list": {"tools": tools}, "rpc": {"jsonrpc": "2.0", "id": 1, "result": {"tools": tools}}, + "list": tools, "card": {"name": "mail", "capabilities": {"tools": tools}}}[shape] + (tmp_path / "tools.json").write_text(json.dumps(data)) + assert [t["name"] for t in tools_from_json(tmp_path / "tools.json")] == ["send_email", "list_users"] + result = _new("mail", "--dir", str(tmp_path / "site"), "--from-tools-json", str(tmp_path / "tools.json")) + assert result.exit_code == 0, result.output + cls = _load(tmp_path / "site") + assert [r.name for r in cls.routes()] == ["list_users", "sign_in", "send_email", "list_users_tool"] + client = TestClient(cls().create_app()) + _passes_conformance(client) + assert client.post("/api/send-email", json={"to": ["a@b.c"], "from": "me@b.c"}).status_code == 501 + assert client.post("/api/send-email", json={}).status_code == 400 + assert client.get("/api/list-users").status_code == 501 + assert client.get("/api/users").json() == [{"id": "user", "name": "Sample User"}] + + +def test_a_tool_named_like_a_path_the_sdk_serves_gets_a_path_of_its_own(tmp_path): + (tmp_path / "tools.json").write_text(json.dumps([{"name": "session", "inputSchema": {}}])) + result = _new("mail", "--dir", str(tmp_path / "site"), "--from-tools-json", str(tmp_path / "tools.json")) + assert result.exit_code == 0, result.output + stub = next(r for r in _load(tmp_path / "site").routes() if r.name == "session") + assert SESSION_PATH.startswith("/api/") and stub.path == f"{SESSION_PATH}-tool" + + +def test_a_tool_named_with_a_leading_digit_makes_a_site_that_loads(tmp_path): + (tmp_path / "tools.json").write_text(json.dumps([{"name": "3d_render", "inputSchema": {}}])) + result = _new("render", "--dir", str(tmp_path / "site"), "--from-tools-json", str(tmp_path / "tools.json")) + assert result.exit_code == 0, result.output + assert "class N3dRenderRequest(BaseModel)" in (tmp_path / "site" / "site.py").read_text() + assert [r.name for r in _load(tmp_path / "site").routes()][-1] == "3d_render" + + +def test_a_tool_description_ending_in_a_quote_or_a_backslash_makes_a_site_that_loads(tmp_path): + tools = [{"name": "set_status", "description": 'Set status to "done"', "inputSchema": {}}, + {"name": "read_path", "description": "Read C:\\temp\\", "inputSchema": {}}, + {"name": "find_note", "description": 'Find a "note"\n\nBy its "title"', "inputSchema": + {"type": "object", "properties": {"q": {"type": "string", "description": 'like "x"'}}}}] + (tmp_path / "tools.json").write_text(json.dumps(tools)) + result = _new("status", "--dir", str(tmp_path / "site"), "--from-tools-json", str(tmp_path / "tools.json")) + assert result.exit_code == 0, result.output + cls = _load(tmp_path / "site") + assert cls.set_status.__doc__ == 'Set status to "done"' and cls.read_path.__doc__ == "Read C:\\temp\\" + + +def test_a_tools_json_that_lists_no_tools_is_refused(tmp_path): + (tmp_path / "tools.json").write_text(json.dumps({"tools": "nope"})) + refused = _new("mail", "--dir", str(tmp_path / "site"), "--from-tools-json", str(tmp_path / "tools.json")) + assert refused.exit_code == 1 and "lists no tools" in refused.output + assert not (tmp_path / "site").exists() + + +def test_tools_named_after_what_the_site_module_uses_make_a_site_that_loads(tmp_path): + names = ["api", "ready_check", "website", "environment_card", "ApiContext", "ApiError", "NotFound", "BaseModel", + "Field", "Optional", "Any", "Literal", "SignIn", "USERS", "list", "dict", "str", "int", "float", "bool", + "after_all"] + (tmp_path / "tools.json").write_text(json.dumps([{"name": n, "inputSchema": {}} for n in names])) + result = _new("shadow", "--dir", str(tmp_path / "site"), "--from-tools-json", str(tmp_path / "tools.json")) + assert result.exit_code == 0, result.output + cls = _load(tmp_path / "site") + routes = cls.routes() + assert [r.name for r in routes] == ["list_users", "sign_in", *names] + client = TestClient(cls().create_app()) + assert client.get("/healthz").status_code == 200 + for route in routes[2:]: + answer = client.request(route.method, route.path, **({} if route.method == "GET" else {"json": {}})) + assert (answer.status_code, answer.json()["code"]) == (501, "not_implemented"), route diff --git a/tst/unit/cli/website_server_image_cli_test.py b/tst/unit/cli/website_server_image_cli_test.py index 1a04d38c9..8db97945a 100644 --- a/tst/unit/cli/website_server_image_cli_test.py +++ b/tst/unit/cli/website_server_image_cli_test.py @@ -10,6 +10,7 @@ from agent_env.cli.env.website import website from agent_env.env import Env +from agent_env.providers.env_providers.constants import AGENT_ENV_WEBSITE_HEALTH_PATH def _stored(id, **_): @@ -27,14 +28,22 @@ def test_a_site_is_built_from_its_server_dockerfile_alone_with_its_build_context with patch("agent_env.cli.env.website.build_image", side_effect=lambda *a, **k: built.append(a[2])), \ patch("agent_env.artifact.artifacts.docker_image.DockerImageArtifact.put", side_effect=lambda **k: _stored(k["id"])) as stored: - result = _put("--server-dockerfile", str(tmp_path / "Dockerfile"), "--environment-name", "mail", - "--health-path", "/healthz") + result = _put("--server-dockerfile", str(tmp_path / "Dockerfile"), "--environment-name", "mail") assert result.exit_code == 0, result.output assert len(built) == 1 and "deprecated" not in result.output assert stored.call_args.kwargs["build_context_path"] == str(tmp_path) assert stored.call_args.kwargs["dockerfile_path"] == str(tmp_path / "Dockerfile") env = Env.get("mail-site") - assert (env.frontend_docker_image_artifact, env.health_path) == (None, "/healthz") + assert (env.frontend_docker_image_artifact, env.health_path) == (None, AGENT_ENV_WEBSITE_HEALTH_PATH) + + +def test_a_health_path_is_kept_for_a_site_without_ready_and_warned_of_as_deprecated(local_stores): + with patch("agent_env.cli.env.website.DockerImageArtifact.put_ref", side_effect=lambda id, **k: _stored(id)): + result = _put("--server-image-ref", "ghcr.io/acme/mail:3", "--environment-name", "mail", + "--health-path", "/healthz") + assert result.exit_code == 0, result.output + assert "--health-path is deprecated" in result.output + assert Env.get("mail-site").health_path == "/healthz" def test_a_site_registers_a_server_image_already_in_a_registry(local_stores): diff --git a/tst/unit/config/test_explain_and_sources.py b/tst/unit/config/test_explain_and_sources.py index 07d55ad1f..809adaf91 100644 --- a/tst/unit/config/test_explain_and_sources.py +++ b/tst/unit/config/test_explain_and_sources.py @@ -163,7 +163,8 @@ def test_sources_says_how_the_file_was_found(config_file, tmp_path, monkeypatch) # Read for identity (USER), or set from [sandbox.providers.sail_vm.config] for the Sail SDK to read # (SAIL_*), never configuration agent-env takes, so none is a layer. # AGENT_ENV_INSTALLED_REQUIRES marks a command run again after installing what a bundle requires, so it never asks twice. -_NOT_CONFIGURATION = {"USER", "DISPLAY", "SAIL_API_KEY", "SAIL_RUNTIME_THREADS", "AGENT_ENV_INSTALLED_REQUIRES"} +_NOT_CONFIGURATION = {"USER", "DISPLAY", "SAIL_API_KEY", "SAIL_RUNTIME_THREADS", "AGENT_ENV_INSTALLED_REQUIRES", + "AGENT_ENV_WEBSITE_DEV_SITE"} def _env_vars_read_by(root): From d7413aaf017a187db5ccee045d3f41deb2a94a8d Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 15:00:05 -0800 Subject: [PATCH 28/57] fix(website): env website dev reloads on website.toml changes, and names its timeouts (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/cli/env/website.py | 4 ++-- src/agent_env/cli/env/website_dev.py | 6 +++++- tst/unit/cli/website_dev_cli_test.py | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/agent_env/cli/env/website.py b/src/agent_env/cli/env/website.py index de7d75aa0..6ce3e302e 100644 --- a/src/agent_env/cli/env/website.py +++ b/src/agent_env/cli/env/website.py @@ -146,14 +146,14 @@ def dev(directory: Path, site_file: str, host: str, port: int, clock_port: int, os.environ[website_dev.SITE_ENV] = str(site_path) try: uvicorn.run("agent_env.cli.env.website_dev:create_app", factory=True, host=host, port=port, reload=True, - reload_dirs=[str(site_dir)], reload_includes=["*.py", "*.json"], reload_excludes=excludes or None) + reload_dirs=[str(site_dir)], reload_includes=website_dev.RELOAD_INCLUDES, reload_excludes=excludes or None) finally: supervisor.stop() clock.close() if watcher is not None: watcher.terminate() try: - watcher.wait(timeout=5) + watcher.wait(timeout=website_dev.WATCHER_STOP_TIMEOUT_S) except subprocess.TimeoutExpired: watcher.kill() diff --git a/src/agent_env/cli/env/website_dev.py b/src/agent_env/cli/env/website_dev.py index e5af25a4e..499a8393a 100644 --- a/src/agent_env/cli/env/website_dev.py +++ b/src/agent_env/cli/env/website_dev.py @@ -30,6 +30,8 @@ SITE_ENV = "AGENT_ENV_WEBSITE_DEV_SITE" BOOT_HEADER = "X-Site-Boot" POLL_QUERY = "agent-env-dev-poll" +RELOAD_INCLUDES = ["*.py", "*.json", "*.toml"] +WATCHER_STOP_TIMEOUT_S = 5.0 _WEB_SKIPPED = {"node_modules", "dist"} @@ -264,11 +266,13 @@ class Supervisor: couldn't prime, before or after the check and for a seed it couldn't read too, counts as unprimed, so the next poll tries again.""" + HTTP_TIMEOUT_S = 10.0 + def __init__(self, base_url: str, clock_url: str, seed: Path | None, *, check: bool = True, echo: Callable[[str], None] = print, client: httpx.Client | None = None, interval: float = 1.0) -> None: self.base_url, self.clock_url, self.seed, self.check = base_url, clock_url, seed, check self.echo, self.interval = echo, interval - self.client = client or httpx.Client(timeout=10) + self.client = client or httpx.Client(timeout=self.HTTP_TIMEOUT_S) self.primed: str | None = None self.checked = False self.stopped = threading.Event() diff --git a/tst/unit/cli/website_dev_cli_test.py b/tst/unit/cli/website_dev_cli_test.py index 0fdcd7bb9..e62a338f0 100644 --- a/tst/unit/cli/website_dev_cli_test.py +++ b/tst/unit/cli/website_dev_cli_test.py @@ -294,6 +294,7 @@ def test_dev_runs_uvicorn_with_reload_beside_the_clock_and_the_supervisor(site_d options = run.call_args.kwargs assert (options["factory"], options["reload"], options["port"]) == (True, True, 8123) assert options["reload_dirs"] == [str(site_dir.resolve())] + assert options["reload_includes"] == ["*.py", "*.json", "*.toml"] assert options["reload_excludes"] == [str(site_dir.resolve() / "web")] assert "http://127.0.0.1:8123/" in result.output and "/clock/advance" in result.output supervisor.stop.assert_called_once() From 9716e6b9fc6b4c0385731d75c03d5dd41da0ec8f Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 16:37:56 -0800 Subject: [PATCH 29/57] fix(website): a new site's page reloads itself at its slash-ending address, as Notes does (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/cli/env/website_template/README.md.tmpl | 2 +- src/agent_env/cli/env/website_template/web/index.html.tmpl | 3 +++ tst/unit/cli/website_new_cli_test.py | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/agent_env/cli/env/website_template/README.md.tmpl b/src/agent_env/cli/env/website_template/README.md.tmpl index c17de7b80..e9296dca7 100644 --- a/src/agent_env/cli/env/website_template/README.md.tmpl +++ b/src/agent_env/cli/env/website_template/README.md.tmpl @@ -7,7 +7,7 @@ | --- | --- | | `site.py` | The server: the `{{class_name}}` WebsiteEnvironment, its `@api` routes and its `@ready_check`, and `serve()` on `PORT` (8000). | | `data/seed.json` | The seed data the store starts with: `current_user`, the acting user, the `users` a browser can sign in as, and the site's collections. | -| `web/` | A minimal Vite app. Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/{{name}}/` alike. `src/site-client.js` is the SDK's browser client: `api()`, `universeNow()`, `followUniverseClock()`, `session()`, `signIn()` and `signOut()`. | +| `web/` | A minimal Vite app. Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/{{name}}/` alike; opened at a path without its final slash, such as `/website/{{name}}`, the page first reloads itself with it. `src/site-client.js` is the SDK's browser client: `api()`, `universeNow()`, `followUniverseClock()`, `session()`, `signIn()` and `signOut()`. | | `Dockerfile` | The server image, built with this folder as its context: Node builds `web/`, then Python installs `agentenv-framework-protocol` with uvicorn. | | `env.toml` | The website env as a bundle entry. | diff --git a/src/agent_env/cli/env/website_template/web/index.html.tmpl b/src/agent_env/cli/env/website_template/web/index.html.tmpl index d4119cdc4..fca23f871 100644 --- a/src/agent_env/cli/env/website_template/web/index.html.tmpl +++ b/src/agent_env/cli/env/website_template/web/index.html.tmpl @@ -4,6 +4,9 @@ {{title}} +
diff --git a/tst/unit/cli/website_new_cli_test.py b/tst/unit/cli/website_new_cli_test.py index 383f18547..309407977 100644 --- a/tst/unit/cli/website_new_cli_test.py +++ b/tst/unit/cli/website_new_cli_test.py @@ -90,6 +90,8 @@ def test_a_new_site_has_notes_layout_and_the_sdks_browser_client(tmp_path): vite = (site / "web" / "vite.config.js").read_text() assert 'base: "./"' in vite and '"/api":' in vite and '"/healthz":' in vite assert 'from "./site-client.js"' in (site / "web" / "src" / "main.js").read_text() + page = (site / "web" / "index.html").read_text() + assert page.index('location.pathname.endsWith("/")') < page.index(' From 7c6718508609ae35e1a52a6120e233ba82a333ba Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 21:09:22 -0800 Subject: [PATCH 32/57] fix(website): the browser client reports a body lost after its headers as a network ApiError (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../website/notes/web/src/site-client.js | 7 ++++++- .../website/client/site-client.js | 7 ++++++- .../tests/test_website_notes_example.py | 21 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/site-client.js b/packages/agentenv-protocol/examples/website/notes/web/src/site-client.js index 2d5b65a39..853cb12c4 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/site-client.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/site-client.js @@ -53,7 +53,12 @@ export async function api(path, { method = "GET", body, headers = {} } = {}) { throw new ApiError(0, "network_error", `${method} ${url}: ${cause?.message || cause}`, null); } follow(response); - const text = await response.text(); + let text; + try { + text = await response.text(); + } catch (cause) { + throw new ApiError(response.status, "network_error", `${method} ${url}: ${cause?.message || cause}`, null); + } let data = null; try { data = text ? JSON.parse(text) : null; diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/client/site-client.js b/packages/agentenv-protocol/src/agentenv_protocol/website/client/site-client.js index 2d5b65a39..853cb12c4 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/client/site-client.js +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/client/site-client.js @@ -53,7 +53,12 @@ export async function api(path, { method = "GET", body, headers = {} } = {}) { throw new ApiError(0, "network_error", `${method} ${url}: ${cause?.message || cause}`, null); } follow(response); - const text = await response.text(); + let text; + try { + text = await response.text(); + } catch (cause) { + throw new ApiError(response.status, "network_error", `${method} ${url}: ${cause?.message || cause}`, null); + } let data = null; try { data = text ? JSON.parse(text) : null; diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index 2b61707d2..736f99e44 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -8,6 +8,8 @@ import importlib.util import json import re +import shutil +import subprocess import sys import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -243,3 +245,22 @@ def test_the_page_uses_the_sdks_browser_client_as_the_package_ships_it() -> None shipped = Path(website.__code__.co_filename).parent / "client" / "site-client.js" assert (NOTES_DIR / "web" / "src" / "site-client.js").read_text(encoding="utf-8") == shipped.read_text(encoding="utf-8") assert 'from "./site-client.js"' in (NOTES_DIR / "web" / "src" / "main.js").read_text(encoding="utf-8") + + +DROPPED_BODY = """ +globalThis.location = {href: "http://site.test/"}; +globalThis.fetch = async () => ({ok: false, status: 502, headers: {get: () => null}, + text: async () => { throw new Error("connection reset"); }}); +const {api, ApiError} = await import(process.argv[1]); +try { await api("api/notes"); } catch (error) { + console.log(JSON.stringify({isApiError: error instanceof ApiError, status: error.status, code: error.code})); +} +""" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="needs node") +def test_the_browser_client_reports_a_body_lost_after_its_headers_as_a_network_api_error() -> None: + shipped = Path(website.__code__.co_filename).parent / "client" / "site-client.js" + out = subprocess.run(["node", "--input-type=module", "-e", DROPPED_BODY, shipped.as_uri()], + capture_output=True, text=True, check=True).stdout + assert json.loads(out) == {"isApiError": True, "status": 502, "code": "network_error"} From 8dfabcbb1e27ca00c5a33e19abf67a0de5e8c376 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 13:34:08 -0800 Subject: [PATCH 33/57] feat(website): live sites, browser/v1 sign-in and WARC replay (FD-4683) This PR's change, rebased as one commit onto #446. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/replay/Dockerfile | 14 + .../website/replay/Dockerfile.dockerignore | 6 + .../examples/website/replay/README.md | 77 ++ .../examples/website/replay/capture.warc.gz | Bin 0 -> 4147 bytes .../examples/website/replay/env.toml | 3 + .../examples/website/replay/site.py | 38 + .../examples/website/replay/source/app.js | 8 + .../website/replay/source/guide/advanced.html | 5 + .../website/replay/source/guide/install.html | 9 + .../website/replay/source/guide/usage.html | 9 + .../examples/website/replay/source/index.html | 23 + .../examples/website/replay/source/style.css | 1 + .../src/agentenv_protocol/website/__init__.py | 10 +- .../src/agentenv_protocol/website/browser.py | 93 +++ .../agentenv_protocol/website/environment.py | 106 ++- .../src/agentenv_protocol/website/replay.py | 696 ++++++++++++++++++ .../tests/test_website_live.py | 190 +++++ .../tests/test_website_replay.py | 490 ++++++++++++ .../env/envs/playwright_mcp/entrypoint.sh | 3 + .../playwright_mcp/recorder-requirements.txt | 4 +- .../env/envs/playwright_mcp/recorder.py | 121 ++- src/agent_env/env/envs/website.py | 45 +- .../env_providers/env_gateway_provider.py | 41 +- src/agent_env/task/episode/rules.py | 2 +- src/agent_env/task_step/registry.py | 2 + .../task_step/task_steps/deploy_env.py | 4 +- .../task_step/task_steps/reset_env.py | 6 +- .../task_step/task_steps/sign_in_browser.py | 284 +++++++ .../env/envs/test_playwright_mcp_cookies.py | 159 ++++ .../env/envs/test_playwright_mcp_recorder.py | 5 +- .../env/envs/test_website_env_settings.py | 94 +++ tst/unit/task_step/sign_in_browser_test.py | 246 +++++++ 32 files changed, 2765 insertions(+), 29 deletions(-) create mode 100644 packages/agentenv-protocol/examples/website/replay/Dockerfile create mode 100644 packages/agentenv-protocol/examples/website/replay/Dockerfile.dockerignore create mode 100644 packages/agentenv-protocol/examples/website/replay/README.md create mode 100644 packages/agentenv-protocol/examples/website/replay/capture.warc.gz create mode 100644 packages/agentenv-protocol/examples/website/replay/env.toml create mode 100644 packages/agentenv-protocol/examples/website/replay/site.py create mode 100644 packages/agentenv-protocol/examples/website/replay/source/app.js create mode 100644 packages/agentenv-protocol/examples/website/replay/source/guide/advanced.html create mode 100644 packages/agentenv-protocol/examples/website/replay/source/guide/install.html create mode 100644 packages/agentenv-protocol/examples/website/replay/source/guide/usage.html create mode 100644 packages/agentenv-protocol/examples/website/replay/source/index.html create mode 100644 packages/agentenv-protocol/examples/website/replay/source/style.css create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/browser.py create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/replay.py create mode 100644 packages/agentenv-protocol/tests/test_website_live.py create mode 100644 packages/agentenv-protocol/tests/test_website_replay.py create mode 100644 src/agent_env/task_step/task_steps/sign_in_browser.py create mode 100644 tst/unit/env/envs/test_playwright_mcp_cookies.py create mode 100644 tst/unit/env/envs/test_website_env_settings.py create mode 100644 tst/unit/task_step/sign_in_browser_test.py diff --git a/packages/agentenv-protocol/examples/website/replay/Dockerfile b/packages/agentenv-protocol/examples/website/replay/Dockerfile new file mode 100644 index 000000000..c641d704b --- /dev/null +++ b/packages/agentenv-protocol/examples/website/replay/Dockerfile @@ -0,0 +1,14 @@ +ARG REPLAY=packages/agentenv-protocol/examples/website/replay + +FROM mirror.gcr.io/library/python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf +ARG REPLAY +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY packages/agentenv-protocol /tmp/agentenv-protocol +RUN pip install --no-cache-dir /tmp/agentenv-protocol "uvicorn>=0.30" && rm -rf /tmp/agentenv-protocol +COPY ${REPLAY}/site.py ${REPLAY}/capture.warc.gz /app/ +ENV PORT=8000 +EXPOSE 8000 +RUN useradd --create-home --uid 10001 appuser +USER appuser +CMD ["python", "site.py"] diff --git a/packages/agentenv-protocol/examples/website/replay/Dockerfile.dockerignore b/packages/agentenv-protocol/examples/website/replay/Dockerfile.dockerignore new file mode 100644 index 000000000..10ae9270c --- /dev/null +++ b/packages/agentenv-protocol/examples/website/replay/Dockerfile.dockerignore @@ -0,0 +1,6 @@ +**/node_modules +**/dist +**/__pycache__ +**/.pytest_cache +**/.venv +.git diff --git a/packages/agentenv-protocol/examples/website/replay/README.md b/packages/agentenv-protocol/examples/website/replay/README.md new file mode 100644 index 000000000..f41fd1bb0 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/replay/README.md @@ -0,0 +1,77 @@ +# Widget Docs: a captured site, replayed + +Widget Docs is a small docs site captured to a WARC file and served by +`agentenv_protocol.website.replay.ReplayWebsite`. The captured pages are served as they were captured, for any host, +with only their same-site links rewritten to stay on the replay. The newsletter sign-up, which a capture can't answer, is written as `@api` routes on top. + +| File | What it is | +| --- | --- | +| `source/` | The site that was captured: a home page, two guide pages, a stylesheet and a script. | +| `capture.warc.gz` | The capture of `source/` at `--depth 1`: `/`, `/guide/install.html`, `/guide/usage.html?utm_source=home`, `/style.css` and `/app.js`. `/guide/advanced.html` is two links from home, so it isn't in the capture. | +| `Dockerfile`, `env.toml` | The server image, built from the repo root as the Notes example's is, and the website env as a bundle entry. | +| `site.py` | The server: `WidgetDocs(ReplayWebsite)` with `capture = "capture.warc.gz"`, plus `POST /api/subscribe` and `GET /api/subscriptions`. | + +## Capture a site + +```bash +python -m agentenv_protocol.website.replay capture https://docs.example.com/ --out capture.warc.gz --depth 2 +``` + +The tool needs only this package and `httpx`. It fetches each start URL. It then fetches the same-site pages those URLs +link to, up to `--depth` links away, and the assets (stylesheets, scripts, images) each captured page loads, with the +stylesheets, images and fonts a captured stylesheet's `@import` and `url()` name. Every +answer goes into a WARC 1.1 file, gzipped one record at a time, which pywb and warcio also read. Notes: + +- Only GETs are captured. +- Redirects are kept as they were answered, and their targets are captured too. +- Bodies are stored decoded, so the stored headers leave out `Content-Encoding` and `Transfer-Encoding`. +- A host is the same site as a start URL when it is that URL's host (without `www.`) or a subdomain of it. +- Request records name only the method, the target and `Host`, so no credential the capturing client sent ends up in + the file. +- `--max-requests` (500 by default) caps a capture, and `--no-assets` leaves out what pages load. + +To capture `source/` again: + +```bash +python -m http.server -d source 8765 & +python -m agentenv_protocol.website.replay capture http://127.0.0.1:8765/ --out capture.warc.gz --depth 1 +``` + +## How a request is matched + +A request is matched by its method (HEAD as GET), its path and its query, never by its host or headers. The query is +normalised before matching: + +- Its parameters are sorted. +- Blank parameters are kept. +- The `utm_*`, `fbclid`, `gclid`, `msclkid` and `_` parameters are dropped. Set `ignore_query` on the class to change + this list. + +Each path segment is percent-decoded and then encoded again, so `%7E` and `~` match. A request the capture doesn't +hold gets a 404. If a capture holds one request twice, the first answer is served. + +When the capture's answers are served: + +- `Set-Cookie` loses its `Domain` attribute, so the cookie works on the replay's own host. +- A `Location` that points at a captured host is made relative. +- In HTML pages (`href`, `src`, `srcset`, `action`, `formaction`, `poster`, `style` and `') + headers = [("Content-Type", "text/html"), ("Content-Security-Policy", "style-src 'sha256-abc'"), + ("Content-Security-Policy-Report-Only", "default-src https://shop.example"), ("X-Kept", "1")] + site = TestClient(Shop(warc(("https://shop.example/", 200, headers, page))).create_app()) + served = site.get("/", headers={"X-Forwarded-Prefix": "/website/shop"}) + assert "content-security-policy" not in served.headers + assert "content-security-policy-report-only" not in served.headers + assert served.headers["x-kept"] == "1" + assert served.content == b'' + + +def test_a_replay_keeps_a_pages_and_a_stylesheets_same_site_links_on_the_replay(): + page = ('' + "" + "ahome" + 'awayctop' + '/not-a-link' + '
' + '
é
').encode() + css = b"@import '/t.css'; a{background:url(https://shop.example/bg.png)} b{src:url(f.woff2)}" + site = TestClient(Shop(warc(("https://shop.example/", 200, [("Content-Type", "text/html; charset=utf-8")], page), + ("https://shop.example/style.css", 200, [("Content-Type", "text/css")], css), + ("https://shop.example/app.js", 200, [("Content-Type", "text/javascript")], + b"fetch('/api/x')"))).create_app()) + behind = {"X-Forwarded-Prefix": "/website/shop"} + served = site.get("/", headers=behind) + assert served.content == page.replace(b'"/style.css"', b'"/website/shop/style.css"').replace( + b'url("/b.png")', b'url("/website/shop/b.png")').replace( + b"'https://shop.example/a?x=1&y=2'", b"'/website/shop/a?x=1&y=2'").replace( + b"href=//shop.example>", b"href=/website/shop/>").replace(b"src=/i.png", b"src=/website/shop/i.png").replace( + b'"/i.png 1x, https://shop.example/i2.png 2x"', b'"/website/shop/i.png 1x, /website/shop/i2.png 2x"').replace( + b'"/search"', b'"/website/shop/search"').replace(b'"/go2"', b'"/website/shop/go2"').replace( + b"url('/d.png')", b"url('/website/shop/d.png')") + assert b"var u = '/api/x'; location.href = \"https://shop.example/js\"" in served.content + assert served.headers["content-length"] == str(len(served.content)) + assert site.head("/", headers=behind).headers["content-length"] == str(len(served.content)) + assert site.get("/style.css", headers=behind).content == ( + b"@import '/website/shop/t.css'; a{background:url(/website/shop/bg.png)} b{src:url(f.woff2)}") + assert site.get("/app.js", headers=behind).content == b"fetch('/api/x')" + + direct = site.get("/").content + assert b"'/a?x=1&y=2'" in direct and b'"/style.css"' in direct and b"https://elsewhere.example/z" in direct diff --git a/src/agent_env/env/envs/playwright_mcp/entrypoint.sh b/src/agent_env/env/envs/playwright_mcp/entrypoint.sh index 337885849..68a602103 100644 --- a/src/agent_env/env/envs/playwright_mcp/entrypoint.sh +++ b/src/agent_env/env/envs/playwright_mcp/entrypoint.sh @@ -7,6 +7,8 @@ set -euo pipefail # The recorder serves the browser's card on RECORDER_PORT, restarted whenever it exits; the MCP server starts once # it listens, so the card is there by the time the server is healthy, or after RECORDER_WAIT_S without it. export RECORDER_PORT="${RECORDER_PORT:-18766}" +export PLAYWRIGHT_STORAGE_STATE="${PLAYWRIGHT_STORAGE_STATE:-/app/storage-state.json}" +[ -f "${PLAYWRIGHT_STORAGE_STATE}" ] || (umask 077 && echo '{"cookies": [], "origins": []}' > "${PLAYWRIGHT_STORAGE_STATE}") (while true; do /opt/recorder/bin/python /app/recorder.py || true; sleep 1; done) & for _ in $(seq 1 "$(( ${RECORDER_WAIT_S:-20} * 10 ))"); do (exec 3<>"/dev/tcp/127.0.0.1/${RECORDER_PORT}") 2>/dev/null && break @@ -17,6 +19,7 @@ exec npx @playwright/mcp@${PLAYWRIGHT_MCP_VERSION} \ --browser chromium \ --headless \ --isolated \ + --storage-state "${PLAYWRIGHT_STORAGE_STATE}" \ --no-sandbox \ --host "${MCP_HOST:-0.0.0.0}" \ --port "${MCP_PORT:-18765}" \ diff --git a/src/agent_env/env/envs/playwright_mcp/recorder-requirements.txt b/src/agent_env/env/envs/playwright_mcp/recorder-requirements.txt index 57ae4a2e3..4dc5eef92 100644 --- a/src/agent_env/env/envs/playwright_mcp/recorder-requirements.txt +++ b/src/agent_env/env/envs/playwright_mcp/recorder-requirements.txt @@ -1,4 +1,4 @@ -# The recorder installs the newest agentenv-framework-protocol at or above this floor, the first with computer-recording/v1; +# The recorder installs the newest agentenv-framework-protocol at or above this floor, the first with browser/v1's cookies; # raise it when the recorder needs a newer protocol. -agentenv-framework-protocol[computer]>=0.1.382 +agentenv-framework-protocol[computer]>=0.1.405 websockets==15.0.1 diff --git a/src/agent_env/env/envs/playwright_mcp/recorder.py b/src/agent_env/env/envs/playwright_mcp/recorder.py index c82dbedf2..e289d8a12 100644 --- a/src/agent_env/env/envs/playwright_mcp/recorder.py +++ b/src/agent_env/env/envs/playwright_mcp/recorder.py @@ -2,7 +2,12 @@ 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 browser's image, so it imports only -``agentenv-framework-protocol[computer]`` and, to talk to the browser, ``websockets``.""" +``agentenv-framework-protocol[computer]`` and, to talk to the browser, ``websockets``. + +The same card serves browser/v1's ``cookies/set`` and ``cookies/clear``, so a step can start the browser signed in to +a site. Playwright MCP starts each browser context it opens from the storage state file ``--storage-state`` names +(``PLAYWRIGHT_STORAGE_STATE``), and launches its browser only at the agent's first tool call, so the cookies go into +that file, and through DevTools into every context of a browser already running.""" from __future__ import annotations import asyncio @@ -30,6 +35,10 @@ ) from agentenv_protocol.computer.card import COMPUTER_RECORDING from agentenv_protocol.computer.recording import RecordedFrame, RecordHints, Recording, Recordings, recording_params +from agentenv_protocol.errors import AgentEnvError +from agentenv_protocol.website.browser import (BROWSER_EXTENSION_URI, CLEAR_COOKIES, SET_COOKIES, Cookie, + devtools, domain_matches, storage_state) +from agentenv_protocol.website.browser import cookies as parse_cookies logger = logging.getLogger("playwright-mcp-recorder") @@ -43,6 +52,7 @@ #: How long one DevTools command may take. CALL_S = 10.0 JPEG_QUALITY = 80 +DEFAULT_STORAGE_STATE = "/app/storage-state.json" class CdpError(Exception): @@ -222,16 +232,106 @@ async def _show(self, cdp: Cdp, target: Optional[str]) -> None: self._session = session +class BrowserCookies: + """browser/v1's cookies: kept in the storage state file each new browser context starts from, and set in, or + cleared from, every context of the browser that is running, if one is. One change runs at a time, its file + write and DevTools calls together, so a concurrent set or clear never restores what another removed.""" + + def __init__(self, path: Path, find: Callable[[], Optional[int]], + connect: Callable[[int], AsyncContextManager[Cdp]]) -> None: + self.path, self._find, self._connect = path, find, connect + self._lock = asyncio.Lock() + + async def set(self, given: list[Cookie]) -> dict: + async with self._lock: + return await self._set(given) + + async def clear(self, domains: Optional[list[str]]) -> dict: + async with self._lock: + return await self._clear(domains) + + async def _set(self, given: list[Cookie]) -> dict: + kept = {c.key(): c for c in self._read()} + kept.update({c.key(): c for c in given}) + self._write(list(kept.values())) + + async def add(cdp: Cdp, context: dict) -> None: + await cdp.call("Storage.setCookies", {"cookies": devtools(given), **context}) + + return {"set": len(given), "open_browser": await self._each_context(add)} + + async def _clear(self, domains: Optional[list[str]]) -> dict: + before = self._read() + kept = [c for c in before if domains is not None and not c.matches(domains)] + self._write(kept) + + async def remove(cdp: Cdp, context: dict) -> None: + current = [] if domains is None else (await cdp.call("Storage.getCookies", context)).get("cookies") or [] + await cdp.call("Storage.clearCookies", context) + others = [_settable(c) for c in current if not domain_matches(c.get("domain", ""), domains or [])] + if others: + await cdp.call("Storage.setCookies", {"cookies": others, **context}) + + return {"cleared": len(before) - len(kept), "open_browser": await self._each_context(remove)} + + async def _each_context(self, act: Callable[[Cdp, dict], Any]) -> bool: + """``act`` on the default context and each other one of the running browser; False when none runs. An error + names the DevTools call's kind only, since its message may repeat a cookie.""" + port = await asyncio.to_thread(self._find) + if port is None: + return False + try: + async with self._connect(port) as cdp: + contexts = (await cdp.call("Target.getBrowserContexts")).get("browserContextIds") or [] + for context in [{}, *({"browserContextId": c} for c in contexts)]: + await act(cdp, context) + except (OSError, CdpError, httpx.HTTPError, KeyError, ValueError, asyncio.TimeoutError) as e: + raise AgentEnvError("browser_unreachable", f"the running browser didn't take the change " + f"({type(e).__name__}); the next context it opens starts with it", applied="unknown", + status=502) from None + return True + + def _read(self) -> list[Cookie]: + try: + state = json.loads(self.path.read_text(encoding="utf-8")) + except FileNotFoundError: + return [] + return [Cookie.model_validate({**c, "expires": None if c.get("expires", -1) in (-1, None) else c["expires"]}) + for c in state.get("cookies") or []] + + def _write(self, kept: list[Cookie]) -> None: + try: + origins = json.loads(self.path.read_text(encoding="utf-8")).get("origins") or [] + except (FileNotFoundError, ValueError): + origins = [] + staged = self.path.with_name(self.path.name + ".tmp") + staged.write_text(json.dumps({"cookies": storage_state(kept), "origins": origins}), encoding="utf-8") + os.chmod(staged, 0o600) + os.replace(staged, self.path) + + +def _settable(cookie: dict) -> dict: + """A cookie DevTools listed, as ``Storage.setCookies`` takes it back.""" + param = {k: cookie[k] for k in ("name", "value", "domain", "path", "secure", "httpOnly", "sameSite") if k in cookie} + if not cookie.get("session", False) and cookie.get("expires", -1) not in (-1, None): + param["expires"] = cookie["expires"] + return param + + @environment_card(name="playwright-mcp") class BrowserRecorder(AgentEnvEnvironment): - """computer-recording/v1 of the Playwright MCP browser's page; the browser's tools stay Playwright MCP's.""" + """computer-recording/v1 of the Playwright MCP browser's page, and browser/v1's cookies; the browser's tools stay + Playwright MCP's.""" recording = Recording() def __init__(self, find: Callable[[], Optional[int]] = find_browser, - connect: Callable[[int], AsyncContextManager[Cdp]] = connect_cdp) -> None: + connect: Callable[[int], AsyncContextManager[Cdp]] = connect_cdp, + storage_state_path: Optional[Path] = None) -> None: self._find, self._connect = find, connect self.recordings = Recordings(_Host(self)) + path = storage_state_path or Path(os.environ.get("PLAYWRIGHT_STORAGE_STATE", DEFAULT_STORAGE_STATE)) + self.cookies = BrowserCookies(path, find, connect) def extension_params(self, uri: str) -> dict: if uri == COMPUTER_RECORDING: @@ -250,6 +350,21 @@ async def _recording_start(self, request_id: str, scope: str = "display", window async def _recording_end(self, recording_id: str, upload: Optional[dict] = None, reason: str = "end") -> dict: return await self.recordings.end(recording_id, upload, reason) + @extension(BROWSER_EXTENSION_URI, op=SET_COOKIES, + description="The browser's cookies, for agentenv's steps, never its agent: cookies/set adds cookies " + "(name, value, domain, path, secure, httpOnly, expires, sameSite) to every context, open " + "and to come, and cookies/clear removes those of some domains, or all of them.") + async def _cookies_set(self, cookies: list[dict]) -> dict: + try: + given = parse_cookies(cookies) + except ValueError as e: + raise AgentEnvError("invalid_params", str(e)) from None + return await self.cookies.set(given) + + @extension(BROWSER_EXTENSION_URI, op=CLEAR_COOKIES) + async def _cookies_clear(self, domains: Optional[list[str]] = None) -> dict: + return await self.cookies.clear(domains) + async def frames(self, hints: RecordHints) -> AsyncIterator[RecordedFrame]: """The page at up to ``fps``: a frame each time it changes, and the last one again after ``HOLD_S``.""" screencast = Screencast(hints.max_edge, self._find, self._connect) diff --git a/src/agent_env/env/envs/website.py b/src/agent_env/env/envs/website.py index 0cc8ce7d1..c70a5e206 100644 --- a/src/agent_env/env/envs/website.py +++ b/src/agent_env/env/envs/website.py @@ -3,6 +3,10 @@ env.toml may carry the site's website spec, ``spec = "website.toml"`` or an inline ``[spec]`` table, whose site name, health path and domains the env takes. +Its ``settings`` are environment variables of the server's container, a live site's credentials among them: a setting +whose name reads as a secret's (a token, a password, ...) is stored as a ``secret:KEY`` or ``env:NAME`` reference, +never its value, and resolved from the deploying user's config only when the container is written. + A deploy reads whether the site is ready from its env card, the protocol layer: ``website/v1``'s ``ready``, as ``computer-app/v1``'s says of an app. ``health_path`` (env.toml's ``health``) is read only for a site whose card has no ``ready``, or that serves no card, and is deprecated.""" @@ -46,6 +50,9 @@ DEFAULT_HEALTH_PATH = AGENT_ENV_WEBSITE_HEALTH_PATH _HEALTH_PATH = re.compile(r"/[A-Za-z0-9._~/-]*") +_SETTING_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +RESERVED_SETTINGS = frozenset({"DATABASE_URL", "SERVICE_NAME", "ENVIRONMENT_NAME", "PORT", "PYTHONUNBUFFERED", + "WEBSITE_ROLE_KEY"}) class WebsiteEnv(Env): @@ -55,7 +62,8 @@ class WebsiteEnv(Env): EntityRef.artifact("frontend_image", artifact_type="docker_image", optional=True)) toml_keys: ClassVar[dict[str, type]] = {"backend_image": object, "frontend_image": object, "environment_name": str, - "health": str, "env_provider_type": str, "spec": object} + "health": str, "env_provider_type": str, "spec": object, + "settings": dict} def __init__( self, @@ -70,6 +78,7 @@ def __init__( metadata: Optional[dict[str, str]] = None, env_provider_type: str = "gateway", domains: tuple[str, ...] | list[str] = (), + settings: Optional[dict[str, str]] = None, ): super().__init__(id, version, metadata=metadata) if not environment_name: @@ -85,6 +94,9 @@ def __init__( raise ValueError(f"website env {id!r}: {problem}") self.health_path = health_path self.domains = tuple(domains) + if problems := settings_problems(settings or {}): + raise ValueError(f"website env {id!r}: {'; '.join(problems)}") + self.settings = dict(settings or {}) self.environment_name = environment_name self.env_provider_type = env_provider_type self._sandbox = None @@ -117,6 +129,8 @@ def to_dict(self) -> dict: base["service_name"] = self.environment_name base["environment_name"] = self.environment_name # dual-write with service_name during the rename base["env_provider_type"] = self.env_provider_type + if self.settings: + base["settings"] = dict(self.settings) return base @classmethod @@ -134,6 +148,7 @@ def from_dict(cls, data: dict) -> WebsiteEnv: metadata=data.get("metadata", {}), env_provider_type=data.get("env_provider_type", "gateway"), domains=data.get("domains", ()), + settings=data.get("settings"), ) @classmethod @@ -157,7 +172,7 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> WebsiteEnv: environment_name=fields["environment_name"], health_path=fields.get("health", DEFAULT_HEALTH_PATH), env_provider_type=fields["env_provider_type"], - domains=fields.get("domains", ())) + domains=fields.get("domains", ()), settings=fields.get("settings")) def to_toml(self, out: ExportContext) -> dict: """The env.toml of a folder this env's images are built from, which ``from_toml`` reads back as it.""" @@ -169,6 +184,8 @@ def to_toml(self, out: ExportContext) -> dict: toml["health"] = self.health_path if self.domains: toml["spec"] = {"site": {"domains": list(self.domains)}} + if self.settings: + toml["settings"] = dict(self.settings) return {**toml, **self.provider_toml(out, self.env_provider_type)} @classmethod @@ -185,6 +202,9 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: health = data.get("health", DEFAULT_HEALTH_PATH) if isinstance(health, str) and (problem := health_path_problem(health)): ctx.refuse([ctx.config_problem(problem)]) + settings = data.get("settings") + if isinstance(settings, dict) and (problems := settings_problems(settings)): + ctx.refuse([ctx.config_problem(problem) for problem in problems]) fields = ctx.accept_env(data, cls, named_by="backend_image") if spec is not None and spec.site.domains: fields["domains"] = spec.site.domains @@ -435,6 +455,27 @@ def website_spec_of(data: dict, folder: Path) -> tuple[Optional[WebsiteSpec], li return spec, problems +def settings_problems(settings: dict) -> list[str]: + """Why ``settings`` can't be a website env's, each problem naming the setting and never its value: a name is an + environment variable's the gateway doesn't set itself, a value a string, and a secret's a bare ``secret:KEY`` or + ``env:NAME`` reference.""" + from agent_env.config.describe import is_secret_key + from agent_env.config.loader import ENV_REF_PREFIX, SECRET_REF_PREFIX + + problems = [] + for name, value in settings.items(): + if not isinstance(name, str) or not _SETTING_NAME.fullmatch(name) or name in RESERVED_SETTINGS: + problems.append(f"settings.{name}: a setting is an environment variable's name the gateway doesn't set " + f"itself ({', '.join(sorted(RESERVED_SETTINGS))} are its own)") + elif not isinstance(value, str): + problems.append(f"settings.{name} is a string, not a {type(value).__name__}") + elif is_secret_key(name) and not (value.startswith((SECRET_REF_PREFIX, ENV_REF_PREFIX)) and "?" not in value + and value not in (SECRET_REF_PREFIX, ENV_REF_PREFIX)): + problems.append(f"settings.{name} is a secret, so it takes a {SECRET_REF_PREFIX}KEY or {ENV_REF_PREFIX}NAME " + "reference with no ?default, not its value") + return problems + + def health_path_problem(health_path: str) -> str | None: """Why ``health_path`` can't be where a site answers once it's usable, or None: it's a path of plain characters, since the gateway writes it into a health check.""" 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 319329d45..79aacc7fd 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -127,12 +127,22 @@ class DeployedGateway: class WebsiteConfig: """Configuration for a website in a gateway deployment: its server image, which serves the site's pages and API and says when it's ready by its card's ``website/v1`` ``ready`` (``health_path``, deprecated, for a site whose card - has none), and a deprecated separate frontend image for a site built on one.""" + has none), a deprecated separate frontend image for a site built on one, and the env's ``settings`` as stored, + secrets as their references, which only the compose file holds resolved.""" backend_image: str frontend_image: str | None environment_name: str health_path: str = AGENT_ENV_WEBSITE_HEALTH_PATH + settings: dict[str, str] | None = None + + def env_vars(self) -> dict[str, str]: + """The settings with each ``secret:`` / ``env:`` reference resolved from the deploying user's config.""" + if not self.settings: + return {} + from agent_env.config import get_config + resolve = get_config().resolve_reference + return {name: resolve(value) for name, value in self.settings.items()} @dataclass @@ -544,6 +554,7 @@ def create_docker_compose( f" - PORT={AGENT_ENV_WEBSITE_BACKEND_PORT}", " - PYTHONUNBUFFERED=1", *([f" - {WEBSITE_ROLE_KEY_ENV}={role_key}"] if role_key else []), + *(f" - {compose_env_entry(name, value)}" for name, value in wc.env_vars().items()), " healthcheck:", f' test: ["CMD-SHELL", "curl -sf -o /dev/null http://localhost:{AGENT_ENV_WEBSITE_BACKEND_PORT}{WELL_KNOWN_PATH}' f' || curl -sf -o /dev/null http://localhost:{AGENT_ENV_WEBSITE_BACKEND_PORT}{wc.health_path}"]', @@ -1526,12 +1537,30 @@ class _GatewayTopology: names_gateway_on_vm: bool # a MultiEnv's record names its gateway sandbox even on a VM +def compose_env_entry(name: str, value: str) -> str: + """``name=value`` as one quoted YAML scalar of a compose ``environment:`` list, so the value stays that variable's + whatever it holds: a newline, quote or ``: `` can't start more of the service, and each ``$`` is ``$$``, which + compose doesn't interpolate. The scalar is plain ASCII, any other character written as its escape.""" + escaped = [] + for char in f"{name}={value}".replace("$", "$$"): + if char in '"\\': + escaped.append("\\" + char) + elif " " <= char <= "~": + escaped.append(char) + elif ord(char) <= 0xFFFF: + escaped.append(f"\\u{ord(char):04x}") + else: + escaped.append(f"\\U{ord(char):08x}") + return '"' + "".join(escaped) + '"' + + 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) + environment_name=env.environment_name, health_path=env.health_path, + settings=dict(env.settings) or None) def _gateway_topology(env: Env) -> _GatewayTopology: @@ -1594,6 +1623,7 @@ def _compose_name_problems(mcp_servers: list[MCPServerConfig], website_configs: # rendered compose is logged. Matches the key to the left of the first '=' on an # ` - KEY=VALUE` env line. _SECRET_ENV_KEY_RE = re.compile(r"^(\s*-\s*)([A-Za-z0-9_]*(?:SECRET|PASSWORD|TOKEN|API_KEY|ROLE_KEY)[A-Za-z0-9_]*)=.*$") +_SETTING_ENV_RE = re.compile(r'^(\s*-\s*")([A-Za-z_][A-Za-z0-9_]*)=.*"$') def _compose_ps_rows(text: str) -> list[dict]: @@ -1624,9 +1654,12 @@ def _why_down(row: dict) -> str | None: def _redact_compose_secrets(compose_content: str) -> str: - """Mask secret-valued env lines so the logged compose never carries a live secret.""" + """Mask secret-valued env lines so the logged compose never carries a live secret, and every website setting's + quoted ``"NAME=VALUE"`` line as ``compose_env_entry`` writes it, whatever its name: its value may be a credential + a ``secret:`` or ``env:`` reference resolved.""" return "\n".join( - _SECRET_ENV_KEY_RE.sub(r"\1\2=***", line) for line in compose_content.splitlines() + _SETTING_ENV_RE.sub(r'\1\2=***"', _SECRET_ENV_KEY_RE.sub(r"\1\2=***", line)) + for line in compose_content.splitlines() ) diff --git a/src/agent_env/task/episode/rules.py b/src/agent_env/task/episode/rules.py index d7409bb28..fc5a77536 100644 --- a/src/agent_env/task/episode/rules.py +++ b/src/agent_env/task/episode/rules.py @@ -151,7 +151,7 @@ def _rubrics_verifier(step: RubricsVerifierTaskStep, outside: Outside) -> list[s "aggregate_verifiers", "apply_server_config", "build_mcp_cli", "combine_universe_verdicts", "configure_scheduled_checks", "deploy_env", "deploy_sandbox", "end_computer_recording", "start_computer_recording", "env_outcome_verifier", "modify_env_tool_access", "reset_env", "review", - "run_container_unit_tests_verifier", "run_docker_container", "snapshot_env", "snapshot_computer_env", "sync_env_clock", + "run_container_unit_tests_verifier", "run_docker_container", "sign_in_browser", "snapshot_env", "snapshot_computer_env", "sync_env_clock", "validation_gate_aggregator", "verify_env_card", "verify_env_core_protocol", "verify_mcp_tool_schema", "verify_spec_conformance", "verify_universe_load_export_roundtrip", # They read only what an outside turn leaves: its reply. diff --git a/src/agent_env/task_step/registry.py b/src/agent_env/task_step/registry.py index d343df2c2..0c8ebd7a5 100644 --- a/src/agent_env/task_step/registry.py +++ b/src/agent_env/task_step/registry.py @@ -47,6 +47,7 @@ def _builtin_registry() -> dict[str, type["TaskStep"]]: from agent_env.task_step.task_steps.register_agent_triggers import RegisterAgentTriggersStep from agent_env.task_step.task_steps.sync_env_clock import SyncEnvClockTaskStep from agent_env.task_step.task_steps.configure_scheduled_checks import ConfigureScheduledChecksTaskStep + from agent_env.task_step.task_steps.sign_in_browser import SignInBrowserTaskStep from agent_env.task_step.task_steps.install_agent import InstallAgentTaskStep from agent_env.task_step.task_steps.verifiers.rubrics_verifier import RubricsVerifierTaskStep from agent_env.task_step.task_steps.verifiers.run_container_unit_tests_verifier import RunContainerUnitTestsVerifierTaskStep @@ -104,6 +105,7 @@ def _builtin_registry() -> dict[str, type["TaskStep"]]: _get_type(RegisterAgentTriggersStep): RegisterAgentTriggersStep, _get_type(SyncEnvClockTaskStep): SyncEnvClockTaskStep, _get_type(ConfigureScheduledChecksTaskStep): ConfigureScheduledChecksTaskStep, + _get_type(SignInBrowserTaskStep): SignInBrowserTaskStep, _get_type(InstallAgentTaskStep): InstallAgentTaskStep, _get_type(RubricsVerifierTaskStep): RubricsVerifierTaskStep, _get_type(RunContainerUnitTestsVerifierTaskStep): RunContainerUnitTestsVerifierTaskStep, diff --git a/src/agent_env/task_step/task_steps/deploy_env.py b/src/agent_env/task_step/task_steps/deploy_env.py index 55d605b0a..4a39c4b39 100644 --- a/src/agent_env/task_step/task_steps/deploy_env.py +++ b/src/agent_env/task_step/task_steps/deploy_env.py @@ -200,7 +200,9 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: deployed_env = await env.deploy(**deploy_kwargs) # create_instance runs inside env.deploy(), so an annotation set after it # returns has to be persisted explicitly to reach the instance record. - annotations = {"deploy_step_id": self.id} + from agent_env.task_step.task_steps.sign_in_browser import mark_live + + annotations = {"deploy_step_id": self.id, **mark_live(context, deployed_env)} deployed_env.metadata = {**(deployed_env.metadata or {}), **annotations} if deployed_env.instance_id: update_env_instance_metadata(deployed_env.instance_id, annotations) diff --git a/src/agent_env/task_step/task_steps/reset_env.py b/src/agent_env/task_step/task_steps/reset_env.py index 88059fdde..7ff94195c 100644 --- a/src/agent_env/task_step/task_steps/reset_env.py +++ b/src/agent_env/task_step/task_steps/reset_env.py @@ -3,7 +3,8 @@ Env-agnostic: looks up the deployed env in context and calls its ``reset()``. Best-effort by default — a reset failure (including an env that doesn't support it) is logged and the run continues. Place right before prompt_agent in a -warm-reuse task. +warm-reuse task. After a reset, the browser is signed in again to the live websites +a sign_in_browser step of the run signed it in to. """ from __future__ import annotations @@ -61,6 +62,9 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: env = Env.get(deployed.env_id, deployed.env_version) await env.reset(deployed) logger.info("reset_env: reset env '%s'", self.env_id) + from agent_env.task_step.task_steps.sign_in_browser import sign_in_again + + await sign_in_again(context, self.env_id) except NotImplementedError: # Env doesn't support reset — not an error. logger.info("reset_env: env '%s' does not support reset; skipping", self.env_id) diff --git a/src/agent_env/task_step/task_steps/sign_in_browser.py b/src/agent_env/task_step/task_steps/sign_in_browser.py new file mode 100644 index 000000000..05cd4e8b2 --- /dev/null +++ b/src/agent_env/task_step/task_steps/sign_in_browser.py @@ -0,0 +1,284 @@ +"""Live websites in a run: marking a run that uses one, and signing the browser in to them. + +A website child env whose card's website/v1 says ``live`` is a real site. ``mark_live``, which deploy_env runs on every +env it deploys, records each one under ``context.metadata["live_websites"]``, sets ``context.metadata["reproducible"]`` +to False, and marks the deploy record ``live``. It also checks the site's domains and ``allow_hosts`` against the network +allowlist of each computer deployed in the run. An allowlist is fixed when its sandbox starts, so a host one leaves out +is recorded under ``unreachable`` and warned about, not added; a run whose sandboxes restrict no egress records +``network: "unrestricted"``, since nothing there enforces ``allow_hosts`` either. + +``sign_in_browser`` calls each live site's website/v1 ``session`` and hands the cookies straight to the browser's +browser/v1 ``cookies/set``, each called on the child env's own card as the env's card nests it, so a site sharing its +name with an MCP server, or with another child, is still the one called. The cookies never enter the context, a log or +the step's record, which keeps only each cookie's name, domain, path and expiry. reset_env signs the browser in again after a reset, as the last sign-in did. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, ClassVar, Optional + +import httpx +from agentenv_protocol import client as protocol_v1 +from agentenv_protocol.website import BROWSER_EXTENSION_URI, CLEAR_COOKIES, SET_COOKIES, WEBSITE_EXTENSION_URI +from agentenv_protocol.website.browser import cookies as parse_cookies +from agentenv_protocol.website.browser import redacted + +from agent_env.entity.refs import EntityRef +from agent_env.env.env import EnvCapabilityUnsupported +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_step import TaskStep, TaskStepDependency + +logger = logging.getLogger(__name__) + +LIVE_KEY = "live_websites" +REPRODUCIBLE_KEY = "reproducible" +SIGN_INS_KEY = "browser_sign_ins" + + +@dataclass(frozen=True) +class LiveSite: + """A live website child env, as its card declares it; ``child`` is that child env, which its session is called on, + and None for a site read back from a record.""" + + name: str + domains: tuple[str, ...] + allow_hosts: tuple[str, ...] + signs_in: bool + child: Optional[protocol_v1.ChildEnv] = field(default=None, compare=False, repr=False) + + def hosts(self) -> list[str]: + return [*self.domains, *(h for h in self.allow_hosts if h not in self.domains)] + + def record(self) -> dict: + return {"name": self.name, "domains": list(self.domains), "allow_hosts": list(self.allow_hosts), + "signs_in": self.signs_in} + + +def _children(card: dict) -> list[protocol_v1.ChildEnv]: + """``card``'s child envs, or ``card`` itself, at the env's own address, for a leaf card.""" + if isinstance(card.get("children_environments"), list): + return protocol_v1.children(card) + return [protocol_v1.ChildEnv(name=card.get("name"), url=None, card=card)] + + +def live_sites(card: Optional[dict]) -> list[LiveSite]: + """The live websites among ``card``'s child envs, or ``card`` itself for a leaf card.""" + sites = [] + for child in _children(card or {}): + params = (child.find_extension(WEBSITE_EXTENSION_URI) or {}).get("params") or {} + if params.get("live") is True and isinstance(child.name, str): + sites.append(LiveSite(child.name, tuple(params.get("domains") or ()), + tuple(params.get("allow_hosts") or ()), + params.get("signs_in") is not False + and protocol_v1.find_extension_method(child.card, WEBSITE_EXTENSION_URI, + "session") is not None, child)) + return sites + + +def browsers(card: Optional[dict]) -> list[protocol_v1.ChildEnv]: + """``card``'s child envs that serve browser/v1's cookies/set.""" + return [child for child in _children(card or {}) if isinstance(child.name, str) + and protocol_v1.find_extension_method(child.card, BROWSER_EXTENSION_URI, SET_COOKIES) is not None] + + +async def _invoke(deployed: Any, child: protocol_v1.ChildEnv, uri: str, method: str, params: dict, + timeout: int) -> Any: + """Call ``method`` of the extension at ``uri`` as ``child``'s own card advertises it, at ``deployed``'s address + with its control headers, which a lookup of the child by its name could confuse with another of that name.""" + if not deployed.environment_url or protocol_v1.find_extension_method(child.card, uri, method) is None: + raise EnvCapabilityUnsupported(uri, method, deployed.env_id, deployed.instance_id, child.name) + return await protocol_v1.invoke_extension(deployed.environment_url, child.card, uri, params, timeout, + method=method, headers=deployed.control_headers) + + +def _allows(entries: list[str], host: str) -> bool: + host = host.lower().rstrip(".") + for entry in (e.lower().rstrip(".") for e in entries): + if entry == host or (entry.startswith("*.") and host.endswith(entry[1:])): + return True + return False + + +def network_check(context: TaskStepContext, sites: list[LiveSite], deployed: Any = None) -> dict: + """How the run's computers' network allowlists treat the live ``sites``' hosts: each restricting computer's + hosts it leaves out, or ``unrestricted`` when no computer in the run restricts egress. ``deployed``, an env being + deployed that the context doesn't hold yet, is one of the run's.""" + from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy + + hosts = list(dict.fromkeys(h for site in sites for h in site.hosts())) + unreachable: dict[str, list[str]] = {} + restricted = False + envs = [*context.deployed_envs, *([deployed] if deployed is not None and deployed not in context.deployed_envs + else [])] + for env in envs: + network = ((env.metadata or {}).get("computer") or {}).get("network") + if not network: + continue + policy = NetworkPolicy.from_dict(network) + if not policy.restricts_egress: + continue + restricted = True + if missing := [h for h in hosts if not _allows(list(policy.allow_hosts), h)]: + unreachable[env.env_id] = missing + if not restricted: + return {"network": "unrestricted"} + return {"network": "allowlist", "unreachable": unreachable} + + +def mark_live(context: TaskStepContext, deployed: Any) -> dict: + """Record ``deployed``'s live websites in ``context`` and return what its deploy record gains: ``live`` and + ``reproducible`` when it has one, nothing when it has none. The network check of every live website recorded so + far is made again with ``deployed`` among the run's computers, so a computer deployed after a site is checked + too.""" + sites = live_sites(deployed.environment_card) + if sites: + context.metadata.setdefault(LIVE_KEY, {})[deployed.env_id] = {"sites": [s.record() for s in sites]} + context.metadata[REPRODUCIBLE_KEY] = False + for env_id, entry in (context.metadata.get(LIVE_KEY) or {}).items(): + before = entry.pop("unreachable", None) or {} + entry_sites = sites if env_id == deployed.env_id else [ + LiveSite(r["name"], tuple(r["domains"]), tuple(r["allow_hosts"]), r["signs_in"]) for r in entry["sites"]] + network = network_check(context, entry_sites, deployed) + entry.update(network) + for computer, missing in (network.get("unreachable") or {}).items(): + if before.get(computer) != missing: + logger.warning("live websites of env %s: env %s's network allowlist leaves out %s, which it can't add " + "once its sandbox runs, so those pages won't load there", env_id, computer, missing) + if env_id == deployed.env_id and network["network"] == "unrestricted": + logger.info("live websites of env %s (%s): no sandbox in this run restricts egress, so allow_hosts is " + "recorded, not enforced", env_id, [s.name for s in sites]) + return {"live": True, REPRODUCIBLE_KEY: False} if sites else {} + + +async def sign_in(context: TaskStepContext, deployed: Any, *, users: Optional[dict[str, str]] = None, + sites: Optional[list[str]] = None, browser: Optional[str] = None, clear: bool = True, + timeout: int = 30) -> dict: + """Sign ``deployed``'s browser in to its live websites (those named in ``sites``, else every one with a session), + each as ``users[name]`` or the site's own choice; with ``clear``, a site's old cookies go first. Returns the record + of what was set, cookies by everything but their values.""" + card = deployed.environment_card or {} + live = {site.name: site for site in live_sites(card)} + unknown = sorted(set(sites or []) - set(live)) + if unknown: + raise RuntimeError(f"env {deployed.env_id!r} has no live website named {unknown}; its live ones are " + f"{sorted(live) or 'none'}") + chosen = [live[name] for name in sites] if sites else [s for s in live.values() if s.signs_in] + offered = [child for child in browsers(card) if browser is None or child.name == browser] + if chosen and not offered: + named = f"named {browser!r} " if browser is not None else "" + raise RuntimeError(f"env {deployed.env_id!r} has live websites with a session ({[s.name for s in chosen]}) " + f"but no child env {named}serves {BROWSER_EXTENSION_URI} {SET_COOKIES} to sign a browser in") + if chosen and browser is not None and len(offered) > 1: + raise RuntimeError(f"env {deployed.env_id!r} has {len(offered)} browsers named {browser!r} " + f"({', '.join(str(c.url) for c in offered)}), so the name can't say which to sign in") + target = offered[0] if chosen else None + browser = target.name if target is not None else browser + record: dict = {"env_id": deployed.env_id, "browser": browser, "sites": []} + for site in chosen: + user = (users or {}).get(site.name) + try: + session = await _invoke(deployed, site.child, WEBSITE_EXTENSION_URI, "session", {"user": user}, timeout) + except Exception as e: + raise RuntimeError(f"live website {site.name!r}: its session failed ({_why(e)})") from None + try: + given = parse_cookies(session.get("cookies") if isinstance(session, dict) else None, + default_domain=site.domains[0] if site.domains else None) + except ValueError as e: + raise RuntimeError(f"live website {site.name!r}: its session returned {e}") from None + try: + if clear and site.domains: + await _invoke(deployed, target, BROWSER_EXTENSION_URI, CLEAR_COOKIES, {"domains": list(site.domains)}, + timeout) + await _invoke(deployed, target, BROWSER_EXTENSION_URI, SET_COOKIES, + {"cookies": [c.model_dump(exclude_none=True) for c in given]}, timeout) + except Exception as e: + raise RuntimeError(f"browser {browser!r}: setting {site.name!r}'s cookies failed ({_why(e)})") from None + record["sites"].append({"name": site.name, "user": user, "expires_at": session.get("expires_at"), + "cookies": redacted(given)}) + logger.info("sign_in_browser: %s signed in to %s as %s (%s)", browser, site.name, user or "its own choice", + [c.name for c in given]) + return record + + +def _why(error: Exception) -> str: + """An error as a record keeps it: a call's status and error code, or the error's kind, never a message that may + repeat a cookie.""" + if isinstance(error, httpx.HTTPStatusError): + code = getattr(error, "code", None) + return f"HTTP {error.response.status_code}{f' {code}' if code else ''}" + if isinstance(error, EnvCapabilityUnsupported): + return str(error) + return type(error).__name__ + + +async def sign_in_again(context: TaskStepContext, env_id: str) -> None: + """After a reset of ``env_id``, sign its browser in again as each sign-in of this run did; never raises.""" + deployed = next((d for d in context.deployed_envs if d.env_id == env_id), None) + for step_id, entry in list((context.metadata.get(SIGN_INS_KEY) or {}).items()): + if deployed is None or entry.get("env_id") != env_id or not entry.get("sites"): + continue + users = {s["name"]: s["user"] for s in entry["sites"] if s.get("user")} + try: + context.metadata[SIGN_INS_KEY][step_id] = await sign_in( + context, deployed, users=users, sites=[s["name"] for s in entry["sites"]], browser=entry.get("browser")) + except Exception as e: + logger.warning("reset_env: signing env %s's browser in again failed: %s", env_id, e) + entry["error"] = str(e)[:500] + + +class SignInBrowserTaskStep(TaskStep): + """Sign a deployed env's browser in to its live websites before the agent starts. ``users`` maps a site's name to + the user to sign in as; ``sites`` limits it to some live sites; ``browser`` names the child env serving browser/v1 + when there are several.""" + + type: ClassVar[str] = "sign_in_browser" + entity_refs = (EntityRef.env("env_id"),) + + def __init__(self, id: str, version: Optional[int], env_id: str, users: Optional[dict[str, str]] = None, + sites: Optional[list[str]] = None, browser: Optional[str] = None, + depends_on: Optional[list[TaskStepDependency]] = None, fail_task_on_error: bool = True, + timeout_seconds: int = 30): + super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error) + if not env_id: + raise ValueError("sign_in_browser needs the env_id of the env whose browser it signs in") + if users is not None and not (isinstance(users, dict) and all( + isinstance(k, str) and isinstance(v, str) for k, v in users.items())): + raise ValueError("sign_in_browser's users maps a site's name to a user's") + self.env_id, self.users, self.sites, self.browser = env_id, users, sites, browser + self.timeout_seconds = timeout_seconds + + def to_dict(self) -> dict: + data = {**super().to_dict(), "env_id": self.env_id, "timeout_seconds": self.timeout_seconds} + for key in ("users", "sites", "browser"): + if getattr(self, key) is not None: + data[key] = getattr(self, key) + return data + + @classmethod + def from_dict(cls, data: dict) -> SignInBrowserTaskStep: + return cls(**cls._base_from_dict(data), env_id=data["env_id"], users=data.get("users"), + sites=data.get("sites"), browser=data.get("browser"), + timeout_seconds=data.get("timeout_seconds", 30)) + + async def execute(self, context: TaskStepContext) -> TaskStepContext: + deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None) + if deployed is None: + raise RuntimeError(f"Env '{self.env_id}' not found in context.deployed_envs") + if live_sites(deployed.environment_card) and self.env_id not in (context.metadata.get(LIVE_KEY) or {}): + mark_live(context, deployed) + try: + record = await sign_in(context, deployed, users=self.users, sites=self.sites, browser=self.browser, + timeout=self.timeout_seconds) + except Exception as e: + context.metadata.setdefault(SIGN_INS_KEY, {})[self.id] = {"env_id": self.env_id, "error": str(e)[:500]} + logger.warning("sign_in_browser %s: %s", self.id, e) + if self.fail_task_on_error: + raise + return context + context.metadata.setdefault(SIGN_INS_KEY, {})[self.id] = record + if not record["sites"]: + logger.info("sign_in_browser %s: env %s has no live website with a session; nothing to sign in to", + self.id, self.env_id) + return context diff --git a/tst/unit/env/envs/test_playwright_mcp_cookies.py b/tst/unit/env/envs/test_playwright_mcp_cookies.py new file mode 100644 index 000000000..22de103bf --- /dev/null +++ b/tst/unit/env/envs/test_playwright_mcp_cookies.py @@ -0,0 +1,159 @@ +"""browser/v1's cookies in the Playwright MCP browser: cookies/set keeps the cookies in the storage state file every +context Playwright MCP opens starts from and sets them in each context of a running browser, cookies/clear removes them +by domain or all of them, and neither answer, nor an error, repeats a cookie's value.""" + +from __future__ import annotations + +import asyncio +import json +import os +import re +import stat +from pathlib import Path + +import httpx +import pytest + +pytest.importorskip("av") + +from agentenv_protocol import client as protocol_client # noqa: E402 +from agentenv_protocol.website.browser import BROWSER_EXTENSION_URI # 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 + +SET, CLEAR = "/agentenv/ext/browser/cookies/set", "/agentenv/ext/browser/cookies/clear" +SID = {"name": "sid", "value": "s3cret", "domain": "github.com", "secure": True, "httpOnly": True, + "expires": 1_900_000_000} +OTHER = {"name": "pref", "value": "dark", "domain": ".example.com"} + + +class _DevTools: + """A browser's DevTools with a default context and two of Playwright's, each holding its cookies.""" + + def __init__(self, refuse: bool = False) -> None: + self.calls: list[tuple[str, dict]] = [] + self.jars: dict = {None: [], "ctx-1": [], "ctx-2": []} + self.refuse = refuse + + async def call(self, method, params=None, session=None): + await asyncio.sleep(0) + params = dict(params or {}) + self.calls.append((method, params)) + jar = self.jars[params.pop("browserContextId", None)] + if method == "Target.getBrowserContexts": + return {"browserContextIds": ["ctx-1", "ctx-2"]} + if self.refuse: + raise recorder.CdpError("Invalid cookie fields: s3cret") + if method == "Storage.setCookies": + jar += [{**c, "session": "expires" not in c, "size": 1} for c in params["cookies"]] + elif method == "Storage.clearCookies": + jar.clear() + elif method == "Storage.getCookies": + return {"cookies": list(jar)} + return {} + + +def _recorder(tmp_path: Path, devtools: _DevTools | None) -> recorder.BrowserRecorder: + class _Connection: + async def __aenter__(self): + return devtools + + async def __aexit__(self, *exc): + return False + + return recorder.BrowserRecorder(find=lambda: None if devtools is None else 9222, + connect=lambda port: _Connection(), + storage_state_path=tmp_path / "storage-state.json") + + +async def _post(app, path: str, body: dict) -> httpx.Response: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://recorder") as http: + return await http.post(path, json=body) + + +@pytest.mark.asyncio +async def test_cookies_go_into_the_storage_state_file_before_the_browser_runs(tmp_path): + app = recorder.create_app(_recorder(tmp_path, None)) + answer = await _post(app, SET, {"cookies": [SID, OTHER]}) + assert answer.json() == {"set": 2, "open_browser": False} and "s3cret" not in answer.text + path = tmp_path / "storage-state.json" + state = json.loads(path.read_text()) + assert state["origins"] == [] + assert state["cookies"] == [ + {"name": "sid", "value": "s3cret", "domain": "github.com", "path": "/", "expires": 1_900_000_000, + "httpOnly": True, "secure": True, "sameSite": "Lax"}, + {"name": "pref", "value": "dark", "domain": ".example.com", "path": "/", "expires": -1, "httpOnly": False, + "secure": False, "sameSite": "Lax"}] + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + + await _post(app, SET, {"cookies": [{**SID, "value": "newer"}]}) + assert [c["value"] for c in json.loads(path.read_text())["cookies"]] == ["newer", "dark"] + + +@pytest.mark.asyncio +async def test_cookies_are_set_in_every_context_of_a_running_browser(tmp_path): + devtools = _DevTools() + app = recorder.create_app(_recorder(tmp_path, devtools)) + assert (await _post(app, SET, {"cookies": [SID]})).json() == {"set": 1, "open_browser": True} + for jar in devtools.jars.values(): + assert jar == [{"name": "sid", "value": "s3cret", "domain": "github.com", "path": "/", "secure": True, + "httpOnly": True, "expires": 1_900_000_000, "session": False, "size": 1}] + + +@pytest.mark.asyncio +async def test_clearing_a_domain_keeps_the_other_cookies_and_clearing_all_keeps_none(tmp_path): + devtools = _DevTools() + app = recorder.create_app(_recorder(tmp_path, devtools)) + await _post(app, SET, {"cookies": [SID, OTHER]}) + assert (await _post(app, CLEAR, {"domains": ["github.com"]})).json() == {"cleared": 1, "open_browser": True} + assert [c["name"] for c in json.loads((tmp_path / "storage-state.json").read_text())["cookies"]] == ["pref"] + for jar in devtools.jars.values(): + assert [(c["name"], c.get("expires")) for c in jar] == [("pref", None)] + assert (await _post(app, CLEAR, {})).json() == {"cleared": 1, "open_browser": True} + assert json.loads((tmp_path / "storage-state.json").read_text())["cookies"] == [] + assert all(jar == [] for jar in devtools.jars.values()) + + +@pytest.mark.asyncio +async def test_concurrent_changes_never_restore_what_another_removed(tmp_path): + devtools = _DevTools() + app = recorder.create_app(_recorder(tmp_path, devtools)) + await _post(app, SET, {"cookies": [SID, OTHER]}) + await asyncio.gather(_post(app, CLEAR, {"domains": ["github.com"]}), _post(app, CLEAR, {"domains": ["example.com"]})) + assert json.loads((tmp_path / "storage-state.json").read_text())["cookies"] == [] + assert all(jar == [] for jar in devtools.jars.values()) + + +@pytest.mark.asyncio +async def test_a_bad_cookie_or_a_browser_that_refuses_answers_without_the_value(tmp_path): + app = recorder.create_app(_recorder(tmp_path, _DevTools(refuse=True))) + bad = await _post(app, SET, {"cookies": [{"name": "sid", "value": "s3cret"}]}) + assert bad.status_code == 400 and "cookies[0].domain" in bad.text and "s3cret" not in bad.text + refused = await _post(app, SET, {"cookies": [SID]}) + assert refused.status_code == 502 and refused.json()["error"]["code"] == "browser_unreachable" + assert "s3cret" not in refused.text + assert json.loads((tmp_path / "storage-state.json").read_text())["cookies"][0]["name"] == "sid" + + +@pytest.mark.asyncio +async def test_the_gateway_nests_browser_v1_under_the_browsers_child_env(tmp_path): + app = recorder.create_app(_recorder(tmp_path, None)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), 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-playwright-mcp", served, GATEWAY_EXTENSIONS)]} + child = protocol_client.find_child(composed, playwright_mcp.PLAYWRIGHT_MCP_ENVIRONMENT_NAME) + methods = protocol_client.find_extension(child, BROWSER_EXTENSION_URI)["params"]["methods"] + assert {op: m["endpoint"] for op, m in methods.items()} == { + "cookies/set": "/svc/mcp-playwright-mcp/agentenv/ext/browser/cookies/set", + "cookies/clear": "/svc/mcp-playwright-mcp/agentenv/ext/browser/cookies/clear"} + + +def test_playwright_mcp_starts_each_context_from_the_file_the_recorder_writes(): + entrypoint = (Path(playwright_mcp.__file__).parent / "entrypoint.sh").read_text() + default = re.search(r'PLAYWRIGHT_STORAGE_STATE="\$\{PLAYWRIGHT_STORAGE_STATE:-([^}]+)\}"', entrypoint).group(1) + assert default == recorder.DEFAULT_STORAGE_STATE + assert '--storage-state "${PLAYWRIGHT_STORAGE_STATE}"' in entrypoint diff --git a/tst/unit/env/envs/test_playwright_mcp_recorder.py b/tst/unit/env/envs/test_playwright_mcp_recorder.py index 207c0d45f..7c70c9d98 100644 --- a/tst/unit/env/envs/test_playwright_mcp_recorder.py +++ b/tst/unit/env/envs/test_playwright_mcp_recorder.py @@ -196,8 +196,9 @@ async def test_a_recording_through_the_card_is_an_mp4_of_the_page(home): manifest = (await http.post(END, json={"recording_id": started["recording_id"]})).json() 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"] + extensions = {e["uri"]: e for e in card["capabilities"]["extensions"]} + assert set(extensions) == {COMPUTER_RECORDING, "urn:agentenv:browser/v1"} + params = extensions[COMPUTER_RECORDING]["params"] assert params["cursor"] is False and params["source"] == "stream" and set(params["methods"]) == {"start", "end"} assert (manifest["ended_by"], manifest["cursor"], manifest["source"]) == ("end", False, "stream") (segment,) = manifest["segments"] diff --git a/tst/unit/env/envs/test_website_env_settings.py b/tst/unit/env/envs/test_website_env_settings.py new file mode 100644 index 000000000..18e7c6584 --- /dev/null +++ b/tst/unit/env/envs/test_website_env_settings.py @@ -0,0 +1,94 @@ +"""A website env's settings: environment variables of its server's container, a live site's credentials among them. +A secret is stored as its reference, never its value, and resolved from the deploying user's config only into the +compose file the gateway runs.""" + +import pytest +import yaml + +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.env import Env +from agent_env.env.envs.service_db import ServiceDBConfig +from agent_env.env.envs.website import WebsiteEnv, settings_problems +from agent_env.providers.env_providers.env_gateway_provider import ( + EnvironmentGatewayProvider, + _redact_compose_secrets, + website_config, +) +from agent_env.providers.env_state import LocalPostgresStateProvider + + +def _image(id): + return get_artifact_store().put_document(DockerImageArtifact( + id=id, description="", image_name=f"registry.example/{id}@sha256:" + "a" * 64)) + + +def test_a_secret_setting_is_stored_as_its_reference(local_stores): + WebsiteEnv.put(id="github-site", server_docker_image_artifact=_image("github-server"), environment_name="github", + settings={"GITHUB_TOKEN": "secret:github-token", "GITHUB_ORG": "acme"}) + stored = Env.get("github-site") + assert stored.settings == {"GITHUB_TOKEN": "secret:github-token", "GITHUB_ORG": "acme"} + assert stored.to_dict()["settings"] == stored.settings + assert "settings" not in WebsiteEnv.put(id="plain", server_docker_image_artifact=_image("plain-server"), + environment_name="plain").to_dict() + + +@pytest.mark.parametrize("settings, problem", [ + ({"GITHUB_TOKEN": "ghp_the_value_itself"}, "settings.GITHUB_TOKEN is a secret"), + ({"API_PASSWORD": "secret:pw?fallback"}, "with no ?default"), + ({"DATABASE_URL": "postgres://elsewhere"}, "the gateway doesn't set itself"), + ({"not a name": "x"}, "an environment variable's name"), + ({"RETRIES": 3}, "settings.RETRIES is a string, not a int"), +]) +def test_a_setting_that_isnt_one_is_refused_without_its_value(settings, problem): + (found,) = settings_problems(settings) + assert problem in found + assert "ghp_the_value_itself" not in found and "fallback" not in found + with pytest.raises(ValueError, match="website env 'x'"): + WebsiteEnv(id="x", version=None, environment_name="x", settings=settings) + + +def test_the_compose_file_holds_the_resolved_settings_and_the_gateway_config_the_references(local_stores, + monkeypatch): + env = WebsiteEnv(id="github-site", version=None, server_docker_image_artifact=_image("github-server"), + environment_name="github", settings={"GITHUB_TOKEN": "env:TEST_GITHUB_TOKEN", "ORG": "acme"}) + monkeypatch.setenv("TEST_GITHUB_TOKEN", "ghp_resolved") + config = website_config(env) + assert config.settings == {"GITHUB_TOKEN": "env:TEST_GITHUB_TOKEN", "ORG": "acme"} + compose = EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=[], website_configs=[config], + state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance()) + environment = yaml.safe_load(compose)["services"]["github-website-backend"]["environment"] + assert "GITHUB_TOKEN=ghp_resolved" in environment + assert "ORG=acme" in environment + + +def test_a_setting_holding_compose_syntax_stays_one_variable(local_stores): + hostile = 'ok\n privileged: true\n volumes:\n - /:/host\n# "q" \\ $HOME ${X} \u2028 é 😀 \x85' + env = WebsiteEnv(id="site", version=None, server_docker_image_artifact=_image("site-server"), + environment_name="site", settings={"EXTRA": hostile}) + compose = EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=[], website_configs=[website_config(env)], + state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance()) + service = yaml.safe_load(compose)["services"]["site-website-backend"] + assert "privileged" not in service and "volumes" not in service + (extra,) = [entry for entry in service["environment"] if entry.startswith("EXTRA=")] + assert extra == "EXTRA=" + hostile.replace("$", "$$") + + +def test_the_logged_compose_masks_every_resolved_setting(local_stores, monkeypatch): + env = WebsiteEnv(id="github-site", version=None, server_docker_image_artifact=_image("github-server"), + environment_name="github", settings={"GITHUB_TOKEN": "env:TEST_GITHUB_TOKEN", + "WEBHOOK": "env:TEST_WEBHOOK", "ORG": 'ac"me'}) + monkeypatch.setenv("TEST_GITHUB_TOKEN", "ghp_resolved") + monkeypatch.setenv("TEST_WEBHOOK", "https://hooks.example/s3cret") + compose = EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=[], website_configs=[website_config(env)], + state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance()) + logged = _redact_compose_secrets(compose) + assert "ghp_resolved" not in logged and "s3cret" not in logged + environment = yaml.safe_load(logged)["services"]["github-website-backend"]["environment"] + assert {"GITHUB_TOKEN=***", "WEBHOOK=***", "ORG=***", "SERVICE_NAME=github"} <= set(environment) diff --git a/tst/unit/task_step/sign_in_browser_test.py b/tst/unit/task_step/sign_in_browser_test.py new file mode 100644 index 000000000..ea1f917e1 --- /dev/null +++ b/tst/unit/task_step/sign_in_browser_test.py @@ -0,0 +1,246 @@ +"""sign_in_browser and the live-site marks: a deploy with a live website is marked not reproducible with its hosts +checked against the run's network allowlists, and the step hands each live site's session cookies to the browser's +browser/v1 cookies/set, through the gateway's child routes, keeping no cookie value in the context or its logs.""" + +from __future__ import annotations + +import json +import logging +from typing import Optional + +import pytest +from agentenv_protocol import client as protocol_client +from agentenv_protocol.agent_env_environment import (AgentEnvEnvironment, AgentEnvStarletteApplication, + environment_card, extension) +from agentenv_protocol.website import BROWSER_EXTENSION_URI, WebsiteEnvironment, session, website +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.testclient import TestClient + +from agent_env.env import env as env_module +from agent_env.env.env import DeployedEnv +from agent_env.env.gateway.constants import GATEWAY_EXTENSIONS +from agent_env.env.gateway.rest_proxy import rewrite_child_card +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.registry import get_task_step_registry +from agent_env.task_step.task_steps.reset_env import ResetEnvTaskStep +from agent_env.task_step.task_steps.sign_in_browser import (LIVE_KEY, SIGN_INS_KEY, SignInBrowserTaskStep, live_sites, + mark_live) + +TOKEN = "ghp_live_secret_value" + + +@environment_card(name="github") +@website(domains=["github.com"], live=True, allow_hosts=["*.githubusercontent.com"]) +class GitHub(WebsiteEnvironment): + @session + def sign_in(self, user: Optional[str]) -> list[dict]: + return [{"name": "user_session", "value": f"{TOKEN}-{user}", "secure": True, "httpOnly": True, + "expires": 1_900_000_000}] + + +@environment_card(name="notes") +@website(domains=["notes.test"]) +class Notes(WebsiteEnvironment): + pass + + +@environment_card(name="playwright-mcp") +class Browser(AgentEnvEnvironment): + def __init__(self, refuse: bool = False) -> None: + self.calls: list[tuple[str, dict]] = [] + self.refuse = refuse + + @extension(BROWSER_EXTENSION_URI, op="cookies/set") + async def _set(self, cookies: list[dict]) -> dict: + self.calls.append(("set", {"cookies": cookies})) + if self.refuse: + from agentenv_protocol.errors import AgentEnvError + raise AgentEnvError("browser_unreachable", f"refused {cookies[0]['value']}", status=502) + return {"set": len(cookies), "open_browser": False} + + @extension(BROWSER_EXTENSION_URI, op="cookies/clear") + async def _clear(self, domains: Optional[list[str]] = None) -> dict: + self.calls.append(("clear", {"domains": domains})) + return {"cleared": 0, "open_browser": False} + + +def _card_app(env: AgentEnvEnvironment) -> Starlette: + app = Starlette() + AgentEnvStarletteApplication(env._build_card(), env).add_routes_to_app(app) + return app + + +class _Gateway: + """The gateway's child routes and composed card, over the children's own apps in process.""" + + def __init__(self, browser: Browser, *, live: bool = True) -> None: + children = {"github": GitHub().create_app() if live else None, "notes": Notes().create_app(), + "mcp-playwright-mcp": _card_app(browser)} + children = {key: app for key, app in children.items() if app is not None} + self.client = TestClient(Starlette(routes=[Mount(f"/svc/{key}", app) for key, app in children.items()])) + cards = [rewrite_child_card(key, TestClient(app).get("/.well-known/agent-env.json").json(), GATEWAY_EXTENSIONS) + for key, app in children.items()] + self.card = {"name": "env", "capabilities": {"extensions": GATEWAY_EXTENSIONS}, + "children_environments": cards} + + def deployed(self, metadata: Optional[dict] = None) -> DeployedEnv: + return DeployedEnv(env_id="e1", env_version=1, environment_card_url="http://gw/.well-known/agent-env.json", + environment_card=self.card, metadata=metadata) + + async def invoke(self, base_url, card, uri, params=None, timeout=30, verify=True, *, method=None, headers=None): + op = protocol_client.find_extension_method(card, uri, method) + response = self.client.request(op["method"], op["endpoint"], json=params or {}) + if response.status_code >= 400: + raise protocol_client.ExtensionCallError(response) + return response.json() + + +@pytest.fixture +def browser() -> Browser: + return Browser() + + +@pytest.fixture +def gateway(browser, monkeypatch) -> _Gateway: + gateway = _Gateway(browser) + monkeypatch.setattr(env_module.protocol_v1, "invoke_extension", gateway.invoke) + return gateway + + +def test_the_step_is_registered_and_round_trips(): + step = SignInBrowserTaskStep(id="s", version=1, env_id="e1", users={"github": "octocat"}, sites=["github"]) + data = step.to_dict() + assert data["type"] == "sign_in_browser" and get_task_step_registry()["sign_in_browser"] is SignInBrowserTaskStep + again = SignInBrowserTaskStep.from_dict(data) + assert (again.users, again.sites, again.browser, again.fail_task_on_error) == ({"github": "octocat"}, ["github"], + None, True) + with pytest.raises(ValueError, match="maps a site"): + SignInBrowserTaskStep(id="s", version=1, env_id="e1", users={"github": 1}) + + +def test_only_a_live_child_is_a_live_site(gateway): + (site,) = live_sites(gateway.card) + assert (site.name, site.domains, site.allow_hosts, site.signs_in) == ("github", ("github.com",), + ("*.githubusercontent.com",), True) + + +def test_a_deploy_with_a_live_site_is_marked_not_reproducible(gateway): + context = TaskStepContext() + assert mark_live(context, gateway.deployed()) == {"live": True, "reproducible": False} + assert context.metadata["reproducible"] is False + assert context.metadata[LIVE_KEY]["e1"] == { + "sites": [{"name": "github", "domains": ["github.com"], "allow_hosts": ["*.githubusercontent.com"], + "signs_in": True}], "network": "unrestricted"} + assert mark_live(TaskStepContext(), _Gateway(Browser(), live=False).deployed()) == {} + + +def test_a_computers_allowlist_that_leaves_out_a_live_host_is_recorded_and_warned(gateway, caplog): + computer = DeployedEnv(env_id="pc", env_version=1, metadata={"computer": {"network": { + "mode": "allowlist", "allow_hosts": ["github.com"]}}}) + context = TaskStepContext(deployed_envs=[computer]) + with caplog.at_level(logging.WARNING): + mark_live(context, gateway.deployed()) + assert context.metadata[LIVE_KEY]["e1"]["unreachable"] == {"pc": ["*.githubusercontent.com"]} + assert "leaves out ['*.githubusercontent.com']" in caplog.text + + +def test_a_computer_deployed_after_a_live_site_is_checked_too(gateway, caplog): + context = TaskStepContext() + site = gateway.deployed() + mark_live(context, site) + context.deployed_envs.append(site) + assert context.metadata[LIVE_KEY]["e1"]["network"] == "unrestricted" + computer = DeployedEnv(env_id="pc", env_version=1, metadata={"computer": {"network": { + "mode": "allowlist", "allow_hosts": ["github.com"]}}}) + with caplog.at_level(logging.WARNING): + assert mark_live(context, computer) == {} + assert context.metadata[LIVE_KEY]["e1"]["network"] == "allowlist" + assert context.metadata[LIVE_KEY]["e1"]["unreachable"] == {"pc": ["*.githubusercontent.com"]} + assert "env pc's network allowlist leaves out ['*.githubusercontent.com']" in caplog.text + + +@pytest.mark.asyncio +async def test_the_browser_is_signed_in_with_the_live_sites_session_and_no_value_is_kept(gateway, browser, caplog): + context = TaskStepContext(deployed_envs=[gateway.deployed()]) + step = SignInBrowserTaskStep(id="sign-in", version=1, env_id="e1", users={"github": "octocat"}) + with caplog.at_level(logging.DEBUG): + await step.execute(context) + assert browser.calls == [ + ("clear", {"domains": ["github.com"]}), + ("set", {"cookies": [{"name": "user_session", "value": f"{TOKEN}-octocat", "domain": "github.com", "path": "/", + "secure": True, "httpOnly": True, "expires": 1_900_000_000}]})] + record = context.metadata[SIGN_INS_KEY]["sign-in"] + assert record["browser"] == "playwright-mcp" and record["sites"][0]["user"] == "octocat" + assert record["sites"][0]["cookies"] == [{"name": "user_session", "domain": "github.com", "path": "/", + "secure": True, "httpOnly": True, "expires": 1_900_000_000.0}] + assert context.metadata["reproducible"] is False + assert TOKEN not in json.dumps(context.to_safe_dict(), default=str) and TOKEN not in caplog.text + + +@pytest.mark.asyncio +async def test_a_browser_that_refuses_fails_the_step_without_the_value(monkeypatch, caplog): + browser = Browser(refuse=True) + gateway = _Gateway(browser) + monkeypatch.setattr(env_module.protocol_v1, "invoke_extension", gateway.invoke) + context = TaskStepContext(deployed_envs=[gateway.deployed()]) + with pytest.raises(RuntimeError, match="HTTP 502 browser_unreachable") as e: + await SignInBrowserTaskStep(id="s", version=1, env_id="e1").execute(context) + assert TOKEN not in str(e.value) and TOKEN not in caplog.text + assert TOKEN not in json.dumps(context.metadata[SIGN_INS_KEY]) + + +@pytest.mark.asyncio +async def test_live_sites_without_a_browser_are_refused(monkeypatch, gateway): + card = {**gateway.card, "children_environments": [c for c in gateway.card["children_environments"] + if c["name"] != "playwright-mcp"]} + deployed = DeployedEnv(env_id="e1", env_version=1, environment_card_url="http://gw/.well-known/agent-env.json", + environment_card=card) + with pytest.raises(RuntimeError, match="no child env serves urn:agentenv:browser/v1"): + await SignInBrowserTaskStep(id="s", version=1, env_id="e1").execute(TaskStepContext(deployed_envs=[deployed])) + step = SignInBrowserTaskStep(id="s", version=1, env_id="e1", sites=["notes"], fail_task_on_error=False) + context = await step.execute(TaskStepContext(deployed_envs=[deployed])) + assert "no live website named ['notes']" in context.metadata[SIGN_INS_KEY]["s"]["error"] + + +@pytest.mark.asyncio +async def test_a_reset_signs_the_browser_in_again(gateway, browser, monkeypatch): + context = TaskStepContext(deployed_envs=[gateway.deployed()]) + await SignInBrowserTaskStep(id="sign-in", version=1, env_id="e1", users={"github": "octocat"}).execute(context) + browser.calls.clear() + + class _Env: + async def reset(self, deployed): + return None + + monkeypatch.setattr("agent_env.env.env.Env.get", lambda *a, **k: _Env()) + await ResetEnvTaskStep(id="reset", version=1, env_id="e1").execute(context) + assert [call for call, _ in browser.calls] == ["clear", "set"] + assert browser.calls[1][1]["cookies"][0]["value"] == f"{TOKEN}-octocat" + + +@environment_card(name="github") +class GitHubMcp(AgentEnvEnvironment): + pass + + +@environment_card(name="playwright-mcp") +class OtherPlaywright(AgentEnvEnvironment): + pass + + +@pytest.mark.asyncio +async def test_a_site_and_a_browser_sharing_a_name_with_another_child_are_each_called_on_their_own_card(gateway, + browser): + decoys = [rewrite_child_card(key, TestClient(_card_app(env)).get("/.well-known/agent-env.json").json(), + GATEWAY_EXTENSIONS) + for key, env in (("mcp-github", GitHubMcp()), ("mcp-other-playwright", OtherPlaywright()))] + card = {**gateway.card, "children_environments": [*decoys, *gateway.card["children_environments"]]} + deployed = DeployedEnv(env_id="e1", env_version=1, environment_card_url="http://gw/.well-known/agent-env.json", + environment_card=card) + context = TaskStepContext(deployed_envs=[deployed]) + await SignInBrowserTaskStep(id="s", version=1, env_id="e1", users={"github": "octocat"}, + browser="playwright-mcp").execute(context) + assert [call for call, _ in browser.calls] == ["clear", "set"] + assert browser.calls[1][1]["cookies"][0]["value"] == f"{TOKEN}-octocat" + assert context.metadata[SIGN_INS_KEY]["s"]["browser"] == "playwright-mcp" From 2db1f6b80a530a3fb63ff00ced141fcd4d8a355a Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 15:01:27 -0800 Subject: [PATCH 34/57] fix(website): sign in a browser without cookies/clear, and replay compressed and inline-styled captures (FD-4683) - sign_in skips cookies/clear on a browser that serves only cookies/set, and names its error-record limit. - An imported WARC's gzip or deflate answers are read decoded, so their pages' and stylesheets' links are rewritten. - A capture loads what a page's style values, style elements and video posters name. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/replay.py | 48 ++++++++++++++++- .../tests/test_website_replay.py | 54 +++++++++++++++++++ .../task_step/task_steps/sign_in_browser.py | 12 +++-- tst/unit/task_step/sign_in_browser_test.py | 22 ++++++++ 4 files changed, 129 insertions(+), 7 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py index 3ae4b0b30..750e7f8d3 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py @@ -40,6 +40,7 @@ import re import sys import uuid +import zlib from collections import deque from html.parser import HTMLParser from pathlib import Path @@ -206,7 +207,33 @@ def _http_response(block: bytes) -> tuple[int, tuple[tuple[str, str], ...], byte if any(n.lower() == "transfer-encoding" and "chunked" in v.lower() for n, v in headers): body = _dechunked(body) headers = tuple((n, v) for n, v in headers if n.lower() != "transfer-encoding") - return status, headers, body + return status, *_decoded(headers, body) + + +_DECODERS = {"gzip": zlib.MAX_WBITS | 16, "x-gzip": zlib.MAX_WBITS | 16, "deflate": zlib.MAX_WBITS} + + +def _decoded(headers: tuple[tuple[str, str], ...], body: bytes) -> tuple[tuple[tuple[str, str], ...], bytes]: + """``body`` with its ``Content-Encoding`` undone and that header dropped, as a capture of this module stores it, so + a page imported from another WARC tool is rewritten too; a coding it can't undo (``br``, say) leaves both as they + are, and a body that expands past ``MAX_CAPTURE_BYTES`` is refused.""" + codings = [c.strip().lower() for n, v in headers if n.lower() == "content-encoding" for c in v.split(",")] + codings = [c for c in codings if c and c != "identity"] + if not codings or any(c not in _DECODERS for c in codings): + return headers, body + for coding in reversed(codings): + inflater = zlib.decompressobj(_DECODERS[coding]) + try: + expanded = inflater.decompress(body, MAX_CAPTURE_BYTES + 1) + except zlib.error: + if coding != "deflate": + return headers, body + inflater = zlib.decompressobj(-zlib.MAX_WBITS) + expanded = inflater.decompress(body, MAX_CAPTURE_BYTES + 1) + if len(expanded) > MAX_CAPTURE_BYTES or inflater.unconsumed_tail: + raise ValueError(f"a captured answer is over {MAX_CAPTURE_BYTES} bytes decoded") + body = expanded + return tuple((n, v) for n, v in headers if n.lower() not in ("content-encoding", "content-length")), body def _dechunked(body: bytes) -> bytes: @@ -256,7 +283,8 @@ def _record(self, kind: str, url: Optional[str], content_type: str, block: bytes class _Links(HTMLParser): - """The links of an HTML page: ``pages`` its anchors and frames lead to, ``assets`` what it loads to show.""" + """The links of an HTML page: ``pages`` its anchors and frames lead to, ``assets`` what it loads to show, a + video's ``poster`` and the CSS URLs of its ``style`` values and ``' + b'
' + b''), + "/t.css": (200, "text/css", b"p{}"), + "/h.png": (200, "image/png", b"\x89PNG"), + "/d.png": (200, "image/png", b"\x89PNG"), + "/p.jpg": (200, "image/jpeg", b"\xff\xd8"), + } + + +def test_a_capture_loads_what_a_pages_style_values_style_elements_and_posters_name(): + site = _InlineStyledSite() + try: + out = io.BytesIO() + result = capture([site.url + "/"], out, depth=0) + assert {key[1] for key in Archive.read(out.getvalue()).entries} == {"/", "/t.css", "/h.png", "/d.png", "/p.jpg"} + assert result.captured == 5 and not result.failed + finally: + site.close() + + +def test_an_imported_warcs_compressed_page_is_read_decoded_so_its_links_stay_on_the_replay(tmp_path): + page = b'a' + css = b"b{background:url(/b.png)}" + records = b"" + for url, kind, body, coding in (("https://shop.example/", "text/html", gzip.compress(page), "gzip"), + ("https://shop.example/s.css", "text/css", zlib.compress(css), "deflate")): + http = (f"HTTP/1.1 200 OK\r\nContent-Type: {kind}\r\nContent-Encoding: {coding}\r\n" + f"Content-Length: {len(body)}\r\n\r\n").encode() + body + records += (f"WARC/1.0\r\nWARC-Type: response\r\nWARC-Target-URI: {url}\r\n" + f"Content-Type: application/http;msgtype=response\r\nContent-Length: {len(http)}\r\n\r\n" + ).encode() + http + b"\r\n\r\n" + (tmp_path / "other.warc").write_bytes(records) + site = TestClient(Shop(tmp_path / "other.warc").create_app()) + behind = {"X-Forwarded-Prefix": "/website/shop"} + served = site.get("/", headers=behind) + assert "content-encoding" not in served.headers + assert served.content == b'a' + assert site.get("/s.css", headers=behind).content == b"b{background:url(/website/shop/b.png)}" + + +def test_an_imported_answer_in_a_coding_the_replay_cant_undo_is_served_as_captured(): + http = b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Encoding: br\r\n\r\n\x0b\x02\x80" + record = (f"WARC/1.0\r\nWARC-Type: response\r\nWARC-Target-URI: https://shop.example/\r\n" + f"Content-Type: application/http;msgtype=response\r\nContent-Length: {len(http)}\r\n\r\n").encode() + entry = Archive.read(record + http + b"\r\n\r\n").find("GET", "/") + assert entry.body == b"\x0b\x02\x80" and ("Content-Encoding", "br") in entry.headers + + def test_a_replay_drops_a_link_tags_integrity_so_its_rewritten_stylesheet_still_loads(): page = (b'' b'') diff --git a/src/agent_env/task_step/task_steps/sign_in_browser.py b/src/agent_env/task_step/task_steps/sign_in_browser.py index 05cd4e8b2..2d0d539c8 100644 --- a/src/agent_env/task_step/task_steps/sign_in_browser.py +++ b/src/agent_env/task_step/task_steps/sign_in_browser.py @@ -35,6 +35,7 @@ LIVE_KEY = "live_websites" REPRODUCIBLE_KEY = "reproducible" SIGN_INS_KEY = "browser_sign_ins" +_ERROR_CHARS = 500 @dataclass(frozen=True) @@ -156,8 +157,8 @@ async def sign_in(context: TaskStepContext, deployed: Any, *, users: Optional[di sites: Optional[list[str]] = None, browser: Optional[str] = None, clear: bool = True, timeout: int = 30) -> dict: """Sign ``deployed``'s browser in to its live websites (those named in ``sites``, else every one with a session), - each as ``users[name]`` or the site's own choice; with ``clear``, a site's old cookies go first. Returns the record - of what was set, cookies by everything but their values.""" + each as ``users[name]`` or the site's own choice; with ``clear``, a site's old cookies go first, on a browser that + serves cookies/clear. Returns the record of what was set, cookies by everything but their values.""" card = deployed.environment_card or {} live = {site.name: site for site in live_sites(card)} unknown = sorted(set(sites or []) - set(live)) @@ -188,7 +189,8 @@ async def sign_in(context: TaskStepContext, deployed: Any, *, users: Optional[di except ValueError as e: raise RuntimeError(f"live website {site.name!r}: its session returned {e}") from None try: - if clear and site.domains: + if clear and site.domains and protocol_v1.find_extension_method(target.card, BROWSER_EXTENSION_URI, + CLEAR_COOKIES) is not None: await _invoke(deployed, target, BROWSER_EXTENSION_URI, CLEAR_COOKIES, {"domains": list(site.domains)}, timeout) await _invoke(deployed, target, BROWSER_EXTENSION_URI, SET_COOKIES, @@ -225,7 +227,7 @@ async def sign_in_again(context: TaskStepContext, env_id: str) -> None: context, deployed, users=users, sites=[s["name"] for s in entry["sites"]], browser=entry.get("browser")) except Exception as e: logger.warning("reset_env: signing env %s's browser in again failed: %s", env_id, e) - entry["error"] = str(e)[:500] + entry["error"] = str(e)[:_ERROR_CHARS] class SignInBrowserTaskStep(TaskStep): @@ -272,7 +274,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: record = await sign_in(context, deployed, users=self.users, sites=self.sites, browser=self.browser, timeout=self.timeout_seconds) except Exception as e: - context.metadata.setdefault(SIGN_INS_KEY, {})[self.id] = {"env_id": self.env_id, "error": str(e)[:500]} + context.metadata.setdefault(SIGN_INS_KEY, {})[self.id] = {"env_id": self.env_id, "error": str(e)[:_ERROR_CHARS]} logger.warning("sign_in_browser %s: %s", self.id, e) if self.fail_task_on_error: raise diff --git a/tst/unit/task_step/sign_in_browser_test.py b/tst/unit/task_step/sign_in_browser_test.py index ea1f917e1..f5ae88da3 100644 --- a/tst/unit/task_step/sign_in_browser_test.py +++ b/tst/unit/task_step/sign_in_browser_test.py @@ -178,6 +178,28 @@ async def test_the_browser_is_signed_in_with_the_live_sites_session_and_no_value assert TOKEN not in json.dumps(context.to_safe_dict(), default=str) and TOKEN not in caplog.text +@environment_card(name="playwright-mcp") +class SetOnlyBrowser(AgentEnvEnvironment): + def __init__(self) -> None: + self.calls: list[tuple[str, dict]] = [] + + @extension(BROWSER_EXTENSION_URI, op="cookies/set") + async def _set(self, cookies: list[dict]) -> dict: + self.calls.append(("set", {"cookies": cookies})) + return {"set": len(cookies), "open_browser": False} + + +@pytest.mark.asyncio +async def test_a_browser_serving_only_cookies_set_is_signed_in_without_clearing(monkeypatch): + browser = SetOnlyBrowser() + gateway = _Gateway(browser) + monkeypatch.setattr(env_module.protocol_v1, "invoke_extension", gateway.invoke) + context = TaskStepContext(deployed_envs=[gateway.deployed()]) + await SignInBrowserTaskStep(id="s", version=1, env_id="e1").execute(context) + assert [call for call, _ in browser.calls] == ["set"] + assert context.metadata[SIGN_INS_KEY]["s"]["sites"][0]["name"] == "github" + + @pytest.mark.asyncio async def test_a_browser_that_refuses_fails_the_step_without_the_value(monkeypatch, caplog): browser = Browser(refuse=True) From a27198463b389ed25f2a8a670b3b26ce00d1791a Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 16:04:19 -0800 Subject: [PATCH 35/57] fix(website): sign_in_browser picks its deployment by env_step_id, and signs in again after a reset with its own timeout (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../task_step/task_steps/reset_env.py | 2 +- .../task_step/task_steps/sign_in_browser.py | 59 ++++++++++++++----- tst/unit/task_step/sign_in_browser_test.py | 32 ++++++++++ 3 files changed, 77 insertions(+), 16 deletions(-) diff --git a/src/agent_env/task_step/task_steps/reset_env.py b/src/agent_env/task_step/task_steps/reset_env.py index 7ff94195c..59d1717d1 100644 --- a/src/agent_env/task_step/task_steps/reset_env.py +++ b/src/agent_env/task_step/task_steps/reset_env.py @@ -64,7 +64,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: logger.info("reset_env: reset env '%s'", self.env_id) from agent_env.task_step.task_steps.sign_in_browser import sign_in_again - await sign_in_again(context, self.env_id) + await sign_in_again(context, deployed) except NotImplementedError: # Env doesn't support reset — not an error. logger.info("reset_env: env '%s' does not support reset; skipping", self.env_id) diff --git a/src/agent_env/task_step/task_steps/sign_in_browser.py b/src/agent_env/task_step/task_steps/sign_in_browser.py index 2d0d539c8..e9c512f11 100644 --- a/src/agent_env/task_step/task_steps/sign_in_browser.py +++ b/src/agent_env/task_step/task_steps/sign_in_browser.py @@ -11,6 +11,8 @@ browser/v1 ``cookies/set``, each called on the child env's own card as the env's card nests it, so a site sharing its name with an MCP server, or with another child, is still the one called. The cookies never enter the context, a log or the step's record, which keeps only each cookie's name, domain, path and expiry. reset_env signs the browser in again after a reset, as the last sign-in did. +With several deployments of the env in a run, ``env_step_id`` names the deploy_env step whose deployment it signs in; the +record keeps that step and the call timeout, so the sign-in after a reset reaches the same browser the same way. """ from __future__ import annotations @@ -176,7 +178,8 @@ async def sign_in(context: TaskStepContext, deployed: Any, *, users: Optional[di f"({', '.join(str(c.url) for c in offered)}), so the name can't say which to sign in") target = offered[0] if chosen else None browser = target.name if target is not None else browser - record: dict = {"env_id": deployed.env_id, "browser": browser, "sites": []} + record: dict = {"env_id": deployed.env_id, "env_step_id": deploy_step_of(deployed), "browser": browser, + "timeout_seconds": timeout, "sites": []} for site in chosen: user = (users or {}).get(site.name) try: @@ -215,25 +218,53 @@ def _why(error: Exception) -> str: return type(error).__name__ -async def sign_in_again(context: TaskStepContext, env_id: str) -> None: - """After a reset of ``env_id``, sign its browser in again as each sign-in of this run did; never raises.""" - deployed = next((d for d in context.deployed_envs if d.env_id == env_id), None) +def deploy_step_of(deployed: Any) -> Optional[str]: + """The id of the deploy_env step that deployed ``deployed``, or None.""" + return (getattr(deployed, "metadata", None) or {}).get("deploy_step_id") + + +def deployment(context: TaskStepContext, env_id: str, env_step_id: Optional[str], step_id: str) -> Any: + """The deployment of ``env_id`` a sign-in reaches: the one deploy_env step ``env_step_id`` made, else the only one; + raises when there is none, or several and no ``env_step_id`` says which.""" + matches = [d for d in context.deployed_envs if d.env_id == env_id] + if env_step_id is not None: + named = [d for d in matches if deploy_step_of(d) == env_step_id] + if not named: + raise RuntimeError(f"sign_in_browser {step_id!r}: env_step_id {env_step_id!r} names no deployment of env " + f"{env_id!r} (deployed by: {[deploy_step_of(d) for d in matches]})") + matches = named + if not matches: + raise RuntimeError(f"Env '{env_id}' not found in context.deployed_envs") + if len(matches) > 1: + raise RuntimeError(f"sign_in_browser {step_id!r}: {len(matches)} deployments of env {env_id!r} (deployed by: " + f"{[deploy_step_of(d) for d in matches]}); set env_step_id to the deploy_env step whose " + "browser to sign in") + return matches[0] + + +async def sign_in_again(context: TaskStepContext, deployed: Any) -> None: + """After a reset of ``deployed``, sign its browser in again as each sign-in of this run that reached it did, with + the same timeout; never raises.""" for step_id, entry in list((context.metadata.get(SIGN_INS_KEY) or {}).items()): - if deployed is None or entry.get("env_id") != env_id or not entry.get("sites"): + if entry.get("env_id") != deployed.env_id or not entry.get("sites"): + continue + if entry.get("env_step_id") is not None and entry["env_step_id"] != deploy_step_of(deployed): continue users = {s["name"]: s["user"] for s in entry["sites"] if s.get("user")} try: context.metadata[SIGN_INS_KEY][step_id] = await sign_in( - context, deployed, users=users, sites=[s["name"] for s in entry["sites"]], browser=entry.get("browser")) + context, deployed, users=users, sites=[s["name"] for s in entry["sites"]], browser=entry.get("browser"), + timeout=entry.get("timeout_seconds") or 30) except Exception as e: - logger.warning("reset_env: signing env %s's browser in again failed: %s", env_id, e) + logger.warning("reset_env: signing env %s's browser in again failed: %s", deployed.env_id, e) entry["error"] = str(e)[:_ERROR_CHARS] class SignInBrowserTaskStep(TaskStep): """Sign a deployed env's browser in to its live websites before the agent starts. ``users`` maps a site's name to the user to sign in as; ``sites`` limits it to some live sites; ``browser`` names the child env serving browser/v1 - when there are several.""" + when there are several; ``env_step_id`` names the deploy_env step whose deployment it signs in, when the env is + deployed more than once.""" type: ClassVar[str] = "sign_in_browser" entity_refs = (EntityRef.env("env_id"),) @@ -241,7 +272,7 @@ class SignInBrowserTaskStep(TaskStep): def __init__(self, id: str, version: Optional[int], env_id: str, users: Optional[dict[str, str]] = None, sites: Optional[list[str]] = None, browser: Optional[str] = None, depends_on: Optional[list[TaskStepDependency]] = None, fail_task_on_error: bool = True, - timeout_seconds: int = 30): + timeout_seconds: int = 30, env_step_id: Optional[str] = None): super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error) if not env_id: raise ValueError("sign_in_browser needs the env_id of the env whose browser it signs in") @@ -249,11 +280,11 @@ def __init__(self, id: str, version: Optional[int], env_id: str, users: Optional isinstance(k, str) and isinstance(v, str) for k, v in users.items())): raise ValueError("sign_in_browser's users maps a site's name to a user's") self.env_id, self.users, self.sites, self.browser = env_id, users, sites, browser - self.timeout_seconds = timeout_seconds + self.timeout_seconds, self.env_step_id = timeout_seconds, env_step_id def to_dict(self) -> dict: data = {**super().to_dict(), "env_id": self.env_id, "timeout_seconds": self.timeout_seconds} - for key in ("users", "sites", "browser"): + for key in ("users", "sites", "browser", "env_step_id"): if getattr(self, key) is not None: data[key] = getattr(self, key) return data @@ -262,12 +293,10 @@ def to_dict(self) -> dict: def from_dict(cls, data: dict) -> SignInBrowserTaskStep: return cls(**cls._base_from_dict(data), env_id=data["env_id"], users=data.get("users"), sites=data.get("sites"), browser=data.get("browser"), - timeout_seconds=data.get("timeout_seconds", 30)) + timeout_seconds=data.get("timeout_seconds", 30), env_step_id=data.get("env_step_id")) async def execute(self, context: TaskStepContext) -> TaskStepContext: - deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None) - if deployed is None: - raise RuntimeError(f"Env '{self.env_id}' not found in context.deployed_envs") + deployed = deployment(context, self.env_id, self.env_step_id, self.id) if live_sites(deployed.environment_card) and self.env_id not in (context.metadata.get(LIVE_KEY) or {}): mark_live(context, deployed) try: diff --git a/tst/unit/task_step/sign_in_browser_test.py b/tst/unit/task_step/sign_in_browser_test.py index f5ae88da3..77980b146 100644 --- a/tst/unit/task_step/sign_in_browser_test.py +++ b/tst/unit/task_step/sign_in_browser_test.py @@ -241,6 +241,38 @@ async def reset(self, deployed): assert browser.calls[1][1]["cookies"][0]["value"] == f"{TOKEN}-octocat" +@pytest.mark.asyncio +async def test_with_two_deployments_of_the_env_the_one_env_step_id_names_is_signed_in_and_signed_in_again( + gateway, browser, monkeypatch): + first, second = gateway.deployed({"deploy_step_id": "deploy-a"}), gateway.deployed({"deploy_step_id": "deploy-b"}) + context = TaskStepContext(deployed_envs=[first, second]) + with pytest.raises(RuntimeError, match=r"2 deployments of env 'e1' \(deployed by: \['deploy-a', 'deploy-b'\]\)"): + await SignInBrowserTaskStep(id="s", version=1, env_id="e1").execute(context) + with pytest.raises(RuntimeError, match="env_step_id 'deploy-c' names no deployment"): + await SignInBrowserTaskStep(id="s", version=1, env_id="e1", env_step_id="deploy-c").execute(context) + step = SignInBrowserTaskStep(id="sign-in", version=1, env_id="e1", env_step_id="deploy-b", timeout_seconds=90) + assert SignInBrowserTaskStep.from_dict(step.to_dict()).env_step_id == "deploy-b" + await step.execute(context) + entry = context.metadata[SIGN_INS_KEY]["sign-in"] + assert (entry["env_step_id"], entry["timeout_seconds"]) == ("deploy-b", 90) + + timeouts = [] + real_invoke = gateway.invoke + + async def invoke(base_url, card, uri, params=None, timeout=30, *args, **kwargs): + timeouts.append(timeout) + return await real_invoke(base_url, card, uri, params, timeout, *args, **kwargs) + + monkeypatch.setattr(env_module.protocol_v1, "invoke_extension", invoke) + from agent_env.task_step.task_steps.sign_in_browser import sign_in_again + + browser.calls.clear() + await sign_in_again(context, first) + assert browser.calls == [] and timeouts == [] + await sign_in_again(context, second) + assert [call for call, _ in browser.calls] == ["clear", "set"] and set(timeouts) == {90} + + @environment_card(name="github") class GitHubMcp(AgentEnvEnvironment): pass From 1b43983dc958b83bfe87767d14d7391b9a364c5a Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 16:39:18 -0800 Subject: [PATCH 36/57] fix(website): a replay behind the gateway's prefix scopes each cookie's Path under it (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/replay.py | 20 +++++++++++++++---- .../tests/test_website_replay.py | 13 ++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py index 750e7f8d3..36afa8db3 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py @@ -573,16 +573,28 @@ def _answer(self, entry: Entry, head: bool, root: str = "") -> Response: return response def _header(self, name: str, value: str, root: str = "") -> str: - """``value`` as the replay answers it: a Set-Cookie without its Domain, so it holds at the replay's host, and - a redirect to a captured host or a root-relative one led by the ``root`` path the replay is served under, so - it stays on the replay behind a gateway's prefix too.""" + """``value`` as the replay answers it: a Set-Cookie without its Domain, so it holds at the replay's host, with + its Path led by the ``root`` path the replay is served under, so it holds there for the paths it named; and a + redirect to a captured host or a root-relative one led by ``root`` too, so it stays on the replay behind a + gateway's prefix.""" if name == "set-cookie": - return ";".join(p for p in value.split(";") if not p.strip().lower().startswith("domain=")) + return ";".join(_cookie_attribute(p, root) for p in value.split(";") + if not p.strip().lower().startswith("domain=")) if name == "location": return _rewritten_url(value, self.env.archive.hosts, root) return value +def _cookie_attribute(part: str, root: str) -> str: + """A Set-Cookie attribute as the replay answers it: a ``Path`` that names a path of the site led by ``root``, so a + cookie scoped to ``/account`` holds at ``/account``; anything else as it is.""" + name, _, path = part.partition("=") + root = root.rstrip("/") + if not root or name.strip().lower() != "path" or not path.strip().startswith("/"): + return part + return f"{name}={root}{path.strip()}" + + _ABSOLUTE_URL = re.compile(r"(?P\s*)(?:https?:)?//(?P[^/?#\s]*)(?P.*)", re.IGNORECASE | re.DOTALL) _TAG_NAME = re.compile(r"<[a-zA-Z][^\t\n\r\f />\x00]*(?:\s|/(?!>))*") _ATTRIBUTE = re.compile(r"""(?P[^\s/>][^\s/=>]*)(?:\s*=+\s*(?P'[^']*'|"[^"]*"|(?!['"])[^>\s]*))?""" diff --git a/packages/agentenv-protocol/tests/test_website_replay.py b/packages/agentenv-protocol/tests/test_website_replay.py index cf32b7b8c..e83cf5507 100644 --- a/packages/agentenv-protocol/tests/test_website_replay.py +++ b/packages/agentenv-protocol/tests/test_website_replay.py @@ -216,6 +216,18 @@ def test_the_data_plane_exports_the_overrides_state(client): assert rpc(client, "data/get")["parts"][0]["data"] == {} +def test_a_cookie_scoped_to_a_path_holds_at_that_path_behind_the_gateways_prefix(): + site = TestClient(Shop(warc(("https://shop.example/account/sign-in", 200, [ + ("Set-Cookie", "sid=abc; Domain=shop.example; path=/account; Secure"), + ("Set-Cookie", "theme=dark"), + ("Set-Cookie", "note=1; Path=relative")], b"ok"))).create_app()) + behind = {"X-Forwarded-Prefix": "/website/shop"} + assert site.get("/account/sign-in", headers=behind).headers.get_list("set-cookie") == [ + "sid=abc; path=/website/shop/account; Secure", "theme=dark", "note=1; Path=relative"] + assert site.get("/account/sign-in").headers.get_list("set-cookie") == [ + "sid=abc; path=/account; Secure", "theme=dark", "note=1; Path=relative"] + + def test_a_replay_behind_the_gateways_prefix_matches_the_sites_own_path(client): behind = {"X-Forwarded-Prefix": "/website/shop"} assert client.get("/", headers=behind).text == "home" @@ -225,6 +237,7 @@ def test_a_replay_behind_the_gateways_prefix_matches_the_sites_own_path(client): assert client.get("/nowhere", headers=behind).status_code == 404 moved = client.get("/old", headers=behind, follow_redirects=False) assert moved.headers["location"] == "/website/shop/new?x=1" + assert client.get("/login", headers=behind).headers["set-cookie"] == "sid=abc; Path=/website/shop/; HttpOnly" relative = TestClient(Shop(warc(("https://shop.example/a", 302, [("Location", "/b")], b""), ("https://shop.example/c", 302, [("Location", "d")], b""))).create_app()) From d8a61dde3329fbda6a6962accf771af85ca81a44 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 17:15:43 -0800 Subject: [PATCH 37/57] fix(website): a WARC's decoded answers share one size budget, and capture and sign-in name their defaults (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/replay.py | 40 ++++++++++++------- .../tests/test_website_replay.py | 19 +++++++++ .../task_step/task_steps/sign_in_browser.py | 9 +++-- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py index 36afa8db3..d05f766bf 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py @@ -62,6 +62,8 @@ MAX_CAPTURE_BYTES = 1 << 30 CAPTURE_READ_CHUNK_BYTES = 1 << 20 CAPTURE_URL_TIMEOUT_SECONDS = 60 +CAPTURE_MAX_REQUESTS = 500 +CAPTURE_REQUEST_TIMEOUT_SECONDS = 20.0 DEFAULT_IGNORED_QUERY = ("utm_*", "fbclid", "gclid", "msclkid", "_") WARC_VERSION = "WARC/1.1" WARC_TYPES = ("application/warc", "application/warc+gzip") @@ -140,7 +142,7 @@ def holds(self, path: str) -> bool: @classmethod def read(cls, source: str | Path | bytes, ignore_query: Iterable[str] = DEFAULT_IGNORED_QUERY) -> Archive: """The answers of a WARC file, gzipped or not, given as its path or its bytes, refused once it expands past - ``MAX_CAPTURE_BYTES``.""" + ``MAX_CAPTURE_BYTES``, or once its answers' bodies, decoded, together do.""" if isinstance(source, bytes): raw = _expanded(io.BytesIO(source)) else: @@ -182,6 +184,8 @@ def _records(stream: BinaryIO) -> Iterator[tuple[dict[str, str], bytes]]: def _entries(stream: BinaryIO) -> Iterator[Entry]: + """Each answer the WARC holds, its body decoded within what is left of ``MAX_CAPTURE_BYTES`` for all the bodies + together, so many small compressed answers can't fill the memory between them.""" methods: dict[str, str] = {} responses = [] for headers, block in _records(stream): @@ -192,13 +196,17 @@ def _entries(stream: BinaryIO) -> Iterator[Entry]: methods[headers["warc-concurrent-to"]] = method elif kind == "response" and "application/http" in headers.get("content-type", ""): responses.append((headers, block)) + left = MAX_CAPTURE_BYTES for headers, block in responses: - status, entry_headers, body = _http_response(block) + status, entry_headers, body = _http_response(block, left) + left -= len(body) + if left < 0: + raise ValueError(f"the capture's answers are over {MAX_CAPTURE_BYTES} bytes decoded") yield Entry(methods.get(headers.get("warc-record-id", ""), "GET"), headers["warc-target-uri"], status, entry_headers, body) -def _http_response(block: bytes) -> tuple[int, tuple[tuple[str, str], ...], bytes]: +def _http_response(block: bytes, limit: int) -> tuple[int, tuple[tuple[str, str], ...], bytes]: head, _, body = block.partition(b"\r\n\r\n") lines = head.decode("latin-1").split("\r\n") status = int(lines[0].split(" ", 2)[1]) @@ -207,16 +215,16 @@ def _http_response(block: bytes) -> tuple[int, tuple[tuple[str, str], ...], byte if any(n.lower() == "transfer-encoding" and "chunked" in v.lower() for n, v in headers): body = _dechunked(body) headers = tuple((n, v) for n, v in headers if n.lower() != "transfer-encoding") - return status, *_decoded(headers, body) + return status, *_decoded(headers, body, limit) _DECODERS = {"gzip": zlib.MAX_WBITS | 16, "x-gzip": zlib.MAX_WBITS | 16, "deflate": zlib.MAX_WBITS} -def _decoded(headers: tuple[tuple[str, str], ...], body: bytes) -> tuple[tuple[tuple[str, str], ...], bytes]: +def _decoded(headers: tuple[tuple[str, str], ...], body: bytes, limit: int) -> tuple[tuple[tuple[str, str], ...], bytes]: """``body`` with its ``Content-Encoding`` undone and that header dropped, as a capture of this module stores it, so a page imported from another WARC tool is rewritten too; a coding it can't undo (``br``, say) leaves both as they - are, and a body that expands past ``MAX_CAPTURE_BYTES`` is refused.""" + are, and a body that expands past ``limit`` bytes is refused, decoding no further than that.""" codings = [c.strip().lower() for n, v in headers if n.lower() == "content-encoding" for c in v.split(",")] codings = [c for c in codings if c and c != "identity"] if not codings or any(c not in _DECODERS for c in codings): @@ -224,14 +232,14 @@ def _decoded(headers: tuple[tuple[str, str], ...], body: bytes) -> tuple[tuple[t for coding in reversed(codings): inflater = zlib.decompressobj(_DECODERS[coding]) try: - expanded = inflater.decompress(body, MAX_CAPTURE_BYTES + 1) + expanded = inflater.decompress(body, limit + 1) except zlib.error: if coding != "deflate": return headers, body inflater = zlib.decompressobj(-zlib.MAX_WBITS) - expanded = inflater.decompress(body, MAX_CAPTURE_BYTES + 1) - if len(expanded) > MAX_CAPTURE_BYTES or inflater.unconsumed_tail: - raise ValueError(f"a captured answer is over {MAX_CAPTURE_BYTES} bytes decoded") + expanded = inflater.decompress(body, limit + 1) + if len(expanded) > limit or inflater.unconsumed_tail: + raise ValueError(f"the capture's answers are over {MAX_CAPTURE_BYTES} bytes decoded") body = expanded return tuple((n, v) for n, v in headers if n.lower() not in ("content-encoding", "content-length")), body @@ -344,9 +352,9 @@ class CaptureResult: skipped: int = 0 -def capture(urls: list[str], out: BinaryIO, *, depth: int = 1, max_requests: int = 500, assets: bool = True, - timeout: float = 20.0, client: Any = None, user_agent: str = "agentenv-replay-capture/1", - gzipped: bool = True) -> CaptureResult: +def capture(urls: list[str], out: BinaryIO, *, depth: int = 1, max_requests: int = CAPTURE_MAX_REQUESTS, + assets: bool = True, timeout: float = CAPTURE_REQUEST_TIMEOUT_SECONDS, client: Any = None, + user_agent: str = "agentenv-replay-capture/1", gzipped: bool = True) -> CaptureResult: """Capture ``urls`` and what they lead to into ``out``, a WARC file; ``client`` is an ``httpx.Client`` to fetch with instead of one of its own, which must not follow redirects.""" import httpx @@ -727,9 +735,11 @@ def main(argv: list[str] | None = None) -> int: grab.add_argument("urls", nargs="+", metavar="URL") grab.add_argument("--out", required=True, help="the WARC file to write; gzipped unless it ends in .warc") grab.add_argument("--depth", type=int, default=1, help="how many links away from a start URL to follow (1)") - grab.add_argument("--max-requests", type=int, default=500, help="stop after this many requests (500)") + grab.add_argument("--max-requests", type=int, default=CAPTURE_MAX_REQUESTS, + help=f"stop after this many requests ({CAPTURE_MAX_REQUESTS})") grab.add_argument("--no-assets", action="store_true", help="capture pages only, not what they load") - grab.add_argument("--timeout", type=float, default=20.0, help="seconds one request may take (20)") + grab.add_argument("--timeout", type=float, default=CAPTURE_REQUEST_TIMEOUT_SECONDS, + help=f"seconds one request may take ({CAPTURE_REQUEST_TIMEOUT_SECONDS:g})") serve = commands.add_parser("serve", help="serve a capture on PORT as a website/v1 site") serve.add_argument("capture") serve.add_argument("--domain", action="append", default=[], help="a domain the site is declared at") diff --git a/packages/agentenv-protocol/tests/test_website_replay.py b/packages/agentenv-protocol/tests/test_website_replay.py index e83cf5507..5bab3b872 100644 --- a/packages/agentenv-protocol/tests/test_website_replay.py +++ b/packages/agentenv-protocol/tests/test_website_replay.py @@ -419,6 +419,25 @@ def test_a_capture_that_expands_past_its_limit_is_refused_while_it_is_read(clien assert Archive.read(warc(("https://shop.example/", 200, [], b"x" * 40_000))).find("GET", "/").body == b"x" * 40_000 +def test_many_small_compressed_answers_are_refused_once_their_decoded_bodies_together_pass_the_limit(monkeypatch): + from agentenv_protocol.website import replay + + def answers(count: int) -> bytes: + records = b"" + for n in range(count): + body = gzip.compress(b"x" * 20_000) + http = (f"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {len(body)}\r\n\r\n").encode() + body + records += (f"WARC/1.0\r\nWARC-Type: response\r\nWARC-Target-URI: https://shop.example/{n}\r\n" + f"Content-Type: application/http;msgtype=response\r\nContent-Length: {len(http)}\r\n\r\n" + ).encode() + http + b"\r\n\r\n" + return records + + monkeypatch.setattr(replay, "MAX_CAPTURE_BYTES", 50_000) + assert len(answers(3)) < 50_000 and len(Archive.read(answers(2))) == 2 + with pytest.raises(ValueError, match="answers are over 50000 bytes decoded"): + Archive.read(answers(3)) + + class _StyledSite(_Site): """A page whose stylesheet imports another and loads an image, a font, a data URI and another site's image.""" diff --git a/src/agent_env/task_step/task_steps/sign_in_browser.py b/src/agent_env/task_step/task_steps/sign_in_browser.py index e9c512f11..2af309270 100644 --- a/src/agent_env/task_step/task_steps/sign_in_browser.py +++ b/src/agent_env/task_step/task_steps/sign_in_browser.py @@ -38,6 +38,7 @@ REPRODUCIBLE_KEY = "reproducible" SIGN_INS_KEY = "browser_sign_ins" _ERROR_CHARS = 500 +SIGN_IN_TIMEOUT_SECONDS = 30 @dataclass(frozen=True) @@ -157,7 +158,7 @@ def mark_live(context: TaskStepContext, deployed: Any) -> dict: async def sign_in(context: TaskStepContext, deployed: Any, *, users: Optional[dict[str, str]] = None, sites: Optional[list[str]] = None, browser: Optional[str] = None, clear: bool = True, - timeout: int = 30) -> dict: + timeout: int = SIGN_IN_TIMEOUT_SECONDS) -> dict: """Sign ``deployed``'s browser in to its live websites (those named in ``sites``, else every one with a session), each as ``users[name]`` or the site's own choice; with ``clear``, a site's old cookies go first, on a browser that serves cookies/clear. Returns the record of what was set, cookies by everything but their values.""" @@ -254,7 +255,7 @@ async def sign_in_again(context: TaskStepContext, deployed: Any) -> None: try: context.metadata[SIGN_INS_KEY][step_id] = await sign_in( context, deployed, users=users, sites=[s["name"] for s in entry["sites"]], browser=entry.get("browser"), - timeout=entry.get("timeout_seconds") or 30) + timeout=entry.get("timeout_seconds") or SIGN_IN_TIMEOUT_SECONDS) except Exception as e: logger.warning("reset_env: signing env %s's browser in again failed: %s", deployed.env_id, e) entry["error"] = str(e)[:_ERROR_CHARS] @@ -272,7 +273,7 @@ class SignInBrowserTaskStep(TaskStep): def __init__(self, id: str, version: Optional[int], env_id: str, users: Optional[dict[str, str]] = None, sites: Optional[list[str]] = None, browser: Optional[str] = None, depends_on: Optional[list[TaskStepDependency]] = None, fail_task_on_error: bool = True, - timeout_seconds: int = 30, env_step_id: Optional[str] = None): + timeout_seconds: int = SIGN_IN_TIMEOUT_SECONDS, env_step_id: Optional[str] = None): super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error) if not env_id: raise ValueError("sign_in_browser needs the env_id of the env whose browser it signs in") @@ -293,7 +294,7 @@ def to_dict(self) -> dict: def from_dict(cls, data: dict) -> SignInBrowserTaskStep: return cls(**cls._base_from_dict(data), env_id=data["env_id"], users=data.get("users"), sites=data.get("sites"), browser=data.get("browser"), - timeout_seconds=data.get("timeout_seconds", 30), env_step_id=data.get("env_step_id")) + timeout_seconds=data.get("timeout_seconds", SIGN_IN_TIMEOUT_SECONDS), env_step_id=data.get("env_step_id")) async def execute(self, context: TaskStepContext) -> TaskStepContext: deployed = deployment(context, self.env_id, self.env_step_id, self.id) From 61ee22a28feca3b7bccb30a7025c44902414f248 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 18:25:35 -0800 Subject: [PATCH 38/57] refactor(website): a replayed or live site serves no /go and answers no state; data/get alone (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/replay.py | 4 ++-- .../tests/test_website_live.py | 19 ++++--------------- .../tests/test_website_replay.py | 8 -------- 3 files changed, 6 insertions(+), 25 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py index d05f766bf..955aaabb7 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py @@ -54,7 +54,7 @@ from ..agent_env_environment import add_data, reset_data from ..types import FilePart -from .environment import SESSION_PATH, STATE_PATH, WebsiteEnvironment, default_store_hook, website +from .environment import SESSION_PATH, WebsiteEnvironment, default_store_hook, website from .errors import NotFound from .routes import error_response, reserved_path @@ -457,7 +457,7 @@ def create_app(self) -> Starlette: if declaration.static is not None or declaration.live: raise ValueError(f"{type(self).__name__} serves its capture, so it takes no static folder and isn't live") asgi = super().create_app() - built_in = {SESSION_PATH: self._session, STATE_PATH: self._state} + built_in = {SESSION_PATH: self._session} paths = {r.path for r in asgi.router.routes if isinstance(r, Route) and built_in.get(r.path) == r.endpoint} replay = _Replay(self, type(self).app) asgi.router.routes.insert(0, _Captured(self, paths, replay)) diff --git a/packages/agentenv-protocol/tests/test_website_live.py b/packages/agentenv-protocol/tests/test_website_live.py index a58366df6..0bd6d0ac4 100644 --- a/packages/agentenv-protocol/tests/test_website_live.py +++ b/packages/agentenv-protocol/tests/test_website_live.py @@ -8,9 +8,9 @@ from starlette.testclient import TestClient from agentenv_protocol import DataPart, add_data, get_data, reset_data -from agentenv_protocol.client import find_extension, find_extension_method -from agentenv_protocol.website import (WEBSITE_EXTENSION_URI, WEBSITE_STATE_OP, Cookie, WebsiteEnvironment, api, - ready_check, session, website) +from agentenv_protocol.client import find_extension +from agentenv_protocol.website import (WEBSITE_EXTENSION_URI, Cookie, WebsiteEnvironment, api, ready_check, session, + website) from agentenv_protocol.website.browser import cookies, devtools, redacted, storage_state SESSION = "/agentenv/ext/website/session" @@ -54,7 +54,7 @@ def client(monkeypatch) -> TestClient: def test_a_live_site_serves_no_pages_only_its_health_data_plane_and_card(client): - for path in ("/", "/login", "/api/session", "/go"): + for path in ("/", "/login", "/api/session"): assert client.get(path).status_code == 404, path assert client.get("/api/health").status_code == 200 card = client.get("/.well-known/agent-env.json").json() @@ -93,17 +93,6 @@ class Plain(WebsiteEnvironment): assert answer.status_code == 501 and answer.json()["error"]["code"] == "no_session" -def test_a_live_site_keeping_the_default_store_answers_no_state(): - @website(domains=["example.com"], live=True) - class Plain(WebsiteEnvironment): - pass - - client = TestClient(Plain().create_app()) - card = client.get("/.well-known/agent-env.json").json() - assert find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_STATE_OP) is None - assert client.get("/agentenv/ext/website/state").status_code == 404 and client.get("/go").status_code == 404 - - def test_a_session_method_returning_a_bad_cookie_is_refused_without_its_value(): @website(domains=["example.com"], live=True) class Broken(WebsiteEnvironment): diff --git a/packages/agentenv-protocol/tests/test_website_replay.py b/packages/agentenv-protocol/tests/test_website_replay.py index 5bab3b872..448ef63cc 100644 --- a/packages/agentenv-protocol/tests/test_website_replay.py +++ b/packages/agentenv-protocol/tests/test_website_replay.py @@ -211,7 +211,6 @@ def test_api_routes_win_over_the_capture_and_the_capture_over_built_in_routes(cl def test_the_data_plane_exports_the_overrides_state(client): assert client.post("/api/cart", json={"sku": "lamp"}).json() == {"sku": "lamp", "id": 1} assert rpc(client, "data/get")["parts"][0]["data"] == {"cart": [{"sku": "lamp", "id": 1}]} - assert client.get("/go").json()["state_diff"]["cart"]["added"] == [{"sku": "lamp", "id": 1}] rpc(client, "data/reset") assert rpc(client, "data/get")["parts"][0]["data"] == {} @@ -307,13 +306,6 @@ def load(capture: bytes) -> None: rpc(client, "data/reset") assert client.get("/api/session").json() == {"captured": True} - empty = TestClient(Shop(warc()).create_app()) - assert empty.get("/go").json()["state_diff"] == {} - rpc(empty, "data/add", {"parts": [{"kind": "file", "file": {"bytes": base64.b64encode(warc( - ("https://shop.example/go", 200, [("Content-Type", "application/json")], b'{"captured go": 1}'))).decode(), - "name": "go.warc"}}]}) - assert empty.get("/go").json() == {"captured go": 1} - class Rows: """Data hooks of a models site, a list of rows.""" From 956163f2f9e6c7e11e7983c56d9e5e74fc7c69d5 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 20:09:11 -0800 Subject: [PATCH 39/57] fix(website): a replay records every host it captured, and its example reads sign-ups through data/get (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agentenv-protocol/examples/website/replay/README.md | 2 +- packages/agentenv-protocol/examples/website/replay/site.py | 2 +- .../src/agentenv_protocol/website/replay.py | 7 ++++--- packages/agentenv-protocol/tests/test_website_replay.py | 6 ++++++ 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/replay/README.md b/packages/agentenv-protocol/examples/website/replay/README.md index f41fd1bb0..b6c48e3ba 100644 --- a/packages/agentenv-protocol/examples/website/replay/README.md +++ b/packages/agentenv-protocol/examples/website/replay/README.md @@ -62,7 +62,7 @@ When the capture's answers are served: ## Synthetic routes on top `@api` routes win over the capture. Here `POST /api/subscribe` keeps sign-ups in the default store. The data plane -exports that store, `data/reset` empties it, and `/go` shows a run's changes for the website state verifier. The +exports that store through `data/get`, which is how a run's sign-ups are read, and `data/reset` empties it. The capture itself isn't task state. A `data/add` file part named `*.warc` or `*.warc.gz` (or typed `application/warc`) replaces the declared capture until the next `data/reset`. This lets a large capture be loaded at deploy time instead of being built into the image. diff --git a/packages/agentenv-protocol/examples/website/replay/site.py b/packages/agentenv-protocol/examples/website/replay/site.py index 09f37cd20..404fc1d5f 100644 --- a/packages/agentenv-protocol/examples/website/replay/site.py +++ b/packages/agentenv-protocol/examples/website/replay/site.py @@ -1,6 +1,6 @@ """Widget Docs, replayed: the pages of a captured docs site, served as they were captured for any host, with the newsletter sign-up the capture can't answer written as ``@api`` routes on top. The sign-ups are the site's state: the -data plane exports them, and ``/go`` shows what a run changed.""" +data plane's ``data/get`` exports them.""" from __future__ import annotations from pydantic import BaseModel diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py index 955aaabb7..b1f1aad90 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py @@ -121,14 +121,15 @@ def __len__(self) -> int: return len(self.entries) def add(self, entry: Entry) -> bool: - """Keep ``entry`` unless the archive holds an answer to its request already; whether it was kept.""" + """Keep ``entry`` unless the archive holds an answer to its request already, recording its host either way, so + links to every captured host stay on the replay; whether it was kept.""" url = urlsplit(entry.url) + if url.hostname: + self.hosts.add(url.hostname.lower()) key = request_key(entry.method, url.path, url.query, self.ignore_query) if key in self.entries: return False self.entries[key] = entry - if url.hostname: - self.hosts.add(url.hostname.lower()) return True def find(self, method: str, path: str, query: str = "") -> Optional[Entry]: diff --git a/packages/agentenv-protocol/tests/test_website_replay.py b/packages/agentenv-protocol/tests/test_website_replay.py index 448ef63cc..dbc54654b 100644 --- a/packages/agentenv-protocol/tests/test_website_replay.py +++ b/packages/agentenv-protocol/tests/test_website_replay.py @@ -380,6 +380,12 @@ def test_the_example_serves_its_capture_and_its_sign_up(): exported = rpc(client, "data/get")["parts"][0]["data"] assert [s["email"] for s in exported["subscriptions"]] == ["ann@example.com"] +def test_an_answer_kept_once_still_records_each_host_it_was_captured_from() -> None: + archive = Archive([Entry("GET", "https://example.com/", 200, (), b"apex"), + Entry("GET", "https://www.example.com/", 200, (), b"www")]) + assert archive.find("GET", "/").body == b"apex" and archive.hosts == {"example.com", "www.example.com"} + + def test_an_entry_is_kept_once_per_request(): archive = Archive([Entry("GET", "https://a.example/x?b=1&a=2", 200, (), b"first"), From 46566a58694d97954f9f65fd2cad5d3312fdf4af Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 21:15:54 -0800 Subject: [PATCH 40/57] fix(website): a wildcard site domain covers its names when a browser's cookies are cleared (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/browser.py | 7 +++++-- packages/agentenv-protocol/tests/test_website_live.py | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/browser.py b/packages/agentenv-protocol/src/agentenv_protocol/website/browser.py index 51d9928b2..36fd6a243 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/browser.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/browser.py @@ -41,9 +41,12 @@ def matches(self, domains: Iterable[str]) -> bool: def domain_matches(domain: str, domains: Iterable[str]) -> bool: - """Whether a cookie's ``domain`` is one of ``domains`` or a subdomain of one, leading dots aside.""" + """Whether a cookie's ``domain`` is one of ``domains`` or a subdomain of one, leading dots aside; a wildcard + domain as ``@website`` declares it, ``*.example.com``, covers ``example.com`` and every name under it, since a + cookie for ``example.com`` is sent to those names too.""" host = domain.lstrip(".").lower() - return any(host == d or host.endswith("." + d) for d in (d.lstrip(".").lower() for d in domains)) + bases = (d.lower().removeprefix("*.").lstrip(".") for d in domains) + return any(host == d or host.endswith("." + d) for d in bases) def cookies(raw: Any, default_domain: Optional[str] = None) -> list[Cookie]: diff --git a/packages/agentenv-protocol/tests/test_website_live.py b/packages/agentenv-protocol/tests/test_website_live.py index 0bd6d0ac4..448118b72 100644 --- a/packages/agentenv-protocol/tests/test_website_live.py +++ b/packages/agentenv-protocol/tests/test_website_live.py @@ -168,6 +168,9 @@ def test_a_cookie_is_kept_without_its_value_and_given_to_a_browser_in_its_own_sh assert Cookie(name="a", value="", domain=".A.example").matches(["a.example"]) assert Cookie(name="a", value="", domain="sub.a.example").matches(["a.example"]) assert not Cookie(name="a", value="", domain="nota.example").matches(["a.example"]) + for domain in ("app.example.com", ".example.com", "deep.app.example.com"): + assert Cookie(name="a", value="", domain=domain).matches(["*.example.com"]), domain + assert not Cookie(name="a", value="", domain="badexample.com").matches(["*.example.com"]) def test_a_website_browser_declares_the_browser_v1_whose_cookies_sign_in_browser_sets(): From d9da03488179c23258d1917ec79c2990286116f2 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 13:34:08 -0800 Subject: [PATCH 41/57] feat(computer): a computer reaches the run's websites by their names (FD-4683) This PR's change, rebased as one commit onto #447. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/__init__.py | 4 +- .../agentenv_protocol/website/readiness.py | 5 +- pyproject.toml | 1 + src/agent_env/env/env.py | 5 + src/agent_env/env/envs/computer/env.py | 14 +- .../env/envs/computer/utils/params.py | 31 +- .../env/envs/computer/utils/site_proxy.py | 852 ++++++++++++++++++ .../env/envs/computer/utils/sites.py | 460 ++++++++++ src/agent_env/env/gateway/constants.py | 3 + src/agent_env/env/gateway/gateway.py | 54 +- src/agent_env/env/gateway/requirements.txt | 1 + src/agent_env/env/gateway/rest_proxy.py | 143 ++- .../env_providers/env_computer_provider.py | 20 +- .../env_providers/env_gateway_provider.py | 4 +- .../task_step/task_steps/deploy_env.py | 2 +- tst/unit/env/envs/test_computer_env.py | 119 +++ tst/unit/env/envs/test_computer_site_proxy.py | 596 ++++++++++++ tst/unit/env/envs/test_computer_sites.py | 404 +++++++++ tst/unit/env/gateway/gateway_card_test.py | 22 + .../env/gateway/rest_proxy_stream_test.py | 14 + .../env/gateway/website_websocket_test.py | 214 +++++ .../env_gateway_provider_test.py | 34 + uv.lock | 2 + 23 files changed, 2973 insertions(+), 31 deletions(-) create mode 100644 src/agent_env/env/envs/computer/utils/site_proxy.py create mode 100644 src/agent_env/env/envs/computer/utils/sites.py create mode 100644 tst/unit/env/envs/test_computer_site_proxy.py create mode 100644 tst/unit/env/envs/test_computer_sites.py create mode 100644 tst/unit/env/gateway/website_websocket_test.py diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py index 77930540d..5b42aa827 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py @@ -25,7 +25,7 @@ website, ) from .errors import ApiError, BadRequest, Conflict, Forbidden, NotFound, Unauthorized -from .readiness import READY_TIMEOUT_S, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness, env_readiness, website_readiness +from .readiness import READY_TIMEOUT_S, WEBSITE_BROWSE_URL_PARAM, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness, env_readiness, website_readiness from .routes import ROLE_KEY_ENV, ROLE_KEY_HEADER, ApiContext, ApiRoute, api, reserved_path from .session import SESSION_COOKIE, SessionSigner from .spec import NameTable, SpecError, WebsiteSpec, from_card, from_environment, load as load_spec, name_table @@ -39,5 +39,5 @@ "WebsiteSpec", "SpecError", "NameTable", "load_spec", "from_card", "from_environment", "name_table", "WEBSITE_EXTENSION_URI", "CLOCK_EXTENSION_URI", "UNIVERSE_NOW_HEADER", "DEFAULT_HEALTH_PATH", "SESSION_PATH", "SESSION_COOKIE", "ROLE_KEY_ENV", "ROLE_KEY_HEADER", - "WEBSITE_READY_OP", "READY_TIMEOUT_S", "WebsiteReadiness", "website_readiness", "env_readiness", + "WEBSITE_READY_OP", "WEBSITE_BROWSE_URL_PARAM", "READY_TIMEOUT_S", "WebsiteReadiness", "website_readiness", "env_readiness", ] diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/readiness.py b/packages/agentenv-protocol/src/agentenv_protocol/website/readiness.py index 958f828e7..33fd31064 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/readiness.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/readiness.py @@ -2,7 +2,9 @@ it is usable now through its card's ``urn:agentenv:website/v1`` ``ready`` (``WEBSITE_READY_OP``), answering a ``WebsiteReadiness``. ``website_readiness`` asks one site as its card advertises it; ``env_readiness`` asks every site of an env, a composed card's children or a single site's own card, and answers for the env as a whole; asking one -site may take ``READY_TIMEOUT_S`` by default.""" +site may take ``READY_TIMEOUT_S`` by default. A composer serving a site to a browser at a route of its own, as the +env gateway serves ``/website//``, says where on the site's ``website/v1`` params, as +``WEBSITE_BROWSE_URL_PARAM``: a path joined onto the composed card's address, or a full URL.""" from __future__ import annotations import asyncio @@ -14,6 +16,7 @@ WEBSITE_EXTENSION_URI = "urn:agentenv:website/v1" WEBSITE_READY_OP = "ready" +WEBSITE_BROWSE_URL_PARAM = "browse_url" READY_TIMEOUT_S = 10.0 diff --git a/pyproject.toml b/pyproject.toml index 7b9931ff8..582993f73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,6 +159,7 @@ dev = [ "tomli-w==1.2.0", # the Codex agent's config writer, which only its image installs; its tests import it "moto>=5.0.0", "psycopg2-binary>=2.9.0", # the gateway server module's driver; outside its container only the tests import it + "websockets>=13", # the gateway's WebSocket relay to a website; outside its container only the tests import it "pytest>=9.0", "pytest-asyncio>=0.23.0", "pytest-dependency>=0.6.0", diff --git a/src/agent_env/env/env.py b/src/agent_env/env/env.py index 9e50ebabf..6dd6e2b19 100644 --- a/src/agent_env/env/env.py +++ b/src/agent_env/env/env.py @@ -421,6 +421,11 @@ def deploy_refusal(self, *, sandbox_type: Optional[str] = None, env_params: Opti overrides this; one that doesn't takes no ``env_params``.""" return f"{self.type} envs take no env_params" if env_params else None + async def run_env_params(self, env_params: Optional[dict], deployed_envs: list[DeployedEnv]) -> Optional[dict]: + """``env_params`` as a run's deploy_env passes them to deploy(), once the run has deployed ``deployed_envs``: + as given, unless the type reads something of what the run deployed into them.""" + return env_params + @classmethod async def from_deployed_env(cls, deployed: DeployedEnv) -> Self: """The env a running deployment serves, for its caller to drive. A type that can be diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index 5e36d9a8f..7ae348fab 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -27,7 +27,7 @@ deploy_through_provider, provider_or_class, ) -from agent_env.env.envs.computer.utils import boot, composer, gate, inside +from agent_env.env.envs.computer.utils import boot, composer, gate, inside, sites from agent_env.env.envs.computer.utils.composer import Composing from agent_env.env.envs.computer.utils import params as computer_params from agent_env.env.envs.computer.driver_env import ComputerDriverEnv @@ -248,6 +248,18 @@ async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, env_state_type=env_state_type, env_state_instance_id=env_state_instance_id, attribution=attribution, env_params=env_params) + async def run_env_params(self, env_params: Optional[dict], deployed_envs: list[DeployedEnv]) -> Optional[dict]: + """With ``site_routing`` and no ``sites``, ``env_params`` naming as its sites the websites the run deployed + before the computer: all of them, or those of the env ids ``site_routing`` lists.""" + routing = (env_params or {}).get("site_routing") + if not routing or env_params.get("sites"): + return env_params + found = await sites.deployed_sites(deployed_envs, None if routing is True else list(routing)) + if not found: + raise ValueError(f"computer env {self.id!r}: env_params.site_routing reaches the run's websites, and the " + "run deployed none before the computer") + return {**env_params, "sites": found} + def deploy_refusal(self, *, sandbox_type: str | None = None, env_params: Optional[dict] = None, **options) -> str | None: """Why deploy() would refuse before booting anything, or None: an option the computer provider doesn't take, a diff --git a/src/agent_env/env/envs/computer/utils/params.py b/src/agent_env/env/envs/computer/utils/params.py index c675a6364..7d9c8feff 100644 --- a/src/agent_env/env/envs/computer/utils/params.py +++ b/src/agent_env/env/envs/computer/utils/params.py @@ -1,22 +1,37 @@ """What a deploy of a computer env takes as ``deploy_env`` ``env_params``: the form to boot, ``computer_settings``, values of the base's deploy-phase settings, ``driver_settings``, the driver's ``ComputerSettings``, such as the default -coordinate space its tools describe, and ``app_settings``, values of each app's ``Settings`` by its name. The env's -display and network are its own, which no deploy overrides.""" +coordinate space its tools describe, ``app_settings``, values of each app's ``Settings`` by its name, and +``site_routing``, opt-in, to reach the run's websites by their names from the computer: its ``sites``, which deploy_env +fills in from the websites the run deployed before the computer, all of them or those of the env ids it lists. The +env's display and network are its own, which no deploy overrides.""" from __future__ import annotations -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Optional, Union from agentenv_protocol.computer import ComputerSettings -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, StrictBool, ValidationError from agent_env.artifact.artifacts.computer import SettingValue -from agent_env.env.envs.computer.utils import gate +from agent_env.env.envs.computer.utils import gate, sites if TYPE_CHECKING: from agent_env.env.envs.computer.env import ComputerEnv +class Site(BaseModel): + """A website the computer reaches by its names: its route on the env gateway, as this process reaches it, and what + its card's website/v1 declares: its names, and for a live site, which the real site serves, the hosts it calls.""" + + model_config = ConfigDict(extra="forbid") + + name: str + url: str + domains: list[str] = Field(default_factory=list) + live: bool = False + allow_hosts: list[str] = Field(default_factory=list) + + class ComputerParams(BaseModel): model_config = ConfigDict(extra="forbid") @@ -24,6 +39,8 @@ class ComputerParams(BaseModel): computer_settings: dict[str, Any] = Field(default_factory=dict) driver_settings: dict[str, Any] = Field(default_factory=dict) app_settings: dict[str, dict[str, Any]] = Field(default_factory=dict) + site_routing: Union[StrictBool, list[str]] = False + sites: list[Site] = Field(default_factory=list) def checked(env: ComputerEnv, env_params: Optional[dict]) -> tuple[ComputerParams, dict[str, SettingValue], list[str]]: @@ -55,6 +72,10 @@ def checked(env: ComputerEnv, env_params: Optional[dict]) -> tuple[ComputerParam merged = app_settings(env, params.app_settings) for name, app in apps.items(): problems += gate.check_app_settings(app, merged[name], f"env_params.app_settings.{name}", env.computer.os.type) + if params.sites and not params.site_routing: + problems.append("env_params.sites: a computer reaches sites only with site_routing") + allow_hosts = (env.network or {}).get("allow_hosts") or () + problems += sites.table_problems(params.sites, allow_hosts) if params.sites else [] return params, values, problems diff --git a/src/agent_env/env/envs/computer/utils/site_proxy.py b/src/agent_env/env/envs/computer/utils/site_proxy.py new file mode 100644 index 000000000..d7299919a --- /dev/null +++ b/src/agent_env/env/envs/computer/utils/site_proxy.py @@ -0,0 +1,852 @@ +"""The site proxy: inside a computer, it serves each website of the deployment under the names the site declares +(``https://notes.test/``), forwarding every request to the site's route on the env gateway +(``/website/notes/``), the only address the computer reaches the site at. It runs on the base's own +``python3``, so it uses the standard library alone. + +``install``, as root, maps each exact name to loopback in the hosts file, trusts the deployment's CA in the system +store, Firefox's policies, Chromium's managed policies and the session user's NSS databases, starts ``serve`` and checks, as the session user, that +each name loads. ``serve`` listens on loopback: on 443 for a name served over HTTPS, on 80 to redirect those to HTTPS +and to serve ``.localhost`` names over HTTP, and on 53 for DNS, over UDP and TCP, when a name is a wildcard, which a +hosts file can't list; it then drops to the session user. The site sees itself at ``/`` under its own name: the path goes upstream under +the route's prefix, ``X-Forwarded-Host`` and ``X-Forwarded-Proto`` say the name and scheme the browser used, +``AgentEnv-Site-Root`` has the gateway tell the site no prefix, so the links it makes are rooted at ``/`` too, and a +``Location`` or cookie naming the route is rewritten back to the name. Hop-by-hop headers and the browser's own +``X-Forwarded-*`` never go upstream; ``Transfer-Encoding`` does, since bodies are relayed as framed, and an +``Alt-Svc`` offering HTTP/3, which the proxy doesn't serve, never reaches the browser. A WebSocket's upgrade is +relayed, for an upstream that answers it.""" + +from __future__ import annotations + +import asyncio +import json +import os +import pwd +import shutil +import socket +import ssl +import struct +import subprocess +import sys +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional +from urllib.parse import urlsplit, urlunsplit + +HOSTS_BEGIN = "# agentenv sites" +HOSTS_END = "# end agentenv sites" +LOOPBACK = "127.0.0.1" +CA_NAME = "agentenv-sites" +NSS_NICKNAME = "agentenv sites" +HOP_BY_HOP = frozenset({"connection", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", + "te", "trailer", "upgrade", "host", "expect"}) +SITE_ROOT_HEADER = "AgentEnv-Site-Root" +FORWARDED = frozenset({"x-forwarded-host", "x-forwarded-proto", "x-forwarded-for", "x-forwarded-port", + "x-forwarded-prefix", "forwarded", SITE_ROOT_HEADER.lower()}) +RESPONSE_DROP = frozenset({"connection", "keep-alive", "proxy-connection", "alt-svc"}) +HEAD_LIMIT = 1 << 20 +CHUNK = 1 << 16 +UPSTREAM_TIMEOUT_S = 30.0 +DNS_TIMEOUT_S = 4.0 +NAMESERVER_PORT = 53 +LISTEN_TIMEOUT_S = 15.0 +CHECK_TIMEOUT_S = 15.0 +FAILED = (("X-Agentenv-Site-Proxy", "failed"),) + + +class BadRequest(Exception): + """A request the proxy can't read.""" + + +def is_local(name: str) -> bool: + """A ``.localhost`` name, served over HTTP: browsers treat it as secure without TLS.""" + return name == "localhost" or name.endswith(".localhost") + + +def matches(pattern: str, host: str) -> bool: + """Whether ``host`` is the name ``pattern`` names: itself, or for ``*.example.com`` one label more, as a + certificate's wildcard matches.""" + host = host.lower().rstrip(".") + if pattern.startswith("*."): + head, dot, rest = host.partition(".") + return bool(head) and bool(dot) and rest == pattern[2:] + return host == pattern + + +@dataclass +class Site: + """One site as the proxy serves it: its names, the route on the gateway it forwards to, and its certificate and key + files, for a site with a name served over HTTPS.""" + + name: str + upstream: str + domains: list[str] + cert: Optional[str] = None + key: Optional[str] = None + scheme: str = field(init=False) + host: str = field(init=False) + port: int = field(init=False) + prefix: str = field(init=False) + + def __post_init__(self) -> None: + parts = urlsplit(self.upstream) + if parts.scheme not in ("http", "https") or not parts.hostname: + raise ValueError(f"site {self.name}: its upstream {self.upstream!r} isn't an http(s) URL") + self.scheme, self.host = parts.scheme, parts.hostname + self.port = parts.port or (443 if parts.scheme == "https" else 80) + self.prefix = parts.path.rstrip("/") + + @property + def netloc(self) -> str: + default = 443 if self.scheme == "https" else 80 + return self.host if self.port == default else f"{self.host}:{self.port}" + + def tls(self, name: str) -> bool: + return not is_local(name) + + +@dataclass +class Config: + """What ``serve`` reads: the session user it drops to, its ports, the sites and, with a wildcard name, the + nameservers it forwards every other question to.""" + + user: Optional[str] + sites: list[Site] + listen: str = LOOPBACK + https_port: int = 443 + http_port: int = 80 + dns_port: Optional[int] = None + nameservers: list[str] = field(default_factory=list) + + @classmethod + def load(cls, path: str) -> Config: + data = json.loads(Path(path).read_text()) + sites = [Site(name=s["name"], upstream=s["upstream"], domains=list(s["domains"]), cert=s.get("cert"), + key=s.get("key")) for s in data["sites"]] + return cls(user=data.get("user"), sites=sites, listen=data.get("listen", LOOPBACK), + https_port=data.get("https_port", 443), http_port=data.get("http_port", 80), + dns_port=data.get("dns_port"), nameservers=list(data.get("nameservers") or [])) + + def site_for(self, host: str) -> Optional[Site]: + """The site ``host`` names: one naming it exactly before one whose wildcard covers it.""" + host = host.lower().rstrip(".") + for exact in (True, False): + for site in self.sites: + if any(matches(name, host) for name in site.domains if name.startswith("*.") != exact): + return site + return None + + +def header(headers: list[tuple[str, str]], name: str) -> Optional[str]: + return next((value for key, value in headers if key.lower() == name), None) + + +def tokens(headers: list[tuple[str, str]], name: str) -> set[str]: + return {token.strip().lower() for key, value in headers if key.lower() == name for token in value.split(",")} + + +def parse_head(data: bytes) -> tuple[str, list[tuple[str, str]]]: + """A message head's start line and its headers, in order.""" + lines = data.decode("latin-1").removesuffix("\r\n\r\n").split("\r\n") + headers = [] + for line in lines[1:]: + key, colon, value = line.partition(":") + if not colon or not key or key != key.strip(): + raise BadRequest(f"a malformed header line {line!r}") + headers.append((key, value.strip())) + return lines[0], headers + + +def split_host(host: str) -> str: + """The name of a ``Host`` value, without its port.""" + if host.startswith("["): + return host[1:].partition("]")[0] + return host.rpartition(":")[0] if host.count(":") == 1 else host + + +def origin(scheme: str, netloc: str) -> tuple[str, str, int]: + """A URL's scheme, name and port, its port the scheme's own when it names none.""" + parts = urlsplit(f"//{netloc}") + scheme = scheme.lower() + return scheme, (parts.hostname or "").lower(), parts.port or (443 if scheme == "https" else 80) + + +def public_location(location: str, site: Site, scheme: str, host: str) -> str: + """A ``Location`` the upstream sent, as the browser follows it: one on the site's route, under the gateway's address, + under the site's own name (as a site that ignores ``AgentEnv-Site-Root`` may send) or as a path, is rewritten onto + the site's name; any other is left alone.""" + parts = urlsplit(location) + if parts.scheme or parts.netloc: + if origin(parts.scheme, parts.netloc) not in (origin(site.scheme, site.netloc), origin(scheme, host)): + return location + elif not location.startswith("/") or location.startswith("//"): + return location + path = parts.path + if not (path == site.prefix or path.startswith(site.prefix + "/")) or not site.prefix: + return location + rest = path[len(site.prefix):] or "/" + origin_parts = (scheme, host) if parts.netloc else ("", "") + return urlunsplit((*origin_parts, rest, parts.query, parts.fragment)) + + +def public_cookie(value: str, site: Site) -> str: + """A ``Set-Cookie`` the upstream sent, with any ``Domain`` naming the gateway dropped and a ``Path`` under the route + rooted at ``/``, so the browser keeps it for the site's name.""" + first, *attributes = value.split(";") + kept = [first] + for attribute in attributes: + key, _, attr_value = attribute.strip().partition("=") + if key.lower() == "domain" and attr_value.strip().lstrip(".").lower() == site.host.lower(): + continue + if key.lower() == "path" and site.prefix and (attr_value == site.prefix + or attr_value.startswith(site.prefix + "/")): + attribute = f" Path={attr_value[len(site.prefix):] or '/'}" + kept.append(attribute) + return ";".join(kept) + + +def upstream_request(method: str, target: str, headers: list[tuple[str, str]], site: Site, *, scheme: str, + host: str, peer: str, upgrade: Optional[str]) -> bytes: + """The request head sent to the site's route: the path under its prefix, the gateway as ``Host``, the browser's + name and scheme in ``X-Forwarded-Host`` and ``X-Forwarded-Proto``, ``AgentEnv-Site-Root`` so the site is told it + is at ``/``, and a connection that closes after it, or upgrades when the browser asked to.""" + if not target.startswith("/"): + parts = urlsplit(target) + target = urlunsplit(("", "", parts.path or "/", parts.query, "")) + kept = [(key, value) for key, value in headers if key.lower() not in HOP_BY_HOP | FORWARDED] + kept += [("Host", site.netloc), ("X-Forwarded-Host", host), ("X-Forwarded-Proto", scheme), + ("X-Forwarded-For", peer), (SITE_ROOT_HEADER, "1")] + kept += [("Connection", "Upgrade"), ("Upgrade", upgrade)] if upgrade else [("Connection", "close")] + lines = [f"{method} {site.prefix}{target} HTTP/1.1", *(f"{key}: {value}" for key, value in kept)] + return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1") + + +def response_head(status_line: str, headers: list[tuple[str, str]], site: Site, *, scheme: str, host: str, + connection: str) -> bytes: + _, _, status = status_line.partition(" ") + kept = [] + for key, value in headers: + lower = key.lower() + if lower in RESPONSE_DROP or (lower == "upgrade" and connection != "Upgrade"): + continue + if lower == "location": + value = public_location(value, site, scheme, host) + elif lower == "set-cookie": + value = public_cookie(value, site) + kept.append((key, value)) + kept.append(("Connection", connection)) + lines = [f"HTTP/1.1 {status}", *(f"{key}: {value}" for key, value in kept)] + return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1") + + +def simple_response(status: str, body: str, extra: tuple[tuple[str, str], ...] = ()) -> bytes: + data = body.encode() + lines = [f"HTTP/1.1 {status}", "Content-Type: text/plain; charset=utf-8", f"Content-Length: {len(data)}", + *(f"{key}: {value}" for key, value in extra), "Connection: close"] + return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1") + data + + +async def read_head(reader: asyncio.StreamReader) -> Optional[bytes]: + """The next message head, or None when the connection closed before one began.""" + try: + return await reader.readuntil(b"\r\n\r\n") + except asyncio.IncompleteReadError as e: + if not e.partial.strip(): + return None + raise BadRequest("the connection closed mid-head") from None + except asyncio.LimitOverrunError: + raise BadRequest("a head over the limit") from None + + +async def relay_exact(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, size: int) -> None: + while size > 0: + data = await reader.read(min(size, CHUNK)) + if not data: + raise BadRequest("the body ended early") + writer.write(data) + await writer.drain() + size -= len(data) + + +async def relay_chunked(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + """A chunked body relayed as framed, to its last chunk and trailers.""" + while True: + line = await reader.readuntil(b"\r\n") + writer.write(line) + try: + size = int(line.split(b";", 1)[0].strip(), 16) + except ValueError: + raise BadRequest(f"a malformed chunk size {line!r}") from None + if size == 0: + while (trailer := await reader.readuntil(b"\r\n")) != b"\r\n": + writer.write(trailer) + writer.write(trailer) + await writer.drain() + return + await relay_exact(reader, writer, size + 2) + + +async def relay_until_eof(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + while data := await reader.read(CHUNK): + writer.write(data) + await writer.drain() + + +async def relay_body(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, headers: list[tuple[str, str]], + *, to_eof: bool) -> None: + if "chunked" in tokens(headers, "transfer-encoding"): + await relay_chunked(reader, writer) + elif (length := header(headers, "content-length")) is not None: + try: + await relay_exact(reader, writer, int(length)) + except ValueError: + raise BadRequest(f"a malformed Content-Length {length!r}") from None + elif to_eof: + await relay_until_eof(reader, writer) + + +async def splice(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + """One direction of an upgraded connection, closing the other side when it ends, which ends the other direction.""" + try: + await relay_until_eof(reader, writer) + except (ConnectionError, OSError, ssl.SSLError): + pass + finally: + writer.close() + + +class SiteProxy: + """The proxy's HTTP side: one connection's requests, each forwarded on a connection of its own to its site's + route.""" + + def __init__(self, config: Config): + self.config = config + self.upstream_tls = ssl.create_default_context() + + async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, scheme: str) -> None: + peer = (writer.get_extra_info("peername") or (LOOPBACK,))[0] + try: + while await self.exchange(reader, writer, scheme, peer): + pass + except BadRequest as e: + writer.write(simple_response("400 Bad Request", f"{e}\n")) + except (ConnectionError, asyncio.IncompleteReadError, ssl.SSLError, OSError): + pass + except Exception as e: + print(f"site proxy: a {scheme} connection failed: {type(e).__name__}: {e}", file=sys.stderr, flush=True) + finally: + try: + await writer.drain() + writer.close() + await writer.wait_closed() + except (ConnectionError, OSError, ssl.SSLError): + pass + + async def exchange(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, scheme: str, + peer: str) -> bool: + """One request and its answer; whether the connection takes another.""" + head = await read_head(reader) + if head is None: + return False + start, headers = parse_head(head) + try: + method, target, version = start.split(" ") + except ValueError: + raise BadRequest(f"a malformed request line {start!r}") from None + host = header(headers, "host") or (urlsplit(target).netloc if "://" in target else "") + site = self.config.site_for(split_host(host)) if host else None + if site is None: + writer.write(simple_response("421 Misdirected Request", f"{host or 'no host'} is no site of this " + "computer\n", FAILED)) + return False + if scheme == "http" and site.tls(split_host(host).lower()): + location = f"https://{split_host(host)}{target if target.startswith('/') else urlsplit(target).path}" + writer.write(simple_response("308 Permanent Redirect", f"{location}\n", (("Location", location),))) + return False + upgrade = header(headers, "upgrade") if "upgrade" in tokens(headers, "connection") else None + if "100-continue" in tokens(headers, "expect"): + writer.write(b"HTTP/1.1 100 Continue\r\n\r\n") + try: + up_reader, up_writer = await asyncio.wait_for(asyncio.open_connection( + site.host, site.port, ssl=self.upstream_tls if site.scheme == "https" else None, + server_hostname=site.host if site.scheme == "https" else None, limit=HEAD_LIMIT), UPSTREAM_TIMEOUT_S) + except (OSError, asyncio.TimeoutError, ssl.SSLError) as e: + writer.write(simple_response("502 Bad Gateway", f"the site {site.name} can't be reached at " + f"{site.upstream}: {type(e).__name__}: {e}\n", FAILED)) + return False + try: + return await self._forward(reader, writer, up_reader, up_writer, method, target, version, headers, site, + scheme=scheme, host=host, peer=peer, upgrade=upgrade) + finally: + up_writer.close() + + async def _forward(self, reader, writer, up_reader, up_writer, method, target, version, headers, site, *, + scheme, host, peer, upgrade) -> bool: + up_writer.write(upstream_request(method, target, headers, site, scheme=scheme, host=host, peer=peer, + upgrade=upgrade)) + await relay_body(reader, up_writer, headers, to_eof=False) + await up_writer.drain() + while True: + answer = await read_head(up_reader) + if answer is None: + writer.write(simple_response("502 Bad Gateway", f"the site {site.name} closed without answering\n", + FAILED)) + return False + status_line, answer_headers = parse_head(answer) + code = int(status_line.split(" ")[1]) + if code == 101 and upgrade: + writer.write(response_head(status_line, answer_headers, site, scheme=scheme, host=host, + connection="Upgrade")) + await writer.drain() + await asyncio.gather(splice(reader, up_writer), splice(up_reader, writer)) + return False + if 100 <= code < 200: + continue + break + bodiless = method == "HEAD" or code in (204, 304) + framed = bodiless or "chunked" in tokens(answer_headers, "transfer-encoding") \ + or header(answer_headers, "content-length") is not None + keep = framed and version == "HTTP/1.1" and "close" not in tokens(headers, "connection") + writer.write(response_head(status_line, answer_headers, site, scheme=scheme, host=host, + connection="keep-alive" if keep else "close")) + if not bodiless: + try: + await relay_body(up_reader, writer, answer_headers, to_eof=True) + except (BadRequest, asyncio.IncompleteReadError): + return False + await writer.drain() + return keep + + +class Resolver(asyncio.DatagramProtocol): + """DNS on loopback for a wildcard name, over UDP and over TCP, which a client retries a truncated answer on and + ``options use-vc`` asks for: each question for a name of a site is answered with loopback, and every other is passed + to the computer's own nameservers over the transport it came on, so the rest of the network is as it was; with none + (a computer whose resolv.conf named no other), every other question fails at once rather than going unanswered.""" + + def __init__(self, config: Config): + self.config = config + self.transport: Optional[asyncio.DatagramTransport] = None + + def connection_made(self, transport) -> None: + self.transport = transport + + def datagram_received(self, data: bytes, addr) -> None: + asyncio.ensure_future(self._answer(data, addr)) + + async def _answer(self, data: bytes, addr) -> None: + reply = await self.answer(data, stream=False) + if reply and self.transport is not None: + self.transport.sendto(reply, addr) + + async def handle_stream(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + """A TCP connection's queries, each framed by its two-byte length, answered in turn until it closes.""" + try: + while True: + try: + (size,) = struct.unpack("!H", await reader.readexactly(2)) + except asyncio.IncompleteReadError: + return + reply = await self.answer(await reader.readexactly(size), stream=True) + if not reply: + return + writer.write(struct.pack("!H", len(reply)) + reply) + await writer.drain() + except (ConnectionError, OSError, asyncio.IncompleteReadError): + pass + finally: + writer.close() + + async def answer(self, data: bytes, *, stream: bool) -> Optional[bytes]: + question = parse_question(data) + if question is not None and self.config.site_for(question[0]) is not None: + return local_answer(data, question) + if not self.config.nameservers: + return failed_answer(data, question) if question is not None else None + return await (self._passed_on_stream(data) if stream else self._passed_on(data)) + + async def _passed_on(self, data: bytes) -> Optional[bytes]: + loop = asyncio.get_running_loop() + for nameserver in self.config.nameservers: + family = socket.AF_INET6 if ":" in nameserver else socket.AF_INET + with socket.socket(family, socket.SOCK_DGRAM) as sock: + sock.setblocking(False) + try: + await loop.sock_connect(sock, (nameserver, NAMESERVER_PORT)) + await loop.sock_sendall(sock, data) + return await asyncio.wait_for(loop.sock_recv(sock, 4096), DNS_TIMEOUT_S) + except (OSError, asyncio.TimeoutError): + continue + return None + + async def _passed_on_stream(self, data: bytes) -> Optional[bytes]: + for nameserver in self.config.nameservers: + try: + return await asyncio.wait_for(self._ask_stream(nameserver, data), DNS_TIMEOUT_S) + except (OSError, asyncio.TimeoutError, asyncio.IncompleteReadError): + continue + return None + + @staticmethod + async def _ask_stream(nameserver: str, data: bytes) -> bytes: + reader, writer = await asyncio.open_connection(nameserver, NAMESERVER_PORT) + try: + writer.write(struct.pack("!H", len(data)) + data) + await writer.drain() + (size,) = struct.unpack("!H", await reader.readexactly(2)) + return await reader.readexactly(size) + finally: + writer.close() + + +def parse_question(data: bytes) -> Optional[tuple[str, int, int]]: + """A query's one question, as (name, type, where it ends), or None for anything else.""" + if len(data) < 12: + return None + _, flags, questions = struct.unpack("!HHH", data[:6]) + if flags & 0x8000 or questions != 1: + return None + labels, at = [], 12 + while at < len(data) and (length := data[at]): + if length & 0xC0: + return None + labels.append(data[at + 1:at + 1 + length].decode("ascii", "replace")) + at += 1 + length + if at + 5 > len(data): + return None + (qtype,) = struct.unpack("!H", data[at + 1:at + 3]) + return ".".join(labels).lower(), qtype, at + 5 + + +def local_answer(query: bytes, question: tuple[str, int, int]) -> bytes: + """The answer naming loopback for an A question about a site's name, and no records for any other type, so a + client asking for IPv6 falls back to it.""" + _, qtype, end = question + (ident, flags) = struct.unpack("!HH", query[:4]) + answers = 1 if qtype in (1, 255) else 0 + head = struct.pack("!HHHHHH", ident, 0x8480 | (flags & 0x0100), 1, answers, 0, 0) + record = struct.pack("!HHHIH", 0xC00C, 1, 1, 60, 4) + socket.inet_aton(LOOPBACK) if answers else b"" + return head + query[12:end] + record + + +def failed_answer(query: bytes, question: tuple[str, int, int]) -> bytes: + """The answer saying the server failed (SERVFAIL), for a question it has no nameserver to pass on to.""" + (ident, flags) = struct.unpack("!HH", query[:4]) + return struct.pack("!HHHHHH", ident, 0x8082 | (flags & 0x0100), 1, 0, 0, 0) + query[12:question[2]] + + +def tls_context(config: Config) -> Optional[ssl.SSLContext]: + """The HTTPS side's context: each site's certificate, picked by the name the browser asks for.""" + contexts = {} + for site in config.sites: + if site.cert and site.key: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.set_alpn_protocols(["http/1.1"]) + context.load_cert_chain(site.cert, site.key) + contexts[site.name] = context + if not contexts: + return None + default = next(iter(contexts.values())) + + def pick(connection: ssl.SSLObject, name: Optional[str], _: ssl.SSLContext) -> None: + site = config.site_for(name) if name else None + if site is not None and site.name in contexts: + connection.context = contexts[site.name] + + default.sni_callback = pick + return default + + +async def serve(config: Config) -> None: + """Listen, drop to the session user, and serve until cancelled.""" + proxy = SiteProxy(config) + servers = [] + if (context := tls_context(config)) is not None: + servers.append(await asyncio.start_server(lambda r, w: proxy.handle(r, w, "https"), config.listen, + config.https_port, ssl=context, limit=HEAD_LIMIT)) + servers.append(await asyncio.start_server(lambda r, w: proxy.handle(r, w, "http"), config.listen, + config.http_port, limit=HEAD_LIMIT)) + transport = None + if config.dns_port: + resolver = Resolver(config) + transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( + lambda: resolver, local_addr=(config.listen, config.dns_port)) + servers.append(await asyncio.start_server(resolver.handle_stream, config.listen, config.dns_port)) + drop_privileges(config.user) + try: + await asyncio.gather(*(server.serve_forever() for server in servers)) + finally: + if transport is not None: + transport.close() + + +def drop_privileges(user: Optional[str]) -> None: + if user is None or os.geteuid() != 0: + return + entry = pwd.getpwnam(user) + os.setgroups([]) + os.setgid(entry.pw_gid) + os.setuid(entry.pw_uid) + + +@dataclass(frozen=True) +class Paths: + """Where ``install`` writes, under ``root``, by the conventions of Debian-family and Red Hat-family Linux.""" + + root: Path = Path("/") + + @property + def hosts(self) -> Path: + return self.root / "etc/hosts" + + @property + def resolv(self) -> Path: + return self.root / "etc/resolv.conf" + + @property + def anchors(self) -> tuple[tuple[Path, list[str]], ...]: + """Each system store's anchor file, with the tool that rebuilds the store from its anchors.""" + return ((self.root / f"usr/local/share/ca-certificates/{CA_NAME}.crt", ["update-ca-certificates"]), + (self.root / f"etc/pki/ca-trust/source/anchors/{CA_NAME}.pem", ["update-ca-trust", "extract"])) + + @property + def firefox_policies(self) -> Path: + return self.root / "etc/firefox/policies/policies.json" + + @property + def chromium_policies(self) -> tuple[Path, ...]: + """Where each Chromium build reads managed policies: Chrome for Testing, Chromium, a snap's Chromium and + Chrome.""" + return tuple(self.root / folder / f"policies/managed/{CA_NAME}.json" + for folder in ("etc/opt/chrome_for_testing", "etc/chromium", "etc/chromium-browser", + "etc/opt/chrome")) + + @property + def profile(self) -> Path: + return self.root / f"etc/profile.d/{CA_NAME}.sh" + + @property + def system_bundle(self) -> Path: + return self.root / "etc/ssl/certs/ca-certificates.crt" + + +def hosts_text(current: str, names: list[str]) -> str: + """``current``, a hosts file, with its block of the sites' names replaced by one mapping ``names`` to loopback.""" + lines, inside = [], False + for line in current.splitlines(): + if line.strip() == HOSTS_BEGIN: + inside = True + elif line.strip() == HOSTS_END: + inside = False + elif not inside: + lines.append(line) + if names: + lines += [HOSTS_BEGIN, *(f"{LOOPBACK} {name}" for name in names), HOSTS_END] + return "\n".join(lines) + "\n" + + +def resolv_text(current: str) -> tuple[str, list[str]]: + """``current``, a resolv.conf, asking loopback first, and the nameservers it named, which loopback passes the rest + to.""" + nameservers = [line.split()[1] for line in current.splitlines() + if line.split()[:1] == ["nameserver"] and len(line.split()) > 1] + passed = [ns for ns in nameservers if ns != LOOPBACK] + kept = [line for line in current.splitlines() if line.split()[:1] != ["nameserver"]] + return "\n".join([f"nameserver {LOOPBACK}", *kept]) + "\n", passed + + +def firefox_policies(current: Optional[str], ca: str) -> str: + """Firefox's policies file with ``ca`` among the certificates it installs, every other policy kept.""" + data = json.loads(current) if current and current.strip() else {} + installs = data.setdefault("policies", {}).setdefault("Certificates", {}).setdefault("Install", []) + if ca not in installs: + installs.append(ca) + return json.dumps(data, indent=2) + "\n" + + +def chromium_policies(ca_pem: str) -> str: + """A Chromium managed policy trusting ``ca_pem`` (``CACertificates``, base64 DER, which is a PEM's body), which + Chromium reads without ``certutil`` or an NSS database.""" + body = "".join(line.strip() for line in ca_pem.splitlines() if line.strip() and not line.startswith("-----")) + return json.dumps({"CACertificates": [body]}, indent=1) + "\n" + + +def write_private(path: Path, text: str) -> None: + """Write ``path`` readable by its owner alone, from its first byte.""" + with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f: + f.write(text) + path.chmod(0o600) + + +def write_in_place(path: Path, text: str) -> None: + """Rewrite ``path`` in place, which a file a container's runtime mounts (its hosts and resolv.conf) needs.""" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + f.write(text) + + +def trust_system(paths: Paths, ca_pem: str) -> Optional[str]: + """Add the CA to the system store, by the tool this Linux has; which store, or None when it has neither.""" + for anchor, tool in paths.anchors: + if shutil.which(tool[0]): + write_in_place(anchor, ca_pem) + subprocess.run(tool, check=True, capture_output=True) + return str(anchor) + return None + + +def trust_nss(user: Optional[str], ca: str) -> list[str]: + """Add the CA to the session user's NSS databases, Chromium's (and a snap Chromium's when there is one), as that + user, for a Chromium too old to read its ``CACertificates`` policy; none without ``certutil``.""" + if user is None or not shutil.which("certutil"): + return [] + home = Path(pwd.getpwnam(user).pw_dir) + folders = [home / ".pki" / "nssdb"] + if (home / "snap" / "chromium").is_dir(): + folders.append(home / "snap" / "chromium" / "current" / ".pki" / "nssdb") + trusted = [] + for folder in folders: + db = f"sql:{folder}" + steps = [["mkdir", "-p", str(folder)]] + if not (folder / "cert9.db").exists(): + steps.append(["certutil", "-N", "-d", db, "--empty-password"]) + steps.append(["certutil", "-A", "-d", db, "-n", NSS_NICKNAME, "-t", "C,,", "-i", ca]) + for step in steps: + subprocess.run(as_user(user, step), check=True, capture_output=True) + trusted.append(str(folder)) + return trusted + + +def as_user(user: Optional[str], argv: list[str]) -> list[str]: + if user is None or os.geteuid() != 0: + return argv + return ["runuser", "-u", user, "--", *argv] + + +def install(config_path: str, paths: Paths = Paths()) -> dict: + """As root: write the sites' certificates, names and trust, start ``serve`` on what's written, and check each name + loads as the session user; what was trusted and each check's result. The config's folder, which arrives root's + alone, is opened to the session user (for the CA) only once its keys and the config are private.""" + if os.geteuid() != 0 and paths.root == Path("/"): + raise SystemExit(f"the site proxy installs as root, not uid {os.geteuid()}") + given = json.loads(Path(config_path).read_text()) + folder = Path(config_path).parent + user = given.get("user") + ca = str(folder / "ca.pem") + if given.get("ca_pem"): + Path(ca).write_text(given["ca_pem"]) + sites = [] + for site in given["sites"]: + served = {"name": site["name"], "upstream": site["upstream"], "domains": site["domains"]} + if site.get("cert_pem"): + cert, key = folder / f"{site['name']}.crt", folder / f"{site['name']}.key" + cert.write_text(site["cert_pem"]) + write_private(key, site["key_pem"]) + served.update(cert=str(cert), key=str(key)) + sites.append(served) + os.chmod(config_path, 0o600) + folder.chmod(0o755) + names = [name for site in given["sites"] for name in site["domains"] if not name.startswith("*.")] + write_in_place(paths.hosts, hosts_text(paths.hosts.read_text() if paths.hosts.exists() else "", names)) + trust: dict = {} + nameservers: list[str] = [] + wildcard = any(name.startswith("*.") for site in given["sites"] for name in site["domains"]) + if wildcard: + text, nameservers = resolv_text(paths.resolv.read_text() if paths.resolv.exists() else "") + write_in_place(paths.resolv, text) + if given.get("ca_pem"): + trust["system"] = trust_system(paths, given["ca_pem"]) + write_in_place(paths.firefox_policies, firefox_policies( + paths.firefox_policies.read_text() if paths.firefox_policies.exists() else None, ca)) + trust["firefox"] = str(paths.firefox_policies) + for policy in paths.chromium_policies: + write_in_place(policy, chromium_policies(given["ca_pem"])) + trust["chromium"] = [str(policy) for policy in paths.chromium_policies] + trust["nss"] = trust_nss(user, ca) + bundle = paths.system_bundle if paths.system_bundle.exists() else None + write_in_place(paths.profile, f"export NODE_EXTRA_CA_CERTS={ca}\n" + + (f"export REQUESTS_CA_BUNDLE={bundle}\n" if bundle else "")) + trust["environment"] = str(paths.profile) + serving = {"user": user, "sites": sites, **{key: given[key] for key in ("listen", "https_port", "http_port") + if key in given}} + if wildcard: + serving.update(dns_port=given.get("dns_port", 53), nameservers=nameservers) + serve_path = folder / "serve.json" + serve_path.write_text(json.dumps(serving)) + start(str(serve_path), given.get("log")) + return {"trust": trust, "names": names, "checks": checks_as(user, str(serve_path))} + + +def start(serve_path: str, log: Optional[str]) -> None: + """Start ``serve`` apart from this process, and wait until it listens.""" + config = Config.load(serve_path) + if log: + Path(log).parent.mkdir(parents=True, exist_ok=True) + output = open(log, "ab") if log else subprocess.DEVNULL + subprocess.Popen([sys.executable, os.path.abspath(__file__), "serve", serve_path], stdin=subprocess.DEVNULL, + stdout=output, stderr=subprocess.STDOUT, start_new_session=True, close_fds=True) + port = config.https_port if any(site.cert for site in config.sites) else config.http_port + deadline = time.monotonic() + LISTEN_TIMEOUT_S + while True: + try: + socket.create_connection((config.listen, port), timeout=1).close() + return + except OSError: + if time.monotonic() > deadline: + tail = Path(log).read_text()[-2000:] if log and Path(log).exists() else "" + raise SystemExit(f"the site proxy didn't listen on {config.listen}:{port} within " + f"{LISTEN_TIMEOUT_S:.0f} s: {tail}") + time.sleep(0.2) + + +def checks_as(user: Optional[str], serve_path: str) -> dict: + """``check`` run as ``user``, given each site's timeout one after another and a margin for its start.""" + sites = len(Config.load(serve_path).sites) + done = subprocess.run(as_user(user, [sys.executable, os.path.abspath(__file__), "check", serve_path]), + capture_output=True, text=True, timeout=CHECK_TIMEOUT_S * (sites + 2)) + if done.returncode != 0: + return {"*": {"error": (done.stderr.strip().splitlines() or ["the check failed"])[-1]}} + return json.loads(done.stdout) + + +def check(config: Config) -> dict: + """Each site's first name loaded, over HTTPS but for a ``.localhost`` name, through the system's resolver and + trust store: its status, or why it didn't load, which an answer the proxy made itself (it marks them ``FAILED``), + such as its 502 for a gateway it couldn't reach, is too.""" + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + results = {} + for site in config.sites: + name = next((n for n in site.domains if not n.startswith("*.")), None) \ + or "agentenv-check." + site.domains[0].removeprefix("*.") + url = f"{'https' if site.tls(name) else 'http'}://{name}/" + try: + with opener.open(url, timeout=CHECK_TIMEOUT_S) as response: + results[name] = {"url": url, "status": response.status} + except urllib.error.HTTPError as e: + if e.headers is not None and e.headers.get(FAILED[0][0]) == FAILED[0][1]: + body = e.read().decode(errors="replace").strip() + results[name] = {"url": url, "error": f"{e.code} {e.reason}: {body}"} + else: + results[name] = {"url": url, "status": e.code} + except (urllib.error.URLError, OSError, ssl.SSLError) as e: + results[name] = {"url": url, "error": str(getattr(e, "reason", e))} + return results + + +def main(argv: list[str]) -> None: + command, path = argv[0], argv[1] + if command == "install": + print(json.dumps(install(path))) + elif command == "serve": + asyncio.run(serve(Config.load(path))) + elif command == "check": + print(json.dumps(check(Config.load(path)))) + else: + raise SystemExit(f"usage: site_proxy.py install|serve|check , not {command!r}") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/src/agent_env/env/envs/computer/utils/sites.py b/src/agent_env/env/envs/computer/utils/sites.py new file mode 100644 index 000000000..971144e3f --- /dev/null +++ b/src/agent_env/env/envs/computer/utils/sites.py @@ -0,0 +1,460 @@ +"""Websites in the computer: the browser in a computer opens a site by the names it declares (``https://notes.test/``) +and reaches it on the env gateway, at the site's route (``/website/notes/``), with TLS it trusts, while every +other host follows the computer's network. + +A deploy with ``env_params.site_routing`` is handed the run's deployed websites (``deployed_sites``), as ``sites``. Each +name is claimed once (``site_table``): a synthetic site's names are routed, a live site's and the network's allowlist +are left to the network, and a synthetic name wins over a live one, which wins over the allowlist; two sites of one +kind claiming a name are refused. Once the computer is booted, a CA made for the deploy signs a certificate for each +site's names (``issue``), and the site proxy (``site_proxy.py``) is installed through the sandbox's root channel and +started (``route``), which a reset does again on its fresh computer. In the computer they live in +``/etc/agentenv/sites``, readable by root alone but the CA, ``ca.pem``, at a fixed path for an app with a certificate +list of its own: the folder is root's alone (0700) before the config and its keys are written into it, until the +install has made the keys and the config private; the CA and certificates are valid for the deploy's ttl and a day more, from a few minutes back for a +clock that's behind.""" + +from __future__ import annotations + +import asyncio +import ipaddress +import json +import logging +import re +import shlex +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Iterable, Optional +from urllib.parse import urlsplit + +import httpx +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +from agent_env.env.envs.computer.utils.site_proxy import is_local, matches +from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, VmSandbox + +if TYPE_CHECKING: + from agent_env.env.env import DeployedEnv + from agent_env.env.envs.computer.utils.params import Site + from agent_env.providers.sandbox_providers.sandbox import Sandbox + +FOLDER = PurePosixPath("/etc/agentenv/sites") +PROXY = FOLDER / "site_proxy.py" +CONFIG = FOLDER / "config.json" +CA_PATH = FOLDER / "ca.pem" +LOG = "/var/log/agentenv/site-proxy.log" +SYNTHETIC, LIVE, ALLOWLIST = "synthetic", "live", "allowlist" +RANKS = (SYNTHETIC, LIVE, ALLOWLIST) +VALID_MARGIN = timedelta(days=1) +CLOCK_SKEW = timedelta(minutes=5) +CARD_ATTEMPTS = 3 +CARD_RETRY_S = 1.0 +_LABEL = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") +_PROXY_SOURCE = Path(__file__).with_name("site_proxy.py") +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Claim: + """A name and who claims it: a site, or the network's allowlist, at its rank.""" + + name: str + rank: str + owner: str + + +@dataclass(frozen=True) +class Route: + """A synthetic site as the computer serves it: its names, and its route on the gateway as the computer reaches + it.""" + + site: str + url: str + domains: tuple[str, ...] + + @property + def tls_names(self) -> tuple[str, ...]: + return tuple(name for name in self.domains if not is_local(name)) + + +@dataclass +class SiteTable: + """Every name of the deployment: the routes, the names left to the network, and each claim a stronger one + shadows.""" + + routes: list[Route] = field(default_factory=list) + unrouted: dict[str, str] = field(default_factory=dict) + shadowed: list[tuple[Claim, Claim]] = field(default_factory=list) + + def to_dict(self) -> dict: + return {"routes": {route.site: list(route.domains) for route in self.routes}, "unrouted": self.unrouted, + "shadowed": [{"name": weak.name, "rank": weak.rank, "owner": weak.owner, "by": strong.owner} + for weak, strong in self.shadowed]} + + +class SiteConflict(ValueError): + """Two sites of one kind claim a name.""" + + +def name_problem(name: str) -> Optional[str]: + """Why ``name`` can't be a site's name, or None: a DNS name of letters, digits and hyphens, a wildcard only as its + whole first label, and no address.""" + if name != name.lower() or name.endswith("."): + return f"{name!r} must be lowercase, with no trailing dot" + try: + ipaddress.ip_address(name) + return f"{name!r} is an address, not a name" + except ValueError: + pass + labels = name.split(".") + if labels[0] == "*": + labels = labels[1:] + if len(labels) < 2: + return f"{name!r} is a wildcard over a single label" + if len(name) > 253 or not labels or not all(_LABEL.match(label) for label in labels): + return f"{name!r} isn't a DNS name (letters, digits and hyphens, a '*' only as its whole first label)" + return None + + +def overlap(a: str, b: str) -> bool: + """Whether a host could be named by both ``a`` and ``b``, a wildcard covering one label.""" + if a == b: + return True + for wild, other in ((a, b), (b, a)): + if wild.startswith("*.") and not other.startswith("*.") and other.partition(".")[2] == wild[2:]: + return True + return False + + +def covers(strong: str, weak: str) -> bool: + """Whether every host ``weak`` names is one ``strong`` names.""" + return strong == weak or (strong.startswith("*.") and not weak.startswith("*.") + and weak.partition(".")[2] == strong[2:]) + + +def fallback_name(site: str) -> str: + """The name a synthetic site declaring none is reached by: its name on ``.localhost``, lowercase, each ``_`` a + ``-``, and no ``-`` at either end of the label (``My_Site`` is ``my-site.localhost``). Two sites it gives one name + are refused as any two claiming it.""" + return f"{site.lower().replace('_', '-')[:63].strip('-')}.localhost" + + +def claims(sites: Iterable[Site], allow_hosts: Iterable[str] = ()) -> list[Claim]: + """Each name claimed: a synthetic site's, its own or ``fallback_name`` when it declares none; a live site's and the + hosts it calls; and the network's allowlist.""" + found = [] + for site in sites: + if site.live: + found += [Claim(name, LIVE, site.name) for name in dict.fromkeys([*site.domains, *site.allow_hosts])] + else: + found += [Claim(name, SYNTHETIC, site.name) + for name in dict.fromkeys(site.domains or [fallback_name(site.name)])] + return found + [Claim(host.lower(), ALLOWLIST, "network") for host in dict.fromkeys(allow_hosts)] + + +def site_table(sites: list[Site], allow_hosts: Iterable[str] = ()) -> SiteTable: + """The deployment's names: a synthetic site's routed, the rest left to the network but those a stronger claim + covers, a stronger claim shadowing each weaker one that overlaps it. Raises SiteConflict naming every name two sites + of one kind claim, and ValueError for a name that can't be one or a site named twice.""" + every = claims(sites, allow_hosts) + names = [site.name for site in sites] + invalid = [f"{claim.owner}: {problem}" for claim in every if claim.rank != ALLOWLIST + and (problem := name_problem(claim.name))] + invalid += [f"two sites are named {name!r}" for name in dict.fromkeys(names) if names.count(name) > 1] + invalid += [f"{name!r} isn't a site's name (letters, digits, '-' and '_')" for name in names + if not re.fullmatch(r"[A-Za-z0-9_-]+", name)] + if invalid: + raise ValueError("; ".join(invalid)) + conflicts, kept, table = [], [], SiteTable() + for rank in RANKS: + for claim in (c for c in every if c.rank == rank): + same = [k for k in kept if k.rank == rank and k.owner != claim.owner and overlap(k.name, claim.name)] + conflicts += [f"{claim.name!r} ({claim.owner}) overlaps {k.name!r} ({k.owner})" for k in same] + stronger = [k for k in kept if k.rank != rank and overlap(k.name, claim.name)] + if stronger := sorted(stronger, key=lambda k: not covers(k.name, claim.name))[:1]: + table.shadowed.append((claim, stronger[0])) + kept.append(claim) + if conflicts: + raise SiteConflict("names claimed twice: " + "; ".join(conflicts)) + by_site = {site.name: site for site in sites} + for owner in dict.fromkeys(c.owner for c in kept if c.rank == SYNTHETIC): + table.routes.append(Route(owner, by_site[owner].url, + tuple(c.name for c in kept if c.owner == owner and c.rank == SYNTHETIC))) + covered = {weak for weak, strong in table.shadowed if covers(strong.name, weak.name)} + table.unrouted = {c.name: c.rank for c in kept if c.rank != SYNTHETIC and c not in covered} + return table + + +def table_problems(sites: list[Site], allow_hosts: Iterable[str] = ()) -> list[str]: + try: + site_table(sites, allow_hosts) + except ValueError as e: + return [f"env_params.sites: {e}"] + return [] + + +@dataclass(frozen=True) +class Issued: + """The deploy's CA certificate, and each site's certificate and key, all PEM; no CA when no name is served over + HTTPS. The CA's key signs them and is gone: nothing more can be issued under it.""" + + ca_pem: Optional[str] + leaves: dict[str, tuple[str, str]] + + +def issue(routes: list[Route], *, valid_for: timedelta, label: str, now: Optional[datetime] = None) -> Issued: + """A CA name-constrained to the routes' names, so it vouches for nothing else however widely it's trusted, and a + certificate signed by it for each route's names served over HTTPS, all valid ``valid_for`` and a margin more.""" + tls = [route for route in routes if route.tls_names] + if not tls: + return Issued(None, {}) + now = now or datetime.now(UTC) + start, end = now - CLOCK_SKEW, now + valid_for + VALID_MARGIN + ca_key = ec.generate_private_key(ec.SECP256R1()) + subject = x509.Name([x509.NameAttribute(NameOID.ORGANIZATION_NAME, "agentenv"), + x509.NameAttribute(NameOID.COMMON_NAME, f"agentenv sites {label}"[:64])]) + permitted = list(dict.fromkeys(name.removeprefix("*.") for route in tls for name in route.tls_names)) + ca = ( + x509.CertificateBuilder() + .subject_name(subject).issuer_name(subject) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(start).not_valid_after(end) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension(_key_usage(key_cert_sign=True, crl_sign=True), critical=True) + .add_extension(x509.NameConstraints(permitted_subtrees=[x509.DNSName(n) for n in permitted], + excluded_subtrees=None), critical=True) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False) + .sign(ca_key, hashes.SHA256()) + ) + leaves = {} + for route in tls: + key = ec.generate_private_key(ec.SECP256R1()) + cert = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, route.tls_names[0])])) + .issuer_name(subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(start).not_valid_after(end) + .add_extension(x509.SubjectAlternativeName([x509.DNSName(n) for n in route.tls_names]), critical=False) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension(_key_usage(digital_signature=True), critical=True) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False) + .sign(ca_key, hashes.SHA256()) + ) + leaves[route.site] = (cert.public_bytes(serialization.Encoding.PEM).decode(), key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode()) + return Issued(ca.public_bytes(serialization.Encoding.PEM).decode(), leaves) + + +def _key_usage(**granted: bool) -> x509.KeyUsage: + usages = ("digital_signature", "content_commitment", "key_encipherment", "data_encipherment", "key_agreement", + "key_cert_sign", "crl_sign", "encipher_only", "decipher_only") + return x509.KeyUsage(**{usage: granted.get(usage, False) for usage in usages}) + + +def proxy_config(table: SiteTable, issued: Issued, *, user: Optional[str]) -> dict: + """What ``site_proxy.py install`` reads: the session user, the CA, and each route with its certificate and key.""" + sites = [] + for route in table.routes: + site = {"name": route.site, "upstream": route.url, "domains": list(route.domains)} + if route.site in issued.leaves: + site["cert_pem"], site["key_pem"] = issued.leaves[route.site] + sites.append(site) + return {"user": user, "ca_pem": issued.ca_pem, "sites": sites, "log": LOG} + + +def reachable_problem(policy: Optional[NetworkPolicy], routes: list[Route]) -> Optional[str]: + """Why ``policy``, the computer's network as its sandbox applies it, keeps it from a route's gateway, or None: a + gateway is reachable by its name in the allowed hosts, exactly or under a ``*.domain`` entry, or by an address in an + allowed range.""" + if policy is None or not policy.restricts_egress: + return None + hosts = sorted({urlsplit(route.url).hostname or "" for route in routes}) + blocked = [host for host in hosts if not _allows(policy, host)] + if blocked: + allowed = [*policy.allow_hosts, *policy.allow_cidrs] + return (f"the computer's network allows only {', '.join(allowed) or 'nothing'}, so it can't reach " + f"the gateway at {', '.join(blocked)} its sites are on") + return None + + +def gateway_problem(routes: list[Route]) -> Optional[str]: + """Why the computer couldn't reach a route's gateway once its sites' names are routed, or None: a gateway named by a + route's name, exactly or under its wildcard, would resolve to the proxy itself.""" + hosts = {urlsplit(route.url).hostname or "" for route in routes} + hidden = sorted({f"{host} (named by {route.site}'s {name})" for host in hosts for route in routes + for name in route.domains if matches(name, host)}) + if hidden: + return f"the gateway at {', '.join(hidden)} would resolve to the computer's own site proxy" + return None + + +def _allows(policy: NetworkPolicy, host: str) -> bool: + host = host.lower().rstrip(".") + if any(host == allowed or (allowed.startswith("*.") and host.endswith(allowed[1:])) + for allowed in (entry.lower().rstrip(".") for entry in policy.allow_hosts)): + return True + try: + address = ipaddress.ip_address(host) + except ValueError: + return False + for entry in (*policy.allow_cidrs, *policy.allow_hosts): + try: + if address in ipaddress.ip_network(entry, strict=False): + return True + except ValueError: + continue + return False + + +class RootChannel: + """Root inside a booted computer, through its sandbox: on a VM sandbox ``docker exec -u 0`` into its container, + elsewhere the sandbox's own exec, which must run as root, as a container sandbox running the image as its own user + (``USER user``) doesn't: that's refused before anything is written. The driver's computer-exec/v1 runs only as the + session user.""" + + def __init__(self, sandbox: Sandbox): + self.sandbox = sandbox + self.checked = False + + async def private_folder(self, path: PurePosixPath) -> None: + """Make ``path`` a folder root alone can enter, an existing one too, so what's written into it next is never + the session user's to read, whatever mode a file lands with.""" + await self.run(["mkdir", "-p", str(path)]) + await self.run(["chmod", "0700", str(path)]) + + async def write(self, path: PurePosixPath, text: str) -> None: + if isinstance(self.sandbox, VmSandbox): + await self.sandbox.write_file_from_text(text, str(path)) + return + await self.run(["mkdir", "-p", str(path.parent)]) + await self.sandbox.write_file_from_text(text, str(path)) + + async def run(self, argv: list[str]) -> str: + if isinstance(self.sandbox, VmSandbox): + return await self.sandbox.exec_script(f"docker exec -u 0 {shlex.quote(self.sandbox.container_name)} " + f"{shlex.join(argv)}") + if not self.checked: + uid = (await self._exec(["id", "-u"])).strip() + if uid != "0": + raise RuntimeError(f"site routing installs as root, and the {getattr(self.sandbox, 'type', 'given')} " + f"sandbox runs the computer's commands as uid {uid}; deploy it on a VM sandbox") + self.checked = True + return await self._exec(argv) + + async def _exec(self, argv: list[str]) -> str: + code, stdout, stderr = await self.sandbox.exec_with_output(*argv) + if code != 0: + raise RuntimeError(f"{shlex.join(argv)} failed (exit {code}):\nstdout: {stdout[-1500:]}\n" + f"stderr: {stderr[-1500:]}") + return stdout + + +async def route(sandbox: Sandbox, sites: list[Site], *, user: Optional[str], network: Optional[dict], + valid_for: timedelta, label: str) -> dict: + """Have the computer on ``sandbox`` reach each of ``sites`` by its names: the table made, the CA and certificates + issued, the proxy installed and started as root, and each name checked as ``user``; what the deploy record keeps of + it. Raises naming a name that didn't load.""" + requested = NetworkPolicy.from_dict(network) if network else None + table = site_table(sites, requested.allow_hosts if requested else ()) + table.routes = [Route(r.site, sandbox.url_from_sandbox(r.url), r.domains) for r in table.routes] + applied = getattr(sandbox, "network_policy", None) or requested + if (problem := reachable_problem(applied, table.routes) or gateway_problem(table.routes)) is not None: + raise RuntimeError(f"site routing: {problem}") + if not table.routes: + return {"names": table.to_dict()} + issued = issue(table.routes, valid_for=valid_for, label=label) + channel = RootChannel(sandbox) + await channel.private_folder(FOLDER) + await channel.write(PROXY, _PROXY_SOURCE.read_text()) + await channel.write(CONFIG, json.dumps(proxy_config(table, issued, user=user))) + output = await channel.run(["python3", str(PROXY), "install", str(CONFIG)]) + report = json.loads(output.strip().splitlines()[-1]) + failed = {name: result for name, result in report["checks"].items() if "error" in result} + if failed: + raise RuntimeError("site routing: " + "; ".join(f"{result.get('url', name)} didn't load in the computer: " + f"{result['error']}" for name, result in failed.items())) + return {"names": table.to_dict(), "ca": issued.ca_pem, "ca_path": str(CA_PATH), "trust": report["trust"], + "checks": report["checks"]} + + +async def deployed_sites(deployed_envs: list[DeployedEnv], only: Optional[list[str]] = None) -> list[dict]: + """The websites a run has deployed, as ``env_params.sites`` names them: each ``website/v1`` child env of a deployed + env's stored card, with the route its composer serves it to a browser at (its params' ``browse_url``, joined onto + the env's address), and the names and liveness its params declare. A record whose card names no ``browse_url``, as + one a gateway made before it did, takes the route from its ``website_frontend_urls``. A site those name that the + stored card doesn't hold, every site of a record without a card or one whose card the gateway couldn't read when it + composed its own, is read live at ``/svc/``, and has no names when it serves no card (404 or no JSON). + Reading one fails otherwise, after ``CARD_ATTEMPTS`` tries, rather than route the site by names it didn't declare. + ``only`` keeps the websites of the envs it names, each of which must be deployed.""" + from agentenv_protocol import client as protocol_v1 + from agentenv_protocol.website import WEBSITE_BROWSE_URL_PARAM, WEBSITE_EXTENSION_URI + + from agent_env.env.env import gateway_url_of + + records = list(deployed_envs) + if only is not None: + missing = [env_id for env_id in only if env_id not in {d.env_id for d in deployed_envs}] + if missing: + raise ValueError(f"site_routing names {', '.join(missing)}, which this run hasn't deployed before the " + "computer") + records = [d for d in records if d.env_id in only] + found = [] + for record in records: + address = gateway_url_of(record) or getattr(record, "environment_url", None) + frontends = getattr(record, "website_frontend_urls", None) or {} + named = set() + for child in protocol_v1.children(getattr(record, "environment_card", None) or {}): + if child.find_extension(WEBSITE_EXTENSION_URI) is None or not child.name: + continue + params = protocol_v1.extension_params(child.card, WEBSITE_EXTENSION_URI) + browse = params.get(WEBSITE_BROWSE_URL_PARAM) + url = (_joined(address, browse) if isinstance(browse, str) and browse and address + else frontends.get(child.name)) + if url is None: + logger.warning("site routing: the website %s of env %s names no route a browser reaches it at, so it " + "isn't routed", child.name, record.env_id) + continue + named.add(child.name) + found.append(_found(child.name, url, params)) + for name, url in frontends.items(): + if name not in named: + card = await _site_card(protocol_v1, f"{gateway_url_of(record)}/svc/{name}", name) + found.append(_found(name, url, protocol_v1.extension_params(card, WEBSITE_EXTENSION_URI))) + return found + + +def _found(name: str, url: str, params: dict) -> dict: + return {"name": name, "url": url, "domains": list(params.get("domains") or []), "live": bool(params.get("live")), + "allow_hosts": list(params.get("allow_hosts") or [])} + + +def _joined(address: str, url: str) -> str: + """``url`` resolved against ``address``: a full URL stands alone, and a path joins onto it.""" + return url if "://" in url else f"{address.rstrip('/')}/{url.lstrip('/')}" + + +async def _site_card(protocol_v1, url: str, name: str) -> dict: + """The card of the site ``name`` at ``url``, or none for a site that serves none.""" + for attempt in range(1, CARD_ATTEMPTS + 1): + try: + return await protocol_v1.get_card(url) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + return {} + error: Exception = e + except ValueError: + return {} + except (httpx.HTTPError, OSError) as e: + error = e + if attempt < CARD_ATTEMPTS: + await asyncio.sleep(CARD_RETRY_S) + raise RuntimeError(f"site routing: the card of the site {name} at {url} couldn't be read, so its names are " + f"unknown: {type(error).__name__}: {error}") diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index 90f5a609d..53c558b26 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -81,6 +81,7 @@ def data_plane_load_timeout_s(size_bytes: int | None) -> int: EXT_NOTIFICATIONS_URI = "urn:agentenv:notifications/v1" EXT_WEBSITE_URI = "urn:agentenv:website/v1" WEBSITE_READY_OP = "ready" +WEBSITE_BROWSE_URL_PARAM = "browse_url" # Rows per GET /changes page: the default, and the most a client may ask for. CHANGES_PAGE_DEFAULT = 1000 @@ -144,6 +145,8 @@ def sources(names: list[str]) -> str: AGENT_ENV_ROLE_HEADER = "AgentEnv-Role" AGENT_ENV_ROLE_KEY_HEADER = "AgentEnv-Role-Key" +# Sent by a computer's site proxy, which serves a website's route at the root of the site's own name. +AGENT_ENV_SITE_ROOT_HEADER = "AgentEnv-Site-Root" GATEWAY_ROLE_KEY_ENV = "AGENTENV_ROLE_KEY" WEBSITE_ROLE_KEY_ENV = "WEBSITE_ROLE_KEY" # MCP request `_meta` keys the gateway stamps on every child tools/call: the caller's role, and on the MCP path a diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 94dfd2366..b054fb147 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -31,8 +31,10 @@ from mcp.shared.exceptions import McpError from mcp.types import CONNECTION_CLOSED, CallToolResult, ErrorData, TextContent, Tool as MCPTool from pydantic import create_model -from starlette.requests import Request +from starlette.requests import HTTPConnection, Request from starlette.responses import Response, StreamingResponse +from starlette.routing import WebSocketRoute +from starlette.websockets import WebSocket from anyio import ClosedResourceError if TYPE_CHECKING: @@ -64,6 +66,7 @@ STEP_SOURCE_POST_TURN_HOOK, TOOL_DISABLE_ACTION, TOOL_ENABLE_ACTION, + WEBSITE_BROWSE_URL_PARAM, WELL_KNOWN_PATH, WILDCARD, GatewayMode, @@ -82,6 +85,8 @@ fetch_child_card, forward, operations, + refuse_websocket, + relay_websocket, rewrite_child_card, route_name, upstream_url, @@ -222,6 +227,11 @@ async def proxy_to_frontend(request: Request) -> Response: async def proxy_to_frontend_root(request: Request) -> Response: return await self._proxy_website_request(request) + self._mcp._custom_starlette_routes += [ + WebSocketRoute("/website/{service_name}/{path:path}", self._proxy_website_socket), + WebSocketRoute("/website/{service_name}", self._proxy_website_socket), + ] + @self._mcp.custom_route(f"/tools/{TOOL_DISABLE_ACTION}", methods=["POST"]) async def tools_disable(request: Request) -> Response: return await self._handle_role_mutation(request, action=TOOL_DISABLE_ACTION) @@ -679,6 +689,25 @@ async def _proxy_website_request(self, request: Request) -> Response: await self._await_barriers(barrier_tasks, barrier_timeout_s) return response + async def _proxy_website_socket(self, websocket: WebSocket) -> None: + """Relay a WebSocket on a site's route to its server, as its HTTP requests are: its data plane and card, and + every path its card advertises for agentenv (``site_reserved_paths``), are refused, and it goes upstream with + the role, role key and forwarded address an HTTP request would carry.""" + reserved = await self._site_reserved_paths(websocket.path_params["service_name"]) + if reserved is None: + await refuse_websocket(websocket, self._json_response({"error": "website card unavailable"}, 503)) + return + if reserved_site_request(websocket, reserved): + await refuse_websocket(websocket, self._json_response({"error": "not found"}, 404)) + return + service_name = websocket.path_params["service_name"] + base_url = self.website_urls.get(service_name) + if base_url is None: + await refuse_websocket(websocket, self._json_response({"error": f"Unknown service: {service_name}"}, 404)) + return + await relay_websocket(websocket, base_url, role=self._request_role(websocket), role_key=self._role_key, + open_timeout=self.REST_PROXY_CONNECT_TIMEOUT_S) + async def _fire_website_triggers(self, request: Request, app: str, status: int) -> tuple[list[asyncio.Task], float | None]: """Feed an answered website request to the trigger engine (fail-open).""" @@ -692,7 +721,7 @@ async def _fire_website_triggers(self, request: Request, app: str, logger.exception("Trigger evaluation failed (fail-open)") return [], None - def _request_role(self, request: Request) -> str: + def _request_role(self, request: HTTPConnection) -> str: """The role the gateway attributes a REST request to: the first AgentEnv-Role header, as `_attributed_role` believes it, else the default role.""" return self._attributed_role(request.headers.get(AGENT_ENV_ROLE_HEADER, ""), @@ -767,7 +796,15 @@ def _backing_keys(self) -> list[str]: return list(self.rest_proxy_urls) def _rewrite_child_card(self, key: str, card: dict) -> dict: - return rewrite_child_card(key, card, self._gateway_extensions()) + """One backing server's card rooted at the gateway (``rewrite_child_card``); a website's ``website/v1`` params + also name the route the gateway serves it to a browser at, ``/website//``, as ``browse_url``.""" + card = rewrite_child_card(key, card, self._gateway_extensions()) + if key in self.website_urls: + for ext in (card.get("capabilities") or {}).get("extensions") or []: + if isinstance(ext, dict) and ext.get("uri") == EXT_WEBSITE_URI: + params = ext.get("params") if isinstance(ext.get("params"), dict) else {} + ext["params"] = {**params, WEBSITE_BROWSE_URL_PARAM: f"/website/{key}/"} + return card async def _fetch_child_card(self, client: httpx.AsyncClient, key: str, base_url: str) -> dict | None: """Fetch + rewrite one backing server's card; None (omit) on 404 / error / timeout / malformed JSON. A @@ -1634,7 +1671,7 @@ def site_reserved_paths(card: dict) -> frozenset[str]: return frozenset(_normalized(p) for p in paths if isinstance(p, str) and p.startswith("/") and _normalized(p) != "/") -def site_request_path(request: Request) -> str | None: +def site_request_path(request: HTTPConnection) -> str | None: """The path a site's server receives for a request on its route: the raw path forwarded, its dot segments removed by httpx and then decoded. It differs from the path as routed here when an encoded slash sits beside a dot segment (``public%2Fx/../api`` reaches ``/api``). None for a request-target the proxy can't build a URL for: ``forward()`` @@ -1645,10 +1682,11 @@ def site_request_path(request: Request) -> str | None: return None -def reserved_site_request(request: Request, reserved: frozenset[str] = frozenset()) -> bool: - """Whether a request on a site's route reaches its data plane or env card, or one of the paths ``reserved`` names - (``site_reserved_paths``): by the path as routed here, or by the one its server receives (``site_request_path``). - A request-target the proxy can't build a URL for isn't reserved here: ``forward()`` refuses it.""" +def reserved_site_request(request: HTTPConnection, reserved: frozenset[str] = frozenset()) -> bool: + """Whether a request or WebSocket on a site's route reaches its data plane or env card, or one of the paths + ``reserved`` names (``site_reserved_paths``): by the path as routed here, or by the one its server receives + (``site_request_path``). A request-target the proxy can't build a URL for isn't reserved here: ``forward()`` + refuses it.""" if reserved_site_path("/" + request.path_params.get("path", ""), reserved): return True sent = site_request_path(request) diff --git a/src/agent_env/env/gateway/requirements.txt b/src/agent_env/env/gateway/requirements.txt index 89a76f21d..261328984 100644 --- a/src/agent_env/env/gateway/requirements.txt +++ b/src/agent_env/env/gateway/requirements.txt @@ -3,3 +3,4 @@ httpx mcp>=1.25,<2 pydantic>=2.0 tzdata +websockets>=13 diff --git a/src/agent_env/env/gateway/rest_proxy.py b/src/agent_env/env/gateway/rest_proxy.py index 10a3b3ba2..50d34d900 100644 --- a/src/agent_env/env/gateway/rest_proxy.py +++ b/src/agent_env/env/gateway/rest_proxy.py @@ -14,9 +14,15 @@ ``default`` the key verified, so a site holding the same key believes the role; - the upstream learns the address the client reached it at: ``X-Forwarded-Host`` and ``X-Forwarded-Proto`` (a front's own, when one before the gateway sent them, else the request's ``Host`` and scheme) and ``X-Forwarded-Prefix``, the - route prefix (``/svc/{service}`` or ``/website/{service}``) the gateway strips, which a client never sets; + route prefix (``/svc/{service}`` or ``/website/{service}``) the gateway strips, which a client never sets: a client + that serves the route at a root of its own (a computer's site proxy, at ``https://notes.test/``) says so with + ``AgentEnv-Site-Root``, and the upstream is then told no prefix; - the upstream response and the client it came through are closed when the response ends, iterated or not. +A WebSocket on ``/website/{service}/{path}`` is relayed under the same rules (:func:`relay_websocket`): the gateway +opens one of its own to the service, with the headers an HTTP request would carry upstream, and relays each message +and the close, either side's ending ending both. + A backing server's env card names its endpoints relative to itself; :func:`rewrite_child_card` roots them under ``/svc/{key}`` so they resolve against the env's address, keeping the gateway's own routes where the gateway offers the same operation. A website's card names its routes on ``urn:agentenv:website/v1``; :func:`website_routes` reads @@ -24,6 +30,8 @@ """ from __future__ import annotations +import asyncio +import contextlib import copy import json import logging @@ -34,9 +42,12 @@ from urllib.parse import unquote import httpx -from starlette.requests import Request +from starlette.requests import HTTPConnection, Request from starlette.responses import Response, StreamingResponse from starlette.types import Receive, Scope, Send +from starlette.websockets import WebSocket, WebSocketState +from websockets.asyncio.client import ClientConnection, connect as connect_websocket +from websockets.exceptions import ConnectionClosed, InvalidHandshake, InvalidStatus # Relative import — gateway runs as a standalone container with no agent_env.* from .constants import ( @@ -44,6 +55,7 @@ AGENT_ENV_ROLE_KEY_HEADER, AGENT_ENV_ROLE_META_KEY, AGENT_ENV_SESSION_META_KEY, + AGENT_ENV_SITE_ROOT_HEADER, CARD_FETCH_TIMEOUT_S, DEFAULT_ROLE, EXT_WEBSITE_URI, @@ -63,7 +75,15 @@ ROLE_KEY_HEADER_BYTES = AGENT_ENV_ROLE_KEY_HEADER.lower().encode() FORWARDED_HOST, FORWARDED_PROTO, FORWARDED_PREFIX = "x-forwarded-host", "x-forwarded-proto", "x-forwarded-prefix" -_FORWARDED = frozenset(name.encode() for name in (FORWARDED_HOST, FORWARDED_PROTO, FORWARDED_PREFIX)) +_FORWARDED = frozenset(name.encode() for name in (FORWARDED_HOST, FORWARDED_PROTO, FORWARDED_PREFIX, + AGENT_ENV_SITE_ROOT_HEADER.lower())) +# A WebSocket's handshake fields, which the gateway's own connection upstream writes for itself. +_WEBSOCKET_HANDSHAKE = frozenset({"sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions", + "sec-websocket-protocol"}) +# What the gateway's own WebSocket handshake answer writes for itself, so a site's copies aren't relayed. +_WEBSOCKET_ANSWER_DROP = HOP_BY_HOP | {b"date", b"server", b"content-length", b"sec-websocket-accept", + b"sec-websocket-protocol", b"sec-websocket-extensions"} +_HTTP_SCHEME = {"ws": "http", "wss": "https"} async def forward(request: Request, base_url: str, *, role: str, timeout: httpx.Timeout, mcp_server: bool, @@ -101,7 +121,7 @@ async def _done(response: Response, on_done: Callable[[int], Awaitable[None]] | return response -def upstream_url(base_url: str, request: Request) -> str: +def upstream_url(base_url: str, request: HTTPConnection) -> str: # Forward the path as received: uvicorn decodes scope["path"] for routing, so Starlette's {path} param # would send %2F, %3F and %23 to the upstream as separators. latin-1 keeps one char per wire byte. url = base_url @@ -115,7 +135,7 @@ def upstream_url(base_url: str, request: Request) -> str: return url -def upstream_headers(request: Request, role: str, role_key: str | None = None) -> httpx.Headers: +def upstream_headers(request: HTTPConnection, role: str, role_key: str | None = None) -> httpx.Headers: """One entry per raw header, so repeated fields (cookie, x-forwarded-for) keep their multiplicity. The role and role key headers are the gateway's to set: the client's copies are dropped and the role the gateway attributed the request to goes in their place, with `role_key` beside any role but `default`, so the upstream @@ -132,15 +152,20 @@ def upstream_headers(request: Request, role: str, role_key: str | None = None) - return headers -def forwarded_address(request: Request) -> dict[str, str]: +def forwarded_address(request: HTTPConnection) -> dict[str, str]: """Where the client reached the proxied service: the host and scheme it addressed (as a front before the gateway names them in its own ``X-Forwarded-Host``/``X-Forwarded-Proto``, else the request's ``Host`` and scheme; the - client could set ``Host`` anyway, so believing its front names nothing it couldn't), and the route prefix as sent. - A header with nothing to say is left out.""" + client could set ``Host`` anyway, so believing its front names nothing it couldn't), and the route prefix as sent, + or none for a client that says with ``AgentEnv-Site-Root`` it serves the route at a root of its own: a computer's + site proxy, whose browser would otherwise get the route's prefix twice. Like the host, that only changes the links + the client's own answer names; what the route reaches is the path's alone. A header with nothing to say is left + out.""" host = _first(request.headers.get(FORWARDED_HOST, "")) or request.headers.get("host", "") proto = _first(request.headers.get(FORWARDED_PROTO, "")).lower() - address = {FORWARDED_HOST: host, FORWARDED_PROTO: proto if proto in ("http", "https") else request.url.scheme, - FORWARDED_PREFIX: route_prefix(request)} + at_root = bool(request.headers.get(AGENT_ENV_SITE_ROOT_HEADER, "").strip()) + scheme = _HTTP_SCHEME.get(request.url.scheme, request.url.scheme) + address = {FORWARDED_HOST: host, FORWARDED_PROTO: proto if proto in ("http", "https") else scheme, + FORWARDED_PREFIX: "" if at_root else route_prefix(request)} return {name: value for name, value in address.items() if value} @@ -148,7 +173,7 @@ def _first(value: str) -> str: return value.split(",")[0].strip() -def route_prefix(request: Request) -> str: +def route_prefix(request: HTTPConnection) -> str: """The path the client reached the proxied service under, as it sent it: the request-target up to the `{path}` remainder, without its trailing slash, e.g. ``/website/notes``, or under a root path ``/env/website/notes``.""" raw_path = request.scope.get("raw_path") or request.scope["path"].encode() @@ -207,6 +232,102 @@ def raw_remainder(raw_path: bytes, decoded_path: str, decoded_rest: str) -> byte return rest +async def relay_websocket(websocket: WebSocket, base_url: str, *, role: str, role_key: str | None = None, + open_timeout: float = 10.0) -> None: + """Relay `websocket` to the service at `base_url` as `role`: a WebSocket of the gateway's own is opened to the same + path, with the headers ``upstream_headers`` sends, asking for the subprotocols the client offered; `websocket` is + accepted with the one the service chose and the headers its answer carried (a ``Set-Cookie`` among them, each + field kept apart), but those the gateway's own answer writes. Each text and binary message is relayed as it came, and either side's + close, with its code, closes the other. A service that refuses the handshake has its answer relayed as the refusal; + one that never answers is a 502.""" + try: + target_url = upstream_url(base_url, websocket) + except ValueError as e: + await refuse_websocket(websocket, _json_response({"error": str(e)}, 400)) + return + headers = [(key, value) for key, value in upstream_headers(websocket, role, role_key).multi_items() + if key.lower() not in _WEBSOCKET_HANDSHAKE] + offered = [protocol.strip() for value in websocket.headers.getlist("sec-websocket-protocol") + for protocol in value.split(",") if protocol.strip()] + try: + upstream = await connect_websocket("ws" + target_url.removeprefix("http"), additional_headers=headers, + subprotocols=offered or None, user_agent_header=None, + open_timeout=open_timeout, max_size=None) + except InvalidStatus as e: + answer = e.response + await refuse_websocket(websocket, Response(content=answer.body or b"", status_code=answer.status_code, + media_type=answer.headers.get("content-type"))) + return + except (InvalidHandshake, OSError, TimeoutError) as e: + logger.error(f"WebSocket proxy to {target_url} failed: {e!r}") + await refuse_websocket(websocket, _json_response({"error": f"Proxy request failed: {e!r}"}, 502)) + return + async with upstream: + answer = [(key.encode("ascii", "surrogateescape"), value.encode("ascii", "surrogateescape")) + for key, value in upstream.response.headers.raw_items()] + await websocket.accept(subprotocol=upstream.subprotocol, + headers=[(key.lower(), value) for key, value in answer + if key.lower() not in _WEBSOCKET_ANSWER_DROP]) + await _relay_messages(websocket, upstream) + + +async def refuse_websocket(websocket: WebSocket, response: Response) -> None: + """Refuse `websocket`'s handshake with `response`, or, on a server that can't send one, a 403.""" + if "websocket.http.response" in websocket.scope.get("extensions", {}): + await websocket.send_denial_response(response) + else: + await websocket.close(1008) + + +async def _relay_messages(websocket: WebSocket, upstream: ClientConnection) -> None: + """Both directions of an accepted WebSocket until either ends, then both closed: the client's close goes upstream + with its code, and the service's to the client.""" + + async def from_client() -> None: + while True: + message = await websocket.receive() + if message["type"] == "websocket.disconnect": + await upstream.close(_sendable(message.get("code"), abnormal=1001), message.get("reason") or "") + return + if message.get("text") is not None: + await upstream.send(message["text"]) + elif message.get("bytes") is not None: + await upstream.send(message["bytes"]) + + async def from_upstream() -> None: + async for data in upstream: + if isinstance(data, str): + await websocket.send_text(data) + else: + await websocket.send_bytes(data) + + tasks = [asyncio.create_task(from_client()), asyncio.create_task(from_upstream())] + try: + await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await upstream.close() + if WebSocketState.DISCONNECTED not in (websocket.client_state, websocket.application_state): + with contextlib.suppress(RuntimeError, OSError, ConnectionClosed): + await websocket.close(_sendable(upstream.close_code, abnormal=1011), upstream.close_reason or "") + + +def _sendable(code: int | None, *, abnormal: int) -> int: + """A close code one side reported, as one the other may be sent: none (1005) as a normal close, and a closure + with no close frame (1006, 1015) or a code no endpoint may send as `abnormal`. + + >>> [_sendable(c, abnormal=1011) for c in (None, 1005, 1000, 1006, 4001, 1004)] + [1000, 1000, 1000, 1011, 4001, 1011] + """ + if code in (None, 1005): + return 1000 + if 1000 <= code <= 1003 or 1007 <= code <= 1014 or 3000 <= code <= 4999: + return code + return abnormal + + class UpstreamResponse(StreamingResponse): """Streams an httpx response through untouched and closes it when the response ends, iterated or not. diff --git a/src/agent_env/providers/env_providers/env_computer_provider.py b/src/agent_env/providers/env_providers/env_computer_provider.py index 1d0b996c5..6b5dbde5b 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -5,7 +5,7 @@ import functools import logging -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, ClassVar, Optional from agentenv_protocol.computer import COMPUTER_DISPLAY, COMPUTER_RECORDING @@ -15,6 +15,7 @@ from agent_env.env.env import DeployedSandboxEnv, EnvCapabilityUnsupported from agent_env.env.envs.computer.utils import params as computer_params from agent_env.env.envs.computer.utils.probing import probe_first_boot +from agent_env.env.envs.computer.utils import sites from agent_env.env.envs.computer.utils.boot import ( app_settings_environment, boot, @@ -36,6 +37,7 @@ if TYPE_CHECKING: from agent_env.env.env import Env from agent_env.env.envs.computer.env import ComputerEnv + from agent_env.providers.sandbox_providers.sandbox import Sandbox from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider logger = logging.getLogger(__name__) @@ -74,6 +76,9 @@ async def deploy( params, values, problems = computer_params.checked(env, env_params) if problems: raise DeployRefused(f"{env.id}: " + "; ".join(problems)) + if params.site_routing and not params.sites: + raise DeployRefused(f"{env.id}: env_params.site_routing reaches the run's websites, which a run's " + "deploy_env names in env_params.sites; give them there to deploy it any other way") await env.compose_unstarted(sandbox_provider, params.form) if not (builds := env.deployable_builds(params.form)): raise DeployRefused(f"{env.id} has no ready build to deploy; its builds are " @@ -132,6 +137,8 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par "env_params": params.model_dump(exclude_defaults=True)}}, ) await _require_display(record, values.get("resolution")) + if params.site_routing: + record.metadata["computer"]["sites"] = await _route_sites(env, booted.sandbox, params, ttl_seconds) except BaseException: await discard(booted.sandbox) raise @@ -161,6 +168,17 @@ async def reboot(self, env: ComputerEnv, deployed: DeployedSandboxEnv) -> None: deployed.control_token) _require_name(env, card) await _require_display(deployed, values.get("resolution")) + if params.site_routing: + record["sites"] = await _route_sites(env, sandbox, params, record["sites"]["valid_for_s"]) + + +async def _route_sites(env: ComputerEnv, sandbox: Sandbox, params: computer_params.ComputerParams, + valid_for_s: int) -> dict: + """The computer on ``sandbox`` routed to ``params.sites`` under a CA of its own, valid ``valid_for_s``; what its + record keeps of it.""" + routed = await sites.route(sandbox, params.sites, user=env.computer.session.user, network=env.network, + valid_for=timedelta(seconds=valid_for_s), label=env.environment_name) + return {**routed, "valid_for_s": valid_for_s} async def end_recordings(record: DeployedSandboxEnv, reason: str) -> None: 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 79aacc7fd..f2c1d31b3 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -936,7 +936,9 @@ async def _stand_up(_spec): card = await self._wait_for_tunnel(gateway_url, timeout=120) if card is None: raise RuntimeError("Tunnel did not become ready in time") - await _wait_for_websites(gateway_url, website_configs or []) + if website_configs: + await _wait_for_websites(gateway_url, website_configs) + card = await self._wait_for_tunnel(gateway_url, timeout=120) or card card_read_at_utc = datetime.now(timezone.utc).isoformat() await self._refuse_tool_name_clashes(gateway_url) mcp_url = _mcp_url(gateway_url, card) diff --git a/src/agent_env/task_step/task_steps/deploy_env.py b/src/agent_env/task_step/task_steps/deploy_env.py index 4a39c4b39..22530034f 100644 --- a/src/agent_env/task_step/task_steps/deploy_env.py +++ b/src/agent_env/task_step/task_steps/deploy_env.py @@ -192,7 +192,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: if self.env_params: if "env_params" not in params and not accepts_kwargs: raise ValueError(f"deploy_env '{self.id}': {env.type} envs take no env_params") - deploy_kwargs["env_params"] = self.env_params + deploy_kwargs["env_params"] = await env.run_env_params(self.env_params, context.deployed_envs) if self.keyed_roles: if "keyed_roles" not in params and not accepts_kwargs: raise ValueError(f"deploy_env '{self.id}': {env.type} envs take no keyed_roles") diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 2cc8bba9b..acbfb5ace 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -14,6 +14,7 @@ import re import shutil import urllib.parse +from datetime import timedelta from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock @@ -637,6 +638,124 @@ async def test_a_reset_a_provider_cant_carry_out_fails_the_step_rather_than_skip TaskStepContext(deployed_envs=[deployed])) +NOTES_CARD = {"name": "notes", "capabilities": {"extensions": [ + {"uri": "urn:agentenv:website/v1", "params": {"domains": ["notes.test"]}}]}} + + +def _sites_gateway(): + from agent_env.env.env import DeployedGatewayEnv + + return DeployedGatewayEnv(env_id="@local/~/kit/sites", env_version=1, sandbox_id="gw", sandbox_type="local", + gateway_url="http://localhost:8080", + website_frontend_urls={"notes": "http://localhost:8080/website/notes/"}) + + +@pytest.fixture +def routes(monkeypatch): + """Each site routing a deploy asks for, as (sandbox, sites, options), answering what the proxy's install would; the + gateway serving the notes site's card.""" + from agentenv_protocol import client as protocol_v1 + + made = [] + + async def route(sandbox, sites, **options): + made.append((sandbox, sites, options)) + return {"names": {"routes": {site.name: site.domains for site in sites}}, "ca": "CA PEM"} + + async def get_card(url, timeout=10, verify=True): + assert url == "http://localhost:8080/svc/notes" + return NOTES_CARD + + monkeypatch.setattr(env_computer_provider.sites, "route", route) + monkeypatch.setattr(protocol_v1, "get_card", get_card) + return made + + +@pytest.mark.asyncio +async def test_a_computer_with_site_routing_reaches_the_websites_the_run_deployed_before_it_by_their_names( + base, driver, docker, boots, routes): + env = _put(base, driver, display={"width": 1600, "height": 900}) + context = TaskStepContext(deployed_envs=[_sites_gateway()]) + + step = DeployEnvTaskStep(id="d", version=None, env_id=env.id, ttl_seconds=3600, env_params={"site_routing": True}) + + assert step.preflight() == [] + await step.execute(context) + + ((sandbox, sites, options),) = routes + assert sandbox is boots.sandboxes[0] + assert [site.model_dump() for site in sites] == [{"name": "notes", "url": "http://localhost:8080/website/notes/", + "domains": ["notes.test"], "live": False, "allow_hosts": []}] + assert options == {"user": "user", "network": None, "valid_for": timedelta(seconds=3600), "label": "desktop"} + record = context.deployed_envs[-1].metadata["computer"] + assert record["sites"] == {"names": {"routes": {"notes": ["notes.test"]}}, "ca": "CA PEM", "valid_for_s": 3600} + assert record["env_params"]["site_routing"] is True and record["env_params"]["sites"][0]["name"] == "notes" + + +@pytest.mark.asyncio +async def test_a_computer_without_site_routing_routes_nothing_whatever_websites_the_run_deployed( + base, driver, docker, boots, routes): + env = _put(base, driver, display={"width": 1600, "height": 900}) + context = TaskStepContext(deployed_envs=[_sites_gateway()]) + + await DeployEnvTaskStep(id="d", version=None, env_id=env.id).execute(context) + + assert routes == [] and "sites" not in context.deployed_envs[-1].metadata["computer"] + + +@pytest.mark.asyncio +async def test_a_reset_routes_the_fresh_computer_to_the_same_sites_under_a_ca_of_its_own( + base, driver, docker, boots, routes, monkeypatch): + env = _put(base, driver, display={"width": 1600, "height": 900}) + context = TaskStepContext(deployed_envs=[_sites_gateway()]) + await DeployEnvTaskStep(id="d", version=None, env_id=env.id, ttl_seconds=3600, + env_params={"site_routing": True}).execute(context) + (sandbox,) = boots.sandboxes + monkeypatch.setattr(local_sandbox.LocalSandboxProvider, "get_sandbox", AsyncMock(return_value=sandbox)) + monkeypatch.setattr(local_sandbox.LocalSandboxProvider, "reset_container", AsyncMock()) + monkeypatch.setattr(env_computer_provider, "ready", AsyncMock(return_value=DRIVER_CARD)) + + await ResetEnvTaskStep(id="reset", version=None, env_id=env.id, fail_task_on_error=True).execute(context) + + first, again = routes + assert again[0] is sandbox and again[1] == first[1] and again[2]["valid_for"] == timedelta(seconds=3600) + assert context.deployed_envs[-1].metadata["computer"]["sites"]["valid_for_s"] == 3600 + + +@pytest.mark.asyncio +async def test_site_routing_with_no_website_deployed_or_outside_a_run_is_refused_before_anything_boots( + base, driver, docker, boots, routes): + env = _put(base, driver, display={"width": 1600, "height": 900}) + + with pytest.raises(ValueError, match="the run deployed none before the computer"): + await DeployEnvTaskStep(id="d", version=None, env_id=env.id, env_params={"site_routing": True}).execute( + TaskStepContext()) + with pytest.raises(DeployRefused, match="which a run's deploy_env names in env_params.sites"): + await Env.get(env.id).deploy(env_params={"site_routing": True}) + + assert boots.boots == [] and routes == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("env_params, problem", [ + ({"site_routing": True, "sites": [{"name": "a", "url": "http://gw/website/a/", "domains": ["x.test"]}, + {"name": "b", "url": "http://gw/website/b/", "domains": ["x.test"]}]}, + "env_params.sites: names claimed twice: 'x.test' (b) overlaps 'x.test' (a)"), + ({"sites": [{"name": "a", "url": "http://gw/website/a/"}]}, + "env_params.sites: a computer reaches sites only with site_routing"), + ({"site_routing": "yes"}, "env_params.site_routing"), +], ids=["conflict", "sites-alone", "not-a-flag"]) +async def test_sites_a_computer_cant_reach_by_their_names_are_refused_at_save_and_at_deploy( + base, driver, docker, boots, routes, env_params, problem): + env = _put(base, driver, display={"width": 1600, "height": 900}) + + saved = DeployEnvTaskStep(id="d", version=None, env_id=env.id, env_params=env_params).preflight() + with pytest.raises(DeployRefused, match=re.escape(problem)): + await Env.get(env.id).deploy(env_params=env_params) + + assert len(saved) == 1 and problem in saved[0] and boots.boots == [] and routes == [] + + @pytest.mark.asyncio async def test_teardown_closes_a_computer_env_ending_its_recordings_before_its_computer_goes( base, driver, docker, boots, monkeypatch): diff --git a/tst/unit/env/envs/test_computer_site_proxy.py b/tst/unit/env/envs/test_computer_site_proxy.py new file mode 100644 index 000000000..2bef37b38 --- /dev/null +++ b/tst/unit/env/envs/test_computer_site_proxy.py @@ -0,0 +1,596 @@ +"""The site proxy a computer reaches websites by their names through: what it forwards to a site's route on the +gateway and how, what it answers with, its certificates by name, its DNS for wildcard names, and what its install +writes. The proxy is a network program, so these tests open sockets, on loopback alone.""" + +from __future__ import annotations + +import asyncio +import base64 +import contextlib +import email.message +import io +import json +import socket +import ssl +import struct +import urllib.error +from datetime import timedelta +from pathlib import Path + +import pytest +import uvicorn +from agentenv_protocol.website import ApiContext, WebsiteEnvironment, api, website +from websockets.asyncio.client import connect +from websockets.exceptions import InvalidStatus + +from agent_env.env.envs.computer.utils import site_proxy, sites +from agent_env.env.envs.computer.utils.params import Site as SiteParams +from agent_env.env.envs.computer.utils.site_proxy import Config, Site, SiteProxy +from agent_env.env.gateway.constants import AGENT_ENV_SITE_ROOT_HEADER +from agent_env.env.gateway.gateway import Gateway +from tst.unit.env.gateway.website_websocket_test import EchoSite + +pytestmark = pytest.mark.usefixtures("socket_enabled") + + +def _ok(body=b"hello", extra=()): + async def respond(start, headers, request_body, reader, writer): + lines = ["HTTP/1.1 200 OK", f"Content-Length: {len(body)}", *(f"{k}: {v}" for k, v in extra)] + writer.write(("\r\n".join(lines) + "\r\n\r\n").encode() + body) + return respond + + +class Upstream: + """The gateway as the proxy sees it: each request recorded with its decoded body, answered by ``respond``.""" + + def __init__(self, respond=None): + self.requests, self.respond, self.server = [], respond or _ok(), None + + async def __aenter__(self): + self.server = await asyncio.start_server(self._handle, "127.0.0.1", 0) + self.port = self.server.sockets[0].getsockname()[1] + return self + + async def __aexit__(self, *exc): + self.server.close() + + async def _handle(self, reader, writer): + head = await reader.readuntil(b"\r\n\r\n") + start, headers = site_proxy.parse_head(head) + body = await _body(reader, headers) + self.requests.append((start, dict((k.lower(), v) for k, v in headers), body)) + await self.respond(start, headers, body, reader, writer) + await writer.drain() + writer.close() + + +async def _body(reader, headers): + if "chunked" in site_proxy.tokens(headers, "transfer-encoding"): + data = b"" + while (size := int((await reader.readuntil(b"\r\n")).split(b";")[0], 16)): + data += (await reader.readexactly(size + 2))[:-2] + await reader.readuntil(b"\r\n") + return data + length = site_proxy.header(headers, "content-length") + return await reader.readexactly(int(length)) if length else b"" + + +@contextlib.asynccontextmanager +async def _proxy(config, scheme="https"): + proxy = SiteProxy(config) + context = site_proxy.tls_context(config) if scheme == "https" else None + server = await asyncio.start_server(lambda r, w: proxy.handle(r, w, scheme), "127.0.0.1", 0, ssl=context) + try: + yield server.sockets[0].getsockname()[1] + finally: + server.close() + + +async def _exchange(port, request: bytes, *, server_hostname=None, cadata=None, reader_writer=None): + if reader_writer is None: + context = None + if cadata is not None: + context = ssl.create_default_context(cadata=cadata) + reader, writer = await asyncio.open_connection("127.0.0.1", port, ssl=context, + server_hostname=server_hostname if context else None) + else: + reader, writer = reader_writer + writer.write(request) + await writer.drain() + head = await reader.readuntil(b"\r\n\r\n") + start, headers = site_proxy.parse_head(head) + body = await _body(reader, headers) + return start, dict((k.lower(), v) for k, v in headers), body, (reader, writer) + + +def _issued(tmp_path, routes): + issued = sites.issue(routes, valid_for=timedelta(hours=1), label="test") + files = {} + for name, (cert, key) in issued.leaves.items(): + (tmp_path / f"{name}.crt").write_text(cert) + (tmp_path / f"{name}.key").write_text(key) + files[name] = (str(tmp_path / f"{name}.crt"), str(tmp_path / f"{name}.key")) + return issued, files + + +def _config(tmp_path, upstream_port, **domains_by_site): + domains_by_site = domains_by_site or {"notes": ["notes.test"]} + routes = [sites.Route(name, f"http://127.0.0.1:{upstream_port}/website/{name}/", tuple(domains)) + for name, domains in domains_by_site.items()] + issued, files = _issued(tmp_path, routes) + config = Config(user=None, sites=[Site(r.site, r.url, list(r.domains), *files.get(r.site, (None, None))) + for r in routes]) + return config, issued + + +@pytest.mark.asyncio +async def test_a_request_goes_to_the_sites_route_with_the_name_and_scheme_the_browser_used(tmp_path): + async with Upstream() as upstream: + config, issued = _config(tmp_path, upstream.port) + async with _proxy(config) as port: + start, headers, body, conn = await _exchange( + port, b"GET /notes/1?full=yes HTTP/1.1\r\nHost: notes.test\r\nX-Forwarded-Host: evil.example\r\n" + b"X-Forwarded-Prefix: /elsewhere\r\nAgentEnv-Site-Root: 0\r\n" + b"Cookie: s=1\r\nConnection: keep-alive\r\n\r\n", server_hostname="notes.test", + cadata=issued.ca_pem) + again = await _exchange(port, b"GET / HTTP/1.1\r\nHost: notes.test\r\n\r\n", reader_writer=conn) + conn[1].close() + + (first_start, first, _), (second_start, _, _) = upstream.requests + assert first_start == "GET /website/notes/notes/1?full=yes HTTP/1.1" and second_start.startswith( + "GET /website/notes/ ") + assert first["host"] == f"127.0.0.1:{upstream.port}" and first["x-forwarded-host"] == "notes.test" + assert (first["x-forwarded-proto"], first["cookie"], first["connection"]) == ("https", "s=1", "close") + assert "x-forwarded-prefix" not in first and first[AGENT_ENV_SITE_ROOT_HEADER.lower()] == "1" + assert site_proxy.SITE_ROOT_HEADER == AGENT_ENV_SITE_ROOT_HEADER + assert (start, body, headers["connection"]) == ("HTTP/1.1 200 OK", b"hello", "keep-alive") + assert again[2] == b"hello" + + +async def _serve(app) -> tuple[uvicorn.Server, int]: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + config = uvicorn.Config(app, lifespan="off", log_level="critical", access_log=False) + config.load() + server = uvicorn.Server(config) + server.lifespan = config.lifespan_class(config) + await server.startup(sockets=[sock]) + return server, sock.getsockname()[1] + + +@pytest.mark.asyncio +async def test_an_sdk_site_behind_the_real_gateway_names_its_own_root_and_keeps_its_card_hidden(tmp_path): + @website(domains=["notes.test"]) + class Notes(WebsiteEnvironment): + @api.get("/api/whoami") + def whoami(self, ctx: ApiContext) -> dict: + return {"base_url": ctx.base_url} + + site, site_port = await _serve(Notes().create_app()) + gateway = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], + rest_proxy_urls={"notes": f"http://127.0.0.1:{site_port}"}, + website_urls={"notes": f"http://127.0.0.1:{site_port}"}) + gw, gw_port = await _serve(gateway._mcp.streamable_http_app()) + answers = [] + try: + config, issued = _config(tmp_path, gw_port) + async with _proxy(config) as port: + for path in ("/api/whoami", "/api/whoami/", "/.well-known/agent-env.json"): + start, headers, body, conn = await _exchange( + port, f"GET {path} HTTP/1.1\r\nHost: notes.test\r\nConnection: close\r\n\r\n".encode(), + server_hostname="notes.test", cadata=issued.ca_pem) + conn[1].close() + answers.append((start, headers.get("location"), body)) + finally: + await gw.shutdown() + await site.shutdown() + + (whoami, redirect, card) = answers + assert json.loads(whoami[2]) == {"base_url": "https://notes.test"} + assert redirect[:2] == ("HTTP/1.1 307 Temporary Redirect", "https://notes.test/api/whoami") + assert card[0].startswith("HTTP/1.1 404") + + +@pytest.mark.asyncio +async def test_a_redirect_or_cookie_naming_the_route_is_rewritten_onto_the_sites_name(tmp_path): + async def respond(start, headers, body, reader, writer): + location = f"http://127.0.0.1:{upstream.port}/website/notes/signed-in?next=%2F#top" + writer.write(f"HTTP/1.1 302 Found\r\nLocation: {location}\r\nSet-Cookie: s=1; Domain=127.0.0.1; " + f"Path=/website/notes/app; HttpOnly\r\nAlt-Svc: h3=\":443\"\r\nContent-Length: 0\r\n\r\n".encode()) + + async with Upstream(respond) as upstream: + config, issued = _config(tmp_path, upstream.port) + async with _proxy(config) as port: + _, headers, _, conn = await _exchange(port, b"POST /login HTTP/1.1\r\nHost: notes.test\r\n" + b"Content-Length: 4\r\n\r\nuser", server_hostname="notes.test", + cadata=issued.ca_pem) + conn[1].close() + + assert headers["location"] == "https://notes.test/signed-in?next=%2F#top" + assert headers["set-cookie"] == "s=1; Path=/app; HttpOnly" and "alt-svc" not in headers + assert upstream.requests[0][2] == b"user" + + +@pytest.mark.parametrize("location, expected", [ + ("/website/notes/a", "/a"), + ("/website/notes", "/"), + ("http://gw:8080/website/notes/a?b=1", "https://notes.test/a?b=1"), + ("http://gw:8080/website/other/a", "http://gw:8080/website/other/a"), + ("http://gw:8080/svc/notes/x", "http://gw:8080/svc/notes/x"), + ("https://gw:8080/website/notes/a", "https://gw:8080/website/notes/a"), + ("https://notes.test/a", "https://notes.test/a"), + ("/a", "/a"), + ("a/b", "a/b"), + ("//gw:8080/website/notes/a", "//gw:8080/website/notes/a"), + ("https://notes.test/website/notes/app/", "https://notes.test/app/"), + ("https://notes.test:443/website/notes", "https://notes.test/"), + ("http://notes.test/website/notes/a", "http://notes.test/website/notes/a"), +], ids=["path", "prefix-alone", "gateway-url", "another-site", "outside-routes", "other-scheme", "site-url", + "site-path", "relative", "scheme-relative", "site-url-under-prefix", "site-url-default-port", + "site-name-other-scheme"]) +def test_only_a_location_on_the_sites_route_is_moved_onto_its_name(location, expected): + site = Site("notes", "http://gw:8080/website/notes/", ["notes.test"]) + + assert site_proxy.public_location(location, site, "https", "notes.test") == expected + + +@pytest.mark.asyncio +async def test_a_chunked_upload_and_a_chunked_answer_are_relayed_as_framed(tmp_path): + async def respond(start, headers, body, reader, writer): + writer.write(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nevent\r\n3\r\n: 1\r\n0\r\n\r\n") + + async with Upstream(respond) as upstream: + config, issued = _config(tmp_path, upstream.port) + async with _proxy(config) as port: + _, headers, body, conn = await _exchange( + port, b"POST /api HTTP/1.1\r\nHost: notes.test\r\nTransfer-Encoding: chunked\r\n\r\n" + b"4\r\nabcd\r\n2\r\nef\r\n0\r\n\r\n", server_hostname="notes.test", cadata=issued.ca_pem) + conn[1].close() + + assert upstream.requests[0][2] == b"abcdef" and upstream.requests[0][1]["transfer-encoding"] == "chunked" + assert (headers["transfer-encoding"], body) == ("chunked", b"event: 1") + + +@pytest.mark.asyncio +async def test_an_answer_with_no_length_is_relayed_to_its_end_and_closes_the_connection(tmp_path): + async def respond(start, headers, body, reader, writer): + writer.write(b"HTTP/1.1 200 OK\r\n\r\nall of it") + + async with Upstream(respond) as upstream: + config, issued = _config(tmp_path, upstream.port) + async with _proxy(config) as port: + reader, writer = await asyncio.open_connection("127.0.0.1", port, ssl=ssl.create_default_context( + cadata=issued.ca_pem), server_hostname="notes.test") + writer.write(b"GET / HTTP/1.1\r\nHost: notes.test\r\n\r\n") + answer = await reader.read() + writer.close() + + assert answer.startswith(b"HTTP/1.1 200 OK\r\n") and b"Connection: close" in answer + assert answer.endswith(b"\r\n\r\nall of it") + + +@pytest.mark.asyncio +async def test_a_websocket_upgrade_is_relayed_both_ways(tmp_path): + async def respond(start, headers, body, reader, writer): + writer.write(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + b"Sec-WebSocket-Accept: x\r\n\r\n") + await writer.drain() + data = await reader.readexactly(4) + writer.write(data[::-1]) + + async with Upstream(respond) as upstream: + config, issued = _config(tmp_path, upstream.port) + async with _proxy(config) as port: + reader, writer = await asyncio.open_connection("127.0.0.1", port, ssl=ssl.create_default_context( + cadata=issued.ca_pem), server_hostname="notes.test") + writer.write(b"GET /live HTTP/1.1\r\nHost: notes.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n" + b"Sec-WebSocket-Key: k\r\n\r\n") + head = await reader.readuntil(b"\r\n\r\n") + writer.write(b"ping") + echoed = await asyncio.wait_for(reader.readexactly(4), 5) + writer.close() + + request = upstream.requests[0][1] + assert (request["connection"], request["upgrade"], request["sec-websocket-key"]) == ("Upgrade", "websocket", "k") + assert head.startswith(b"HTTP/1.1 101 ") and b"Connection: Upgrade" in head and echoed == b"gnip" + + +@pytest.mark.asyncio +async def test_a_browsers_websocket_reaches_the_site_through_the_real_gateway_under_the_sites_name(tmp_path): + site = EchoSite() + site_server, site_port = await _serve(site.app) + gateway = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], + website_urls={"notes": f"http://127.0.0.1:{site_port}"}) + gw, gw_port = await _serve(gateway._mcp.streamable_http_app()) + try: + config, issued = _config(tmp_path, gw_port) + async with _proxy(config) as port: + context = ssl.create_default_context(cadata=issued.ca_pem) + async with connect("wss://notes.test/live", host="127.0.0.1", port=port, ssl=context) as websocket: + await websocket.send("hello") + echoed = await asyncio.wait_for(websocket.recv(), 5) + with pytest.raises(InvalidStatus) as refused: + async with connect("wss://notes.test/agentenv", host="127.0.0.1", port=port, ssl=context): + pass + finally: + await gw.shutdown() + await site_server.shutdown() + + ((path, headers),) = site.handshakes + assert echoed == "HELLO" and path == "/live" and refused.value.response.status_code == 404 + assert (headers["x-forwarded-host"], headers["x-forwarded-proto"]) == ("notes.test", "https") + assert "x-forwarded-prefix" not in headers + + +@pytest.mark.asyncio +async def test_http_redirects_a_name_served_over_https_and_serves_a_localhost_name_as_it_is(tmp_path): + async with Upstream() as upstream: + config, _ = _config(tmp_path, upstream.port, notes=["notes.test", "notes.localhost"]) + async with _proxy(config, scheme="http") as port: + redirected = await _exchange(port, b"GET /a?b=1 HTTP/1.1\r\nHost: notes.test\r\n\r\n") + served = await _exchange(port, b"GET /a HTTP/1.1\r\nHost: notes.localhost:80\r\n\r\n") + for answer in (redirected, served): + answer[3][1].close() + + assert redirected[0].startswith("HTTP/1.1 308 ") and redirected[1]["location"] == "https://notes.test/a?b=1" + ((_, request, _),) = upstream.requests + assert (request["x-forwarded-host"], request["x-forwarded-proto"]) == ("notes.localhost:80", "http") + assert served[2] == b"hello" + + +@pytest.mark.asyncio +async def test_a_name_no_site_has_is_misdirected_and_an_unreachable_site_is_a_bad_gateway(tmp_path): + with socket.socket() as closed: + closed.bind(("127.0.0.1", 0)) + dead = closed.getsockname()[1] + config, _ = _config(tmp_path, dead, notes=["notes.localhost"]) + async with _proxy(config, scheme="http") as port: + unknown = await _exchange(port, b"GET / HTTP/1.1\r\nHost: other.localhost\r\n\r\n") + unreachable = await _exchange(port, b"GET / HTTP/1.1\r\nHost: notes.localhost\r\n\r\n") + + assert unknown[0].startswith("HTTP/1.1 421 ") and b"other.localhost is no site" in unknown[2] + assert unreachable[0].startswith("HTTP/1.1 502 ") and b"the site notes can't be reached" in unreachable[2] + assert unknown[1]["x-agentenv-site-proxy"] == unreachable[1]["x-agentenv-site-proxy"] == "failed" + + +def test_a_check_answered_by_the_proxys_own_bad_gateway_fails_and_a_sites_own_error_status_loads(monkeypatch): + answers = {"notes.test": site_proxy.FAILED, "chat.test": ()} + + class Opener: + def open(self, url, timeout): + name = url.split("/")[2] + headers = email.message.Message() + for key, value in answers[name]: + headers[key] = value + raise urllib.error.HTTPError(url, 502, "Bad Gateway", headers, io.BytesIO(b"the site can't be reached\n")) + + monkeypatch.setattr(site_proxy.urllib.request, "build_opener", lambda *handlers: Opener()) + config = Config(user=None, sites=[Site("notes", "http://gw/website/notes/", ["notes.test"]), + Site("chat", "http://gw/website/chat/", ["chat.test"])]) + + assert site_proxy.check(config) == { + "notes.test": {"url": "https://notes.test/", "error": "502 Bad Gateway: the site can't be reached"}, + "chat.test": {"url": "https://chat.test/", "status": 502}} + + +@pytest.mark.asyncio +async def test_each_name_is_served_the_certificate_of_its_own_site_and_a_wildcard_covers_one_label(tmp_path): + async with Upstream() as upstream: + config, issued = _config(tmp_path, upstream.port, notes=["notes.test"], chat=["chat.test", "*.chat.test"]) + async with _proxy(config) as port: + seen = {} + for name in ("notes.test", "chat.test", "eu.chat.test"): + reader, writer = await asyncio.open_connection("127.0.0.1", port, ssl=ssl.create_default_context( + cadata=issued.ca_pem), server_hostname=name) + seen[name] = writer.get_extra_info("peercert")["subject"][0][0][1] + writer.close() + with pytest.raises(ssl.SSLCertVerificationError): + await asyncio.open_connection("127.0.0.1", port, ssl=ssl.create_default_context(cadata=issued.ca_pem), + server_hostname="a.eu.chat.test") + + assert seen == {"notes.test": "notes.test", "chat.test": "chat.test", "eu.chat.test": "chat.test"} + + +def test_a_site_is_found_by_its_exact_name_before_a_wildcard_and_a_wildcard_covers_one_label(): + config = Config(user=None, sites=[Site("wild", "http://gw/website/wild/", ["*.chat.test"]), + Site("eu", "http://gw/website/eu/", ["eu.chat.test"])]) + + assert [getattr(config.site_for(name), "name", None) for name in ("eu.chat.test", "us.chat.test", "chat.test", + "a.us.chat.test", "US.chat.test.")] == [ + "eu", "wild", None, None, "wild"] + + +def _query(name: str, qtype: int) -> bytes: + labels = b"".join(bytes([len(part)]) + part.encode() for part in name.split(".")) + return struct.pack("!HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0) + labels + b"\0" + struct.pack("!HH", qtype, 1) + + +@pytest.mark.asyncio +async def test_dns_answers_a_sites_wildcard_name_with_loopback_and_passes_every_other_question_on(): + passed = [] + + class Nameserver(asyncio.DatagramProtocol): + def connection_made(self, transport): + self.transport = transport + + def datagram_received(self, data, addr): + passed.append(data) + self.transport.sendto(data[:2] + b"\x81\x80" + data[4:], addr) + + loop = asyncio.get_running_loop() + upstream, _ = await loop.create_datagram_endpoint(Nameserver, local_addr=("127.0.0.1", 0)) + resolver = site_proxy.Resolver(Config(user=None, sites=[Site("chat", "http://gw/website/chat/", ["*.chat.test"])], + nameservers=["127.0.0.1"])) + answered = [] + resolver.transport = type("Transport", (), {"sendto": lambda self, data, addr: answered.append(data)})() + try: + with pytest.MonkeyPatch.context() as patch: + patch.setattr(site_proxy, "NAMESERVER_PORT", upstream.get_extra_info("sockname")[1]) + for name, qtype in (("eu.chat.test", 1), ("eu.chat.test", 28), ("example.org", 1)): + await resolver._answer(_query(name, qtype), None) + finally: + upstream.close() + + a, aaaa, other = answered + assert struct.unpack("!HHHH", a[:8]) == (0x1234, 0x8580, 1, 1) and a.endswith(socket.inet_aton("127.0.0.1")) + assert struct.unpack("!HHHH", aaaa[:8]) == (0x1234, 0x8580, 1, 0) + assert passed == [_query("example.org", 1)] and other[2:4] == b"\x81\x80" + + +@pytest.mark.asyncio +async def test_dns_over_tcp_answers_a_sites_name_and_passes_every_other_question_on_over_tcp(): + passed = [] + + async def nameserver(reader, writer): + (size,) = struct.unpack("!H", await reader.readexactly(2)) + query = await reader.readexactly(size) + passed.append(query) + reply = query[:2] + b"\x81\x80" + query[4:] + writer.write(struct.pack("!H", len(reply)) + reply) + await writer.drain() + writer.close() + + upstream = await asyncio.start_server(nameserver, "127.0.0.1", 0) + resolver = site_proxy.Resolver(Config(user=None, sites=[Site("chat", "http://gw/website/chat/", ["*.chat.test"])], + nameservers=["127.0.0.1"])) + server = await asyncio.start_server(resolver.handle_stream, "127.0.0.1", 0) + answered = [] + try: + with pytest.MonkeyPatch.context() as patch: + patch.setattr(site_proxy, "NAMESERVER_PORT", upstream.sockets[0].getsockname()[1]) + reader, writer = await asyncio.open_connection("127.0.0.1", server.sockets[0].getsockname()[1]) + for name in ("eu.chat.test", "example.org"): + query = _query(name, 1) + writer.write(struct.pack("!H", len(query)) + query) + await writer.drain() + (size,) = struct.unpack("!H", await reader.readexactly(2)) + answered.append(await reader.readexactly(size)) + writer.close() + finally: + server.close() + upstream.close() + + local, other = answered + assert struct.unpack("!HHHH", local[:8]) == (0x1234, 0x8580, 1, 1) + assert local.endswith(socket.inet_aton("127.0.0.1")) + assert passed == [_query("example.org", 1)] and other[2:4] == b"\x81\x80" + + +@pytest.mark.asyncio +async def test_dns_with_no_nameserver_to_pass_on_to_still_answers_a_sites_name_and_fails_every_other_at_once(): + resolver = site_proxy.Resolver(Config(user=None, sites=[Site("chat", "http://gw/website/chat/", ["*.chat.test"])])) + + local = await resolver.answer(_query("eu.chat.test", 1), stream=False) + other = await resolver.answer(_query("example.org", 1), stream=True) + + assert struct.unpack("!HHHH", local[:8]) == (0x1234, 0x8580, 1, 1) + assert struct.unpack("!HHHH", other[:8]) == (0x1234, 0x8182, 1, 0) and other[12:] == _query("example.org", 1)[12:] + + +def test_the_hosts_block_is_replaced_not_added_to_and_an_empty_one_is_removed(): + first = site_proxy.hosts_text("127.0.0.1 localhost\n", ["notes.test", "chat.test"]) + again = site_proxy.hosts_text(first + "10.0.0.1 db\n", ["notes.test"]) + + assert first == ("127.0.0.1 localhost\n# agentenv sites\n127.0.0.1 notes.test\n127.0.0.1 chat.test\n" + "# end agentenv sites\n") + assert again == "127.0.0.1 localhost\n10.0.0.1 db\n# agentenv sites\n127.0.0.1 notes.test\n# end agentenv sites\n" + assert site_proxy.hosts_text(again, []) == "127.0.0.1 localhost\n10.0.0.1 db\n" + + +def test_resolv_conf_asks_loopback_first_and_keeps_its_nameservers_to_pass_the_rest_to(): + text, passed = site_proxy.resolv_text("search corp\nnameserver 127.0.0.11\nnameserver 8.8.8.8\noptions ndots:0\n") + + assert text == "nameserver 127.0.0.1\nsearch corp\noptions ndots:0\n" and passed == ["127.0.0.11", "8.8.8.8"] + + +def test_firefoxs_policies_gain_the_ca_and_keep_every_other_policy(): + merged = json.loads(site_proxy.firefox_policies(json.dumps({"policies": {"DisableTelemetry": True}}), "/ca.pem")) + + assert merged == {"policies": {"DisableTelemetry": True, "Certificates": {"Install": ["/ca.pem"]}}} + assert json.loads(site_proxy.firefox_policies(json.dumps(merged), "/ca.pem")) == merged + + +def test_install_writes_the_sites_files_names_and_trust_then_starts_the_proxy_on_them(tmp_path, monkeypatch): + root, folder = tmp_path / "root", tmp_path / "sites" + (root / "etc").mkdir(parents=True) + folder.mkdir() + (root / "etc/hosts").write_text("127.0.0.1 localhost\n") + (root / "etc/resolv.conf").write_text("nameserver 127.0.0.11\n") + folder.chmod(0o700) + table = sites.site_table([SiteParams(name="notes", url="http://gw/website/notes/", domains=["notes.test"]), + SiteParams(name="chat", url="http://gw/website/chat/", domains=["*.chat.test"]), + SiteParams(name="bare", url="http://gw/website/bare/")]) + issued = sites.issue(table.routes, valid_for=timedelta(hours=1), label="t") + (folder / "config.json").write_text(json.dumps(sites.proxy_config(table, issued, user=None))) + started = [] + monkeypatch.setattr(site_proxy, "start", lambda path, log: started.append(Config.load(path))) + monkeypatch.setattr(site_proxy, "checks_as", lambda user, path: {"notes.test": {"status": 200}}) + monkeypatch.setattr(site_proxy.shutil, "which", lambda tool: None) + + report = site_proxy.install(str(folder / "config.json"), site_proxy.Paths(root)) + + (config,) = started + assert [(s.name, s.domains, bool(s.cert)) for s in config.sites] == [ + ("notes", ["notes.test"], True), ("chat", ["*.chat.test"], True), ("bare", ["bare.localhost"], False)] + assert (config.dns_port, config.nameservers) == (53, ["127.0.0.11"]) + assert [(folder / name).stat().st_mode & 0o777 for name in ("notes.key", "config.json")] == [0o600, 0o600] + assert folder.stat().st_mode & 0o777 == 0o755 + assert "key_pem" not in (folder / "serve.json").read_text() + assert (root / "etc/hosts").read_text().splitlines()[2:4] == ["127.0.0.1 notes.test", "127.0.0.1 bare.localhost"] + assert (root / "etc/resolv.conf").read_text() == "nameserver 127.0.0.1\n" + assert json.loads((root / "etc/firefox/policies/policies.json").read_text())["policies"]["Certificates"] == { + "Install": [str(folder / "ca.pem")]} + assert (folder / "ca.pem").read_text() == issued.ca_pem + for policy in ("etc/opt/chrome_for_testing", "etc/chromium", "etc/chromium-browser", "etc/opt/chrome"): + (der,) = json.loads((root / policy / "policies/managed/agentenv-sites.json").read_text())["CACertificates"] + assert base64.b64decode(der) == ssl.PEM_cert_to_DER_cert(issued.ca_pem) + assert report["trust"]["system"] is None and report["trust"]["nss"] == [] + assert len(report["trust"]["chromium"]) == 4 + assert report["checks"] == {"notes.test": {"status": 200}} + + +def test_the_checks_are_given_each_sites_timeout_in_turn(tmp_path, monkeypatch): + serve_path = tmp_path / "serve.json" + serve_path.write_text(json.dumps({"user": None, "sites": [ + {"name": f"s{i}", "upstream": f"http://gw/website/s{i}/", "domains": [f"s{i}.localhost"]} for i in range(7)]})) + given = [] + + def run(argv, **kwargs): + given.append(kwargs["timeout"]) + return site_proxy.subprocess.CompletedProcess(argv, 0, stdout="{}", stderr="") + + monkeypatch.setattr(site_proxy.subprocess, "run", run) + site_proxy.checks_as(None, str(serve_path)) + + assert given[0] >= site_proxy.CHECK_TIMEOUT_S * 7 + + +@pytest.mark.parametrize("resolv", [None, "", "nameserver 127.0.0.1\n"], ids=["missing", "empty", "loopback-only"]) +def test_a_wildcard_name_starts_dns_even_when_resolv_conf_names_no_other_nameserver(tmp_path, monkeypatch, resolv): + root, folder = tmp_path / "root", tmp_path / "sites" + (root / "etc").mkdir(parents=True) + folder.mkdir() + if resolv is not None: + (root / "etc/resolv.conf").write_text(resolv) + table = sites.site_table([SiteParams(name="chat", url="http://gw/website/chat/", domains=["*.chat.test"])]) + issued = sites.issue(table.routes, valid_for=timedelta(hours=1), label="t") + (folder / "config.json").write_text(json.dumps(sites.proxy_config(table, issued, user=None))) + started = [] + monkeypatch.setattr(site_proxy, "start", lambda path, log: started.append(Config.load(path))) + monkeypatch.setattr(site_proxy, "checks_as", lambda user, path: {}) + monkeypatch.setattr(site_proxy.shutil, "which", lambda tool: None) + + site_proxy.install(str(folder / "config.json"), site_proxy.Paths(root)) + + (config,) = started + assert (config.dns_port, config.nameservers) == (53, []) + assert (root / "etc/resolv.conf").read_text() == "nameserver 127.0.0.1\n" + + +def test_the_proxy_is_standard_library_alone_so_the_bases_own_python_runs_it(): + source = Path(site_proxy.__file__).read_text() + imported = {line.split()[1].split(".")[0] for line in source.splitlines() + if line.startswith(("import ", "from ")) and not line.startswith("from __future__")} + + assert imported <= {"asyncio", "json", "os", "pwd", "shutil", "socket", "ssl", "struct", "subprocess", "sys", + "time", "urllib", "dataclasses", "pathlib", "typing"} diff --git a/tst/unit/env/envs/test_computer_sites.py b/tst/unit/env/envs/test_computer_sites.py new file mode 100644 index 000000000..d77539b51 --- /dev/null +++ b/tst/unit/env/envs/test_computer_sites.py @@ -0,0 +1,404 @@ +"""Websites in the computer: the names a deployment's sites claim and which wins, the CA and certificates made for a +deploy, how the proxy is installed through the sandbox's root channel, and the sites a run deployed.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +import httpx +import pytest +from cryptography import x509 +from cryptography.x509.verification import PolicyBuilder, Store, VerificationError + +from agent_env.env.envs.computer.utils import sites +from agent_env.env.envs.computer.utils.params import Site +from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, VmSandbox + + +def _site(name, *domains, live=False, allow_hosts=()): + return Site(name=name, url=f"https://gw.example/website/{name}/", domains=list(domains), live=live, + allow_hosts=list(allow_hosts)) + + +def test_a_synthetic_sites_names_are_routed_and_a_site_declaring_none_is_its_name_on_localhost(): + table = sites.site_table([_site("notes", "notes.test"), _site("slack", "slack.com", "*.slack.com"), + _site("bare")]) + + assert [(r.site, r.domains, r.tls_names) for r in table.routes] == [ + ("notes", ("notes.test",), ("notes.test",)), + ("slack", ("slack.com", "*.slack.com"), ("slack.com", "*.slack.com")), + ("bare", ("bare.localhost",), ())] + assert table.unrouted == {} and table.shadowed == [] + + +def test_a_synthetic_name_wins_over_a_live_one_which_wins_over_the_allowlist(): + table = sites.site_table([_site("gh-live", "github.com", "api.github.com", live=True, + allow_hosts=["*.githubassets.com"]), + _site("gh", "github.com")], + allow_hosts=["github.com", "*.githubassets.com", "*.wikipedia.org"]) + + assert [(r.site, r.domains) for r in table.routes] == [("gh", ("github.com",))] + assert table.unrouted == {"api.github.com": "live", "*.githubassets.com": "live", "*.wikipedia.org": "allowlist"} + assert table.to_dict()["shadowed"] == [ + {"name": "github.com", "rank": "live", "owner": "gh-live", "by": "gh"}, + {"name": "github.com", "rank": "allowlist", "owner": "network", "by": "gh"}, + {"name": "*.githubassets.com", "rank": "allowlist", "owner": "network", "by": "gh-live"}] + + +def test_an_allowlisted_wildcard_a_site_takes_one_name_of_stays_for_the_rest(): + table = sites.site_table([_site("app", "app.slack.com")], allow_hosts=["*.slack.com"]) + + assert table.unrouted == {"*.slack.com": "allowlist"} and len(table.shadowed) == 1 + + +def test_a_site_named_with_capitals_or_underscores_declaring_none_is_reached_by_a_dns_name_on_localhost(): + table = sites.site_table([_site("my_site"), _site("Notes"), _site("-edge_")]) + + assert [(r.site, r.domains) for r in table.routes] == [ + ("my_site", ("my-site.localhost",)), ("Notes", ("notes.localhost",)), ("-edge_", ("edge.localhost",))] + + +def test_two_sites_given_one_fallback_name_are_refused(): + with pytest.raises(sites.SiteConflict, match=r"'my-site[.]localhost' \(my-site\) overlaps 'my-site[.]localhost' " + r"\(my_site\)"): + sites.site_table([_site("my_site"), _site("my-site")]) + + +@pytest.mark.parametrize("claimed, conflict", [ + ([_site("a", "notes.test"), _site("b", "notes.test")], "'notes.test' (b) overlaps 'notes.test' (a)"), + ([_site("a", "*.slack.com"), _site("b", "eu.slack.com")], "'eu.slack.com' (b) overlaps '*.slack.com' (a)"), + ([_site("a", "x.test", live=True), _site("b", "x.test", live=True)], "'x.test' (b) overlaps 'x.test' (a)"), +], ids=["synthetic", "wildcard", "live"]) +def test_two_sites_of_one_kind_claiming_a_name_are_refused(claimed, conflict): + with pytest.raises(sites.SiteConflict, match=conflict.replace("*", r"\*").replace(".", "[.]").replace("(", r"\(") + .replace(")", r"\)")): + sites.site_table(claimed) + + +def test_a_wildcard_and_a_name_one_label_deeper_or_its_apex_dont_overlap(): + assert not sites.overlap("*.slack.com", "slack.com") and not sites.overlap("*.slack.com", "a.b.slack.com") + assert sites.overlap("*.slack.com", "eu.slack.com") and sites.overlap("eu.slack.com", "*.slack.com") + + +@pytest.mark.parametrize("name, problem", [ + ("Notes.test", "must be lowercase"), ("notes.test.", "no trailing dot"), ("10.0.0.1", "an address"), + ("*.test", "a wildcard over a single label"), ("a.*.test", "isn't a DNS name"), + ("no_underscore.test", "isn't a DNS"), +]) +def test_a_name_that_cant_be_a_sites_is_refused(name, problem): + with pytest.raises(ValueError, match=problem): + sites.site_table([_site("a", name)]) + + +def test_two_sites_of_one_name_are_refused(): + with pytest.raises(ValueError, match="two sites are named 'a'"): + sites.site_table([_site("a", "a.test"), _site("a", "b.test")]) + + +def _verify(ca_pem, cert_pem, name, now): + ca = x509.load_pem_x509_certificate(ca_pem.encode()) + verifier = PolicyBuilder().store(Store([ca])).time(now).build_server_verifier(x509.DNSName(name)) + return verifier.verify(x509.load_pem_x509_certificate(cert_pem.encode()), []) + + +def test_a_deploys_ca_signs_each_sites_names_and_is_constrained_to_them(): + now = datetime.now(UTC).replace(microsecond=0) + table = sites.site_table([_site("notes", "notes.test"), _site("slack", "slack.com", "*.slack.com"), + _site("bare")]) + + issued = sites.issue(table.routes, valid_for=timedelta(hours=3), label="desktop", now=now) + + assert set(issued.leaves) == {"notes", "slack"} + ca = x509.load_pem_x509_certificate(issued.ca_pem.encode()) + constraints = ca.extensions.get_extension_for_class(x509.NameConstraints).value + assert [n.value for n in constraints.permitted_subtrees] == ["notes.test", "slack.com"] + assert ca.not_valid_after_utc == now + timedelta(hours=3) + sites.VALID_MARGIN + for site, name in (("notes", "notes.test"), ("slack", "slack.com"), ("slack", "eu.slack.com")): + _verify(issued.ca_pem, issued.leaves[site][0], name, now) + with pytest.raises(VerificationError): + _verify(issued.ca_pem, issued.leaves["notes"][0], "slack.com", now) + assert "PRIVATE KEY" in issued.leaves["notes"][1] and "PRIVATE KEY" not in issued.ca_pem + + +def test_no_ca_is_made_when_no_name_is_served_over_https(): + assert sites.issue(sites.site_table([_site("bare")]).routes, valid_for=timedelta(hours=1), label="x") == \ + sites.Issued(None, {}) + + +class FakeVm(VmSandbox): + """A VM sandbox whose container's root channel is recorded, the proxy's install answering ``report``.""" + + type = "fake" + sandbox_id = "vm-1" + tunnel_urls = {} + vnc_url = None + + def __init__(self, report): + self.report, self.scripts, self.written, self.events = report, [], {}, [] + + @property + def container_name(self): + return "computer-1" + + def url_from_sandbox(self, url): + return url.replace("https://gw.example", "http://host.docker.internal:8080") + + async def terminate(self): + pass + + async def write_file_from_text(self, content, destination_path, container=None): + self.written[destination_path] = content + self.events.append(("write", destination_path)) + + async def exec_script(self, script, *, max_retries=0): + self.scripts.append(script) + self.events.append(script) + return "noise\n" + json.dumps(self.report) + "\n" + + +_REPORT = {"trust": {"system": "/usr/local/share/ca-certificates/agentenv-sites.crt", "nss": []}, + "names": ["notes.test"], "checks": {"notes.test": {"url": "https://notes.test/", "status": 200}}} + + +@pytest.mark.asyncio +async def test_route_installs_the_proxy_as_root_with_each_sites_route_as_the_computer_reaches_it(): + sandbox = FakeVm(_REPORT) + + routed = await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, + valid_for=timedelta(hours=1), label="desktop") + + assert set(sandbox.written) == {"/etc/agentenv/sites/site_proxy.py", "/etc/agentenv/sites/config.json"} + config = json.loads(sandbox.written["/etc/agentenv/sites/config.json"]) + (site,) = config["sites"] + assert (config["user"], site["upstream"], site["domains"]) == ( + "user", "http://host.docker.internal:8080/website/notes/", ["notes.test"]) + assert site["key_pem"] and config["ca_pem"] == routed["ca"] + assert sandbox.scripts == ["docker exec -u 0 computer-1 mkdir -p /etc/agentenv/sites", + "docker exec -u 0 computer-1 chmod 0700 /etc/agentenv/sites", + "docker exec -u 0 computer-1 python3 /etc/agentenv/sites/site_proxy.py install " + "/etc/agentenv/sites/config.json"] + assert routed["checks"] == _REPORT["checks"] and routed["names"]["routes"] == {"notes": ["notes.test"]} + assert "PRIVATE KEY" not in json.dumps(routed) + + +@pytest.mark.asyncio +async def test_the_sites_keys_land_in_a_folder_root_alone_can_enter(): + sandbox = FakeVm(_REPORT) + + await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, + valid_for=timedelta(hours=1), label="desktop") + + private = sandbox.events.index("docker exec -u 0 computer-1 chmod 0700 /etc/agentenv/sites") + assert private < sandbox.events.index(("write", "/etc/agentenv/sites/config.json")) + assert "key_pem" in sandbox.written["/etc/agentenv/sites/config.json"] + + +@pytest.mark.asyncio +async def test_a_name_that_doesnt_load_in_the_computer_fails_the_route(): + report = {**_REPORT, "checks": {"notes.test": {"url": "https://notes.test/", "error": "certificate verify failed"}}} + + with pytest.raises(RuntimeError, match="https://notes.test/ didn't load in the computer: certificate verify"): + await sites.route(FakeVm(report), [_site("notes", "notes.test")], user="user", network=None, + valid_for=timedelta(hours=1), label="desktop") + + +@pytest.mark.asyncio +async def test_a_network_that_keeps_the_computer_from_the_gateway_is_refused_before_anything_is_installed(): + sandbox = FakeVm(_REPORT) + + with pytest.raises(RuntimeError, match="allows only api.example, so it can't reach the gateway at " + "host.docker.internal"): + await sites.route(sandbox, [_site("notes", "notes.test")], user="user", + network={"mode": "allowlist", "allow_hosts": ["api.example"]}, valid_for=timedelta(hours=1), + label="desktop") + + assert sandbox.scripts == [] and sandbox.written == {} + + +@pytest.mark.asyncio +async def test_a_gateway_the_sandboxs_applied_network_allows_by_its_address_range_is_reached(): + sandbox = FakeVm(_REPORT) + sandbox.url_from_sandbox = lambda url: url.replace("https://gw.example", "http://10.0.0.7:8080") + sandbox.network_policy = NetworkPolicy(mode="allowlist", allow_hosts=("api.example",), + allow_cidrs=("10.0.0.0/24",)) + + routed = await sites.route(sandbox, [_site("notes", "notes.test")], user="user", + network={"mode": "allowlist", "allow_hosts": ["api.example"]}, + valid_for=timedelta(hours=1), label="desktop") + + assert routed["checks"] == _REPORT["checks"] + routes = [sites.Route("notes", "http://10.0.0.7:8080/website/notes/", ("notes.test",))] + assert sites.reachable_problem(NetworkPolicy(mode="allowlist", allow_cidrs=("10.1.0.0/24",)), routes) == ( + "the computer's network allows only 10.1.0.0/24, so it can't reach the gateway at 10.0.0.7 its sites are on") + assert sites.reachable_problem(NetworkPolicy(mode="allowlist", allow_hosts=("*.internal", "10.0.0.7")), + routes) is None + + +@pytest.mark.asyncio +async def test_a_sandbox_that_is_the_container_is_reached_as_root_through_its_own_exec(): + calls = [] + + async def exec_with_output(*argv): + calls.append(argv) + return 0, {"python3": json.dumps(_REPORT), "id": "0\n"}.get(argv[0], ""), "" + + async def write_file_from_text(content, path): + calls.append(("write", path)) + + sandbox = SimpleNamespace(url_from_sandbox=lambda url: url, exec_with_output=exec_with_output, + write_file_from_text=write_file_from_text) + + await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, + valid_for=timedelta(hours=1), label="desktop") + + assert calls == [("id", "-u"), ("mkdir", "-p", "/etc/agentenv/sites"), ("chmod", "0700", "/etc/agentenv/sites"), + ("mkdir", "-p", "/etc/agentenv/sites"), ("write", "/etc/agentenv/sites/site_proxy.py"), + ("mkdir", "-p", "/etc/agentenv/sites"), ("write", "/etc/agentenv/sites/config.json"), + ("python3", "/etc/agentenv/sites/site_proxy.py", "install", "/etc/agentenv/sites/config.json")] + + +@pytest.mark.asyncio +async def test_a_container_sandbox_that_runs_the_computer_as_its_user_is_refused_before_anything_is_written(): + calls = [] + + async def exec_with_output(*argv): + calls.append(argv) + return 0, "1000\n", "" + + async def write_file_from_text(content, path): + calls.append(("write", path)) + + sandbox = SimpleNamespace(type="coreweave", url_from_sandbox=lambda url: url, exec_with_output=exec_with_output, + write_file_from_text=write_file_from_text) + + with pytest.raises(RuntimeError, match="the coreweave sandbox runs the computer's commands as uid 1000"): + await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, + valid_for=timedelta(hours=1), label="desktop") + + assert calls == [("id", "-u")] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("domains", [("gw.sites.test",), ("*.sites.test",)]) +async def test_a_site_naming_the_gateway_its_routes_are_on_is_refused_before_anything_is_installed(domains): + sandbox = FakeVm(_REPORT) + sandbox.url_from_sandbox = lambda url: url.replace("https://gw.example", "http://gw.sites.test:8080") + + with pytest.raises(RuntimeError, match=r"the gateway at gw.sites.test \(named by notes's .*sites.test\) would"): + await sites.route(sandbox, [_site("notes", *domains)], user="user", network=None, + valid_for=timedelta(hours=1), label="desktop") + + assert sandbox.scripts == [] and sandbox.written == {} + + +@pytest.mark.asyncio +async def test_the_sites_a_run_deployed_are_read_from_each_gateways_records_and_their_cards(monkeypatch): + from agentenv_protocol import client as protocol_v1 + + cards = {"https://gw.example/svc/notes": {"capabilities": {"extensions": [ + {"uri": "urn:agentenv:website/v1", "params": {"domains": ["notes.test"]}}]}}, + "https://gw.example/svc/gh": {"capabilities": {"extensions": [ + {"uri": "urn:agentenv:website/v1", "params": {"domains": ["github.com"], "live": True, + "allow_hosts": ["*.githubassets.com"]}}]}}} + + async def get_card(url, timeout=10, verify=True): + if url not in cards: + raise _status_error(url, 404) + return cards[url] + + monkeypatch.setattr(protocol_v1, "get_card", get_card) + from agent_env.env.env import DeployedGatewayEnv + gateway = DeployedGatewayEnv(env_id="sites", env_version=1, sandbox_id="s", gateway_url="https://gw.example", + website_frontend_urls={name: f"https://gw.example/website/{name}/" + for name in ("notes", "gh", "legacy")}) + computer = SimpleNamespace(env_id="desktop") + + found = await sites.deployed_sites([gateway, computer]) + + assert found == [ + {"name": "notes", "url": "https://gw.example/website/notes/", "domains": ["notes.test"], "live": False, + "allow_hosts": []}, + {"name": "gh", "url": "https://gw.example/website/gh/", "domains": ["github.com"], "live": True, + "allow_hosts": ["*.githubassets.com"]}, + {"name": "legacy", "url": "https://gw.example/website/legacy/", "domains": [], "live": False, + "allow_hosts": []}] + assert await sites.deployed_sites([gateway, computer], only=["desktop"]) == [] + with pytest.raises(ValueError, match="site_routing names other, which this run hasn't deployed"): + await sites.deployed_sites([gateway], only=["other"]) + + +@pytest.mark.asyncio +async def test_the_sites_a_run_deployed_are_its_stored_cards_website_children_at_the_routes_the_card_names( + monkeypatch): + from agentenv_protocol import client as protocol_v1 + + from agent_env.env.env import DeployedGatewayEnv + + def website(name, browse=None, **params): + extension = {"uri": "urn:agentenv:website/v1", + "params": {**params, **({"browse_url": browse} if browse else {})}} + return {"name": name, "url": f"/svc/{name}/agentenv", "capabilities": {"extensions": [extension]}} + + card = {"name": "env", "children_environments": [ + website("notes", "/website/notes/", domains=["notes.test"]), + {"name": "mcp-notes", "url": "/svc/mcp-notes/agentenv", "capabilities": {"extensions": []}}, + website("gh", domains=["github.com"], live=True, allow_hosts=["*.githubassets.com"]), + website("orphan", domains=["orphan.test"])]} + reads = [] + + async def get_card(url, timeout=10, verify=True): + reads.append(url) + raise _status_error(url, 404) + + monkeypatch.setattr(protocol_v1, "get_card", get_card) + gateway = DeployedGatewayEnv(env_id="sites", env_version=1, sandbox_id="s", gateway_url="https://gw.example", + environment_card=card, + website_frontend_urls={name: f"https://gw.example/website/{name}/" + for name in ("gh", "legacy")}) + + found = await sites.deployed_sites([gateway]) + + assert found == [ + {"name": "notes", "url": "https://gw.example/website/notes/", "domains": ["notes.test"], "live": False, + "allow_hosts": []}, + {"name": "gh", "url": "https://gw.example/website/gh/", "domains": ["github.com"], "live": True, + "allow_hosts": ["*.githubassets.com"]}, + {"name": "legacy", "url": "https://gw.example/website/legacy/", "domains": [], "live": False, + "allow_hosts": []}] + assert reads == ["https://gw.example/svc/legacy"] + + +@pytest.mark.asyncio +async def test_a_card_that_fails_to_load_is_retried_then_fails_rather_than_dropping_the_sites_names(monkeypatch): + from agentenv_protocol import client as protocol_v1 + + from agent_env.env.env import DeployedGatewayEnv + + card = {"capabilities": {"extensions": [{"uri": "urn:agentenv:website/v1", "params": {"domains": ["notes.test"]}}]}} + answers = [httpx.ReadTimeout("slow"), _status_error("https://gw.example/svc/notes", 503), card] + + async def get_card(url, timeout=10, verify=True): + answer = answers.pop(0) + if isinstance(answer, Exception): + raise answer + return answer + + monkeypatch.setattr(protocol_v1, "get_card", get_card) + monkeypatch.setattr(sites, "CARD_RETRY_S", 0) + gateway = DeployedGatewayEnv(env_id="sites", env_version=1, sandbox_id="s", gateway_url="https://gw.example", + website_frontend_urls={"notes": "https://gw.example/website/notes/"}) + + (found,) = await sites.deployed_sites([gateway]) + + assert found["domains"] == ["notes.test"] and answers == [] + answers.extend([_status_error("https://gw.example/svc/notes", 500)] * sites.CARD_ATTEMPTS) + with pytest.raises(RuntimeError, match="the card of the site notes at https://gw.example/svc/notes couldn't be " + "read, so its names are unknown: HTTPStatusError"): + await sites.deployed_sites([gateway]) + + +def _status_error(url, code): + request = httpx.Request("GET", f"{url}/.well-known/agent-env.json") + return httpx.HTTPStatusError(f"{code}", request=request, response=httpx.Response(code, request=request)) diff --git a/tst/unit/env/gateway/gateway_card_test.py b/tst/unit/env/gateway/gateway_card_test.py index 6b71a6b7c..48979ddfa 100644 --- a/tst/unit/env/gateway/gateway_card_test.py +++ b/tst/unit/env/gateway/gateway_card_test.py @@ -267,6 +267,28 @@ async def fake_fetch(client, key, base_url): assert {c["url"] for c in card["children_environments"]} == {"/svc/mcp-items/agentenv", "/svc/webitems/agentenv"} +def test_a_website_childs_card_names_the_route_the_gateway_serves_it_to_a_browser_at(): + """The website/v1 param naming it mirrors the protocol's, as the gateway image has no agentenv_protocol.""" + from agentenv_protocol.website import WEBSITE_BROWSE_URL_PARAM + + gw = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], + website_urls={"notes": "http://notes:8000", "bare": "http://bare:8000"}, + rest_proxy_urls={"notes": "http://notes:8000", "bare": "http://bare:8000", "mcp-notes": "http://m"}) + site = {"name": "notes", "url": "/agentenv", "capabilities": {"extensions": [ + {"uri": "urn:agentenv:website/v1", "params": {"domains": ["notes.test"]}}]}} + + notes = gw._rewrite_child_card("notes", site) + mcp = gw._rewrite_child_card("mcp-notes", site) + bare = gw._rewrite_child_card("bare", {"name": "bare", "url": "/agentenv", "capabilities": {"extensions": []}}) + + assert gateway_constants.WEBSITE_BROWSE_URL_PARAM == WEBSITE_BROWSE_URL_PARAM + assert protocol_client.extension_params(notes, "urn:agentenv:website/v1") == { + "domains": ["notes.test"], WEBSITE_BROWSE_URL_PARAM: "/website/notes/"} + assert protocol_client.extension_params(mcp, "urn:agentenv:website/v1") == {"domains": ["notes.test"]} + assert bare["capabilities"]["extensions"] == [] + assert site["capabilities"]["extensions"][0]["params"] == {"domains": ["notes.test"]} + + @pytest.mark.asyncio @pytest.mark.parametrize("method", ["data/reset", "data/add", "data/get"]) async def test_env_data_plane_requires_single_child(method): diff --git a/tst/unit/env/gateway/rest_proxy_stream_test.py b/tst/unit/env/gateway/rest_proxy_stream_test.py index 79e8b5fb6..0c27edebb 100644 --- a/tst/unit/env/gateway/rest_proxy_stream_test.py +++ b/tst/unit/env/gateway/rest_proxy_stream_test.py @@ -579,6 +579,20 @@ async def test_the_upstream_learns_the_host_scheme_and_route_prefix_the_client_r ] +@pytest.mark.asyncio +async def test_a_client_serving_the_route_at_its_own_root_is_told_no_prefix_and_its_header_stays_with_the_gateway( + monkeypatch): + up = _Upstream([b"ok"]) + up.install(monkeypatch) + gw = _gateway(website_urls={"web": "http://web"}) + await _call(gw, "/website/web/api/x", headers={"host": "gw.example", "x-forwarded-host": "notes.test", + "x-forwarded-proto": "https", "agentenv-site-root": "1"}) + await _call(gw, "/website/web/api/x", headers={"host": "gw.example", "agentenv-site-root": ""}) + assert [_forwarded(r) for r in up.forwarded] == [(["notes.test"], ["https"], []), + (["gw.example"], ["http"], ["/website/web"])] + assert all("agentenv-site-root" not in r.headers for r in up.forwarded) + + @pytest.mark.asyncio async def test_a_front_s_own_forwarded_host_and_scheme_win_and_reach_the_upstream_once(monkeypatch): up = _Upstream([b"ok"]) diff --git a/tst/unit/env/gateway/website_websocket_test.py b/tst/unit/env/gateway/website_websocket_test.py new file mode 100644 index 000000000..5c14d750d --- /dev/null +++ b/tst/unit/env/gateway/website_websocket_test.py @@ -0,0 +1,214 @@ +"""A WebSocket on a site's route reaches the site's server through the real gateway, under the HTTP route's rules: its +data plane and card are refused, the role and forwarded address go upstream as the gateway writes them, and each +message and either side's close is relayed.""" + +from __future__ import annotations + +import asyncio +import socket + +import pytest +import pytest_asyncio +import uvicorn +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route, WebSocketRoute +from starlette.websockets import WebSocket, WebSocketDisconnect +from websockets.asyncio.client import connect +from websockets.exceptions import ConnectionClosed, InvalidStatus + +from agent_env.env.gateway.gateway import Gateway + +pytestmark = pytest.mark.usefixtures("socket_enabled") + + +class EchoSite: + """A site whose ``/live`` echoes each message, closes with 4001 on ``bye``, and records each handshake's headers + and the code its client closed with.""" + + def __init__(self): + self.handshakes, self.closed = [], [] + self.app = Starlette(routes=[WebSocketRoute("/live", self.live), WebSocketRoute("/session", self.session), + WebSocketRoute("/{path:path}", self.live)]) + + async def session(self, websocket: WebSocket) -> None: + await websocket.accept(headers=[(b"set-cookie", b"s=2; Path=/"), (b"set-cookie", b"t=3; Path=/"), + (b"x-site", b"yes")]) + await websocket.close(1000) + + async def live(self, websocket: WebSocket) -> None: + self.handshakes.append((websocket.url.path, dict(websocket.headers))) + offered = websocket.headers.get("sec-websocket-protocol") + await websocket.accept(subprotocol="chat.v2" if offered and "chat.v2" in offered else None) + try: + while True: + message = await websocket.receive() + if message["type"] == "websocket.disconnect": + self.closed.append(message.get("code")) + return + if message.get("text") == "bye": + await websocket.close(4001, "done") + return + if message.get("text") is not None: + await websocket.send_text(message["text"].upper()) + else: + await websocket.send_bytes(message["bytes"][::-1]) + except WebSocketDisconnect as e: + self.closed.append(e.code) + + +async def serve(app) -> tuple[uvicorn.Server, int]: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + config = uvicorn.Config(app, lifespan="off", log_level="critical", access_log=False) + config.load() + server = uvicorn.Server(config) + server.lifespan = config.lifespan_class(config) + await server.startup(sockets=[sock]) + return server, sock.getsockname()[1] + + +async def _until(condition, timeout_s: float = 5.0) -> None: + deadline = asyncio.get_running_loop().time() + timeout_s + while not condition(): + assert asyncio.get_running_loop().time() < deadline + await asyncio.sleep(0.02) + + +@pytest_asyncio.fixture +async def stack(): + site = EchoSite() + site_server, site_port = await serve(site.app) + gateway = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], role_key="secret", + rest_proxy_urls={"notes": f"http://127.0.0.1:{site_port}"}, + website_urls={"notes": f"http://127.0.0.1:{site_port}"}) + gateway_server, gateway_port = await serve(gateway._asgi_app()) + try: + yield site, f"ws://127.0.0.1:{gateway_port}/website/notes" + finally: + await gateway_server.shutdown() + await site_server.shutdown() + + +@pytest.mark.asyncio +async def test_messages_and_the_sites_close_are_relayed_with_the_gateways_headers(stack): + site, route = stack + async with connect(f"{route}/live?room=1", subprotocols=["chat.v1", "chat.v2"], + additional_headers=[("AgentEnv-Role", "grader"), ("X-Forwarded-Prefix", "/elsewhere"), + ("Cookie", "s=1")]) as websocket: + await websocket.send("hello") + text = await asyncio.wait_for(websocket.recv(), 5) + await websocket.send(b"\x01\x02\x03") + data = await asyncio.wait_for(websocket.recv(), 5) + await websocket.send("bye") + with pytest.raises(ConnectionClosed): + await asyncio.wait_for(websocket.recv(), 5) + subprotocol, close = websocket.subprotocol, websocket.close_code + + ((path, headers),) = site.handshakes + assert (text, data, subprotocol, close) == ("HELLO", b"\x03\x02\x01", "chat.v2", 4001) + assert path == "/live" and headers["cookie"] == "s=1" + assert (headers["agentenv-role"], headers.get("agentenv-role-key")) == ("default", None) + assert (headers["x-forwarded-proto"], headers["x-forwarded-prefix"]) == ("http", "/website/notes") + + +@pytest.mark.asyncio +async def test_the_sites_handshake_answer_reaches_the_client_with_each_cookie_apart(stack): + _, route = stack + async with connect(f"{route}/session") as websocket: + headers = websocket.response.headers + + assert headers.get_all("set-cookie") == ["s=2; Path=/", "t=3; Path=/"] and headers["x-site"] == "yes" + assert len(headers.get_all("sec-websocket-accept")) == 1 and len(headers.get_all("server")) <= 1 + + +@pytest.mark.asyncio +async def test_the_clients_close_reaches_the_site_and_a_keyed_role_goes_upstream(stack): + site, route = stack + async with connect(f"{route}/live", additional_headers=[("AgentEnv-Role", "grader"), + ("AgentEnv-Role-Key", "secret")]) as websocket: + await websocket.send("ping") + assert await asyncio.wait_for(websocket.recv(), 5) == "PING" + await websocket.close(4002, "leaving") + + await _until(lambda: site.closed) + assert site.closed == [4002] + assert (site.handshakes[0][1]["agentenv-role"], site.handshakes[0][1]["agentenv-role-key"]) == ("grader", "secret") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["/.well-known/agent-env.json", "/agentenv", "/%61gentenv", "/x/..%2Fagentenv"]) +async def test_a_websocket_to_the_sites_data_plane_or_card_is_refused(stack, path): + site, route = stack + with pytest.raises(InvalidStatus) as refused: + async with connect(f"{route}{path}"): + pass + + assert refused.value.response.status_code == 404 and site.handshakes == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["/go", "/go/", "/x/..%2Fgo", "/grading/now", "/agentenv/ext/website/state"]) +async def test_a_websocket_to_a_path_the_sites_card_advertises_is_refused(path): + site = EchoSite() + card = {"name": "notes", "url": "/agentenv", "capabilities": {"extensions": [{ + "uri": "urn:agentenv:website/v1", "params": {"state": {"path": "/go"}, "methods": { + "state": {"method": "GET", "endpoint": "/agentenv/ext/website/state"}}}}, + {"uri": "urn:example:grading/v1", "params": {"endpoint": "/grading"}}]}} + site.app.router.routes.insert(0, Route("/.well-known/agent-env.json", lambda request: JSONResponse(card))) + site_server, site_port = await serve(site.app) + gateway = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], + website_urls={"notes": f"http://127.0.0.1:{site_port}"}) + gateway_server, gateway_port = await serve(gateway._asgi_app()) + route = f"ws://127.0.0.1:{gateway_port}/website/notes" + try: + with pytest.raises(InvalidStatus) as refused: + async with connect(f"{route}{path}"): + pass + async with connect(f"{route}/live") as websocket: + await websocket.send("ping") + assert await asyncio.wait_for(websocket.recv(), 5) == "PING" + finally: + await gateway_server.shutdown() + await site_server.shutdown() + + assert refused.value.response.status_code == 404 and [p for p, _ in site.handshakes] == ["/live"] + + +@pytest.mark.asyncio +async def test_a_websocket_is_refused_while_the_sites_card_cant_be_read(): + site = EchoSite() + site.app.router.routes.insert(0, Route("/.well-known/agent-env.json", lambda request: JSONResponse({}, 503))) + site_server, site_port = await serve(site.app) + gateway = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], + website_urls={"notes": f"http://127.0.0.1:{site_port}"}) + gateway_server, gateway_port = await serve(gateway._asgi_app()) + try: + with pytest.raises(InvalidStatus) as refused: + async with connect(f"ws://127.0.0.1:{gateway_port}/website/notes/live"): + pass + finally: + await gateway_server.shutdown() + await site_server.shutdown() + + assert refused.value.response.status_code == 503 and site.handshakes == [] + + +@pytest.mark.asyncio +async def test_a_site_that_refuses_the_handshake_has_its_answer_relayed(): + site_server, site_port = await serve(Starlette(routes=[])) + gateway = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], + website_urls={"notes": f"http://127.0.0.1:{site_port}"}) + gateway_server, gateway_port = await serve(gateway._mcp.streamable_http_app()) + try: + with pytest.raises(InvalidStatus) as refused: + async with connect(f"ws://127.0.0.1:{gateway_port}/website/notes/missing"): + pass + with pytest.raises(InvalidStatus) as unknown: + async with connect(f"ws://127.0.0.1:{gateway_port}/website/other/live"): + pass + finally: + await gateway_server.shutdown() + await site_server.shutdown() + + assert refused.value.response.status_code == 403 and unknown.value.response.status_code == 404 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 7902db84a..f7491b936 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -2051,6 +2051,40 @@ async def test_deploy_returns_the_gateway_record_for_the_env(caplog, keyed_roles state_provider.teardown.assert_awaited_once_with(external) +@pytest.mark.asyncio +async def test_a_deploy_with_websites_records_the_card_read_once_they_are_ready(): + """The gateway composes its card from its children's as it reads them, so the card read before the sites were up + may lack them; the one recorded is read once they're ready, as what reads the sites from it expects.""" + from agent_env.providers.env_state import DatabaseStateProvider + + gp, external = EnvironmentGatewayProvider(), MagicMock(state_type="remote", instance_id="st-ext") + state_provider = MagicMock(spec=DatabaseStateProvider, prepare=AsyncMock(), teardown=AsyncMock()) + vm = MagicMock(sandbox_id="vm-1", type="modal_vm", tunnel_urls={18765: "https://vm.example"}, host_port=lambda port: port, host_ips=(), + load_docker_images=AsyncMock(), exec_script=AsyncMock(), exec_with_output=AsyncMock(return_value=(0, "gateway", ""))) + env = WebsiteEnv(id="web-shop", version=1, backend_docker_image_artifact=_loadable("shop-be"), + frontend_docker_image_artifact=_loadable("shop-fe"), environment_name="shop") + before, after = {"name": "env1234", "children_environments": []}, {"name": "env1234", "children_environments": [{"name": "shop"}]} + + with patch("agent_env.providers.env_state.acquire_state_for_deploy", AsyncMock(return_value=external)), \ + patch("agent_env.providers.env_state.build_state_provider", return_value=state_provider), \ + patch("agent_env.env.env.Env.get", MagicMock(return_value=MagicMock(docker_image_artifact=_loadable("gw")))), \ + patch("agent_env.config.get_config", return_value=MagicMock(default_gateway_env_id="gw-id")), \ + patch.object(gp, "create_docker_compose", return_value="services: {}"), \ + patch.object(gp, "_wait_for_gateway", AsyncMock(return_value=True)), \ + patch.object(gp, "_get_container_id", AsyncMock(return_value=None)), \ + patch.object(gp, "_wait_for_tunnel", AsyncMock(side_effect=[before, after])), \ + patch.object(gp, "_refuse_tool_name_clashes", AsyncMock()), \ + patch.object(env_gateway_provider, "_wait_for_websites", AsyncMock()) as websites_waited, \ + patch.object(env_gateway_provider, "_tool_names", AsyncMock(return_value=set())), \ + patch("agent_env.providers.env_providers.env_gateway_provider.asyncio.sleep", AsyncMock()): + record = await gp.deploy(env, _vm_provider(create_vm=AsyncMock(return_value=vm)), ttl_seconds=60, env_state_type="remote") + + websites_waited.assert_awaited_once() + assert record.environment_card == after + assert record.website_frontend_urls == {"shop": "https://vm.example/website/shop/"} + await gp.close() + + @pytest.mark.asyncio async def test_deploy_records_the_gateways_sidecar_urls_and_its_containers(): gp, sandboxes = EnvironmentGatewayProvider(), {i: _FakeSandbox(i, 18765, f"https://{i}") for i in ("gw", "srv", "db", "pg", "dm")} diff --git a/uv.lock b/uv.lock index f54d69ab7..9061e21ae 100644 --- a/uv.lock +++ b/uv.lock @@ -131,6 +131,7 @@ dev = [ { name = "transformers" }, { name = "urllib3" }, { name = "vercel-sandbox" }, + { name = "websockets" }, ] gcp = [ { name = "google-api-core" }, @@ -266,6 +267,7 @@ requires-dist = [ { name = "urllib3", marker = "extra == 'boat'", specifier = ">=2.1.0,<3" }, { name = "uvicorn", specifier = ">=0.47" }, { name = "vercel-sandbox", marker = "extra == 'vercel'", specifier = ">=0.7.0,<0.8" }, + { name = "websockets", marker = "extra == 'dev'", specifier = ">=13" }, ] provides-extras = ["openenv", "harness", "trl", "nemo-gym", "verl", "skyrl", "miles", "slime", "aws", "gcp", "sail", "vercel", "tensorlake", "tenki", "boxd", "daytona", "boat", "coreweave", "dev", "smol"] From ff8c9502c7b8a562bea919b1bac968f5f7c6af4f Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 15:03:03 -0800 Subject: [PATCH 42/57] fix(computer): refuse site routing on a disk computer, match sites by their route, and bound DNS and WebSocket relays (FD-4683) - site_routing on a disk computer is refused before boot, and a container form is chosen when one is ready. - deployed_sites matches a card's website to its frontend URL by its /svc// route, not its card name. - The in-computer DNS relay passes on an upstream UDP answer whole, up to the largest datagram. - The gateway's WebSocket relay holds at most WEBSOCKET_MESSAGE_MAX_BYTES of one upstream message, closing both sides with 1009 past it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../env/envs/computer/utils/site_proxy.py | 3 ++- .../env/envs/computer/utils/sites.py | 12 +++++++-- src/agent_env/env/gateway/rest_proxy.py | 12 ++++++--- .../env_providers/env_computer_provider.py | 3 +++ tst/unit/env/envs/test_computer_env.py | 17 +++++++++++++ tst/unit/env/envs/test_computer_site_proxy.py | 25 +++++++++++++++++++ tst/unit/env/envs/test_computer_sites.py | 21 ++++++++++++++++ .../env/gateway/website_websocket_test.py | 23 +++++++++++++++++ 8 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/agent_env/env/envs/computer/utils/site_proxy.py b/src/agent_env/env/envs/computer/utils/site_proxy.py index d7299919a..049772098 100644 --- a/src/agent_env/env/envs/computer/utils/site_proxy.py +++ b/src/agent_env/env/envs/computer/utils/site_proxy.py @@ -51,6 +51,7 @@ UPSTREAM_TIMEOUT_S = 30.0 DNS_TIMEOUT_S = 4.0 NAMESERVER_PORT = 53 +DATAGRAM_MAX = 65535 LISTEN_TIMEOUT_S = 15.0 CHECK_TIMEOUT_S = 15.0 FAILED = (("X-Agentenv-Site-Proxy", "failed"),) @@ -476,7 +477,7 @@ async def _passed_on(self, data: bytes) -> Optional[bytes]: try: await loop.sock_connect(sock, (nameserver, NAMESERVER_PORT)) await loop.sock_sendall(sock, data) - return await asyncio.wait_for(loop.sock_recv(sock, 4096), DNS_TIMEOUT_S) + return await asyncio.wait_for(loop.sock_recv(sock, DATAGRAM_MAX), DNS_TIMEOUT_S) except (OSError, asyncio.TimeoutError): continue return None diff --git a/src/agent_env/env/envs/computer/utils/sites.py b/src/agent_env/env/envs/computer/utils/sites.py index 971144e3f..ca98c0887 100644 --- a/src/agent_env/env/envs/computer/utils/sites.py +++ b/src/agent_env/env/envs/computer/utils/sites.py @@ -416,13 +416,14 @@ async def deployed_sites(deployed_envs: list[DeployedEnv], only: Optional[list[s continue params = protocol_v1.extension_params(child.card, WEBSITE_EXTENSION_URI) browse = params.get(WEBSITE_BROWSE_URL_PARAM) + service = _served_as(child) url = (_joined(address, browse) if isinstance(browse, str) and browse and address - else frontends.get(child.name)) + else frontends.get(service)) if url is None: logger.warning("site routing: the website %s of env %s names no route a browser reaches it at, so it " "isn't routed", child.name, record.env_id) continue - named.add(child.name) + named.add(service) found.append(_found(child.name, url, params)) for name, url in frontends.items(): if name not in named: @@ -431,6 +432,13 @@ async def deployed_sites(deployed_envs: list[DeployedEnv], only: Optional[list[s return found +def _served_as(child) -> str: + """The gateway service a child env is served as: the ```` of the ``/svc//`` route its url is at, which + ``website_frontend_urls`` keys it by, else its card's name.""" + parts = urlsplit(child.url or "").path.strip("/").split("/") + return parts[1] if len(parts) > 1 and parts[0] == "svc" and parts[1] else child.name + + def _found(name: str, url: str, params: dict) -> dict: return {"name": name, "url": url, "domains": list(params.get("domains") or []), "live": bool(params.get("live")), "allow_hosts": list(params.get("allow_hosts") or [])} diff --git a/src/agent_env/env/gateway/rest_proxy.py b/src/agent_env/env/gateway/rest_proxy.py index 50d34d900..b51f33c2d 100644 --- a/src/agent_env/env/gateway/rest_proxy.py +++ b/src/agent_env/env/gateway/rest_proxy.py @@ -84,6 +84,7 @@ _WEBSOCKET_ANSWER_DROP = HOP_BY_HOP | {b"date", b"server", b"content-length", b"sec-websocket-accept", b"sec-websocket-protocol", b"sec-websocket-extensions"} _HTTP_SCHEME = {"ws": "http", "wss": "https"} +WEBSOCKET_MESSAGE_MAX_BYTES = 16 * 1024 * 1024 async def forward(request: Request, base_url: str, *, role: str, timeout: httpx.Timeout, mcp_server: bool, @@ -238,8 +239,9 @@ async def relay_websocket(websocket: WebSocket, base_url: str, *, role: str, rol path, with the headers ``upstream_headers`` sends, asking for the subprotocols the client offered; `websocket` is accepted with the one the service chose and the headers its answer carried (a ``Set-Cookie`` among them, each field kept apart), but those the gateway's own answer writes. Each text and binary message is relayed as it came, and either side's - close, with its code, closes the other. A service that refuses the handshake has its answer relayed as the refusal; - one that never answers is a 502.""" + close, with its code, closes the other; a message from the service larger than ``WEBSOCKET_MESSAGE_MAX_BYTES``, the + size uvicorn takes from a client, closes both with 1009 rather than be held whole. A service that refuses the + handshake has its answer relayed as the refusal; one that never answers is a 502.""" try: target_url = upstream_url(base_url, websocket) except ValueError as e: @@ -252,7 +254,7 @@ async def relay_websocket(websocket: WebSocket, base_url: str, *, role: str, rol try: upstream = await connect_websocket("ws" + target_url.removeprefix("http"), additional_headers=headers, subprotocols=offered or None, user_agent_header=None, - open_timeout=open_timeout, max_size=None) + open_timeout=open_timeout, max_size=WEBSOCKET_MESSAGE_MAX_BYTES) except InvalidStatus as e: answer = e.response await refuse_websocket(websocket, Response(content=answer.body or b"", status_code=answer.status_code, @@ -310,8 +312,10 @@ async def from_upstream() -> None: await asyncio.gather(*tasks, return_exceptions=True) await upstream.close() if WebSocketState.DISCONNECTED not in (websocket.client_state, websocket.application_state): + sent = upstream.protocol.close_sent + code = sent.code if sent is not None and sent.code == 1009 else upstream.close_code with contextlib.suppress(RuntimeError, OSError, ConnectionClosed): - await websocket.close(_sendable(upstream.close_code, abnormal=1011), upstream.close_reason or "") + await websocket.close(_sendable(code, abnormal=1011), upstream.close_reason or "") def _sendable(code: int | None, *, abnormal: int) -> int: diff --git a/src/agent_env/providers/env_providers/env_computer_provider.py b/src/agent_env/providers/env_providers/env_computer_provider.py index 6b5dbde5b..af50abc76 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -83,6 +83,9 @@ async def deploy( if not (builds := env.deployable_builds(params.form)): raise DeployRefused(f"{env.id} has no ready build to deploy; its builds are " f"{env.build_statuses() or 'none yet'}; compose it with: agent-env env build --id {env.id}") + if params.site_routing and not (builds := [build for build in builds if build[0] != "disk"]): + raise DeployRefused(f"{env.id}: env_params.site_routing installs into the computer's container, and a disk " + "computer runs inside a launcher container it can't reach; deploy a container form") attempt = functools.partial(self._boot, env, builds, params, values, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, attribution=attribution) return await self._run(sandbox_provider, env.id, attempt) diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index acbfb5ace..e000878a0 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -736,6 +736,23 @@ async def test_site_routing_with_no_website_deployed_or_outside_a_run_is_refused assert boots.boots == [] and routes == [] +@pytest.mark.asyncio +async def test_site_routing_on_a_disk_computer_is_refused_before_it_boots_and_a_container_form_is_kept( + base, driver, docker, boots, routes, monkeypatch): + from agent_env.env.envs.computer.env import ComputerEnv + + env = _put(base, driver, display={"width": 1600, "height": 900}) + container = ComputerEnv.deployable_builds(Env.get(env.id)) + disk = [("disk", arch, build) for _, arch, build in container] + monkeypatch.setattr(ComputerEnv, "deployable_builds", lambda self, form=None: disk) + params = {"site_routing": True, "sites": [{"name": "a", "url": "http://gw/website/a/", "domains": ["a.test"]}]} + + with pytest.raises(DeployRefused, match="a disk computer runs inside a launcher container"): + await Env.get(env.id).deploy(env_params=params) + + assert boots.boots == [] and routes == [] + + @pytest.mark.asyncio @pytest.mark.parametrize("env_params, problem", [ ({"site_routing": True, "sites": [{"name": "a", "url": "http://gw/website/a/", "domains": ["x.test"]}, diff --git a/tst/unit/env/envs/test_computer_site_proxy.py b/tst/unit/env/envs/test_computer_site_proxy.py index 2bef37b38..314e968a9 100644 --- a/tst/unit/env/envs/test_computer_site_proxy.py +++ b/tst/unit/env/envs/test_computer_site_proxy.py @@ -437,6 +437,31 @@ def datagram_received(self, data, addr): assert passed == [_query("example.org", 1)] and other[2:4] == b"\x81\x80" +@pytest.mark.asyncio +async def test_dns_passes_on_an_answer_larger_than_a_classic_datagram_whole(): + class Nameserver(asyncio.DatagramProtocol): + def connection_made(self, transport): + self.transport = transport + + def datagram_received(self, data, addr): + self.transport.sendto(data[:2] + b"\x81\x80" + data[4:] + b"\0" * 6000, addr) + + loop = asyncio.get_running_loop() + upstream, _ = await loop.create_datagram_endpoint(Nameserver, local_addr=("127.0.0.1", 0)) + resolver = site_proxy.Resolver(Config(user=None, sites=[], nameservers=["127.0.0.1"])) + answered = [] + resolver.transport = type("Transport", (), {"sendto": lambda self, data, addr: answered.append(data)})() + query = _query("example.org", 16) + try: + with pytest.MonkeyPatch.context() as patch: + patch.setattr(site_proxy, "NAMESERVER_PORT", upstream.get_extra_info("sockname")[1]) + await resolver._answer(query, None) + finally: + upstream.close() + + assert [len(reply) for reply in answered] == [len(query) + 6000] + + @pytest.mark.asyncio async def test_dns_over_tcp_answers_a_sites_name_and_passes_every_other_question_on_over_tcp(): passed = [] diff --git a/tst/unit/env/envs/test_computer_sites.py b/tst/unit/env/envs/test_computer_sites.py index d77539b51..e102c067a 100644 --- a/tst/unit/env/envs/test_computer_sites.py +++ b/tst/unit/env/envs/test_computer_sites.py @@ -370,6 +370,27 @@ async def get_card(url, timeout=10, verify=True): assert reads == ["https://gw.example/svc/legacy"] +@pytest.mark.asyncio +async def test_a_card_child_named_apart_from_its_service_is_matched_to_its_frontend_by_its_route(monkeypatch): + from agentenv_protocol import client as protocol_v1 + + from agent_env.env.env import DeployedGatewayEnv + + async def get_card(url, timeout=10, verify=True): + raise AssertionError(f"read {url} live") + + monkeypatch.setattr(protocol_v1, "get_card", get_card) + card = {"name": "env", "children_environments": [{"name": "Notes", "url": "/svc/notes/agentenv", "capabilities": { + "extensions": [{"uri": "urn:agentenv:website/v1", "params": {"domains": ["notes.test"]}}]}}]} + gateway = DeployedGatewayEnv(env_id="sites", env_version=1, sandbox_id="s", gateway_url="https://gw.example", + environment_card=card, + website_frontend_urls={"notes": "https://gw.example/website/notes/"}) + + assert await sites.deployed_sites([gateway]) == [ + {"name": "Notes", "url": "https://gw.example/website/notes/", "domains": ["notes.test"], "live": False, + "allow_hosts": []}] + + @pytest.mark.asyncio async def test_a_card_that_fails_to_load_is_retried_then_fails_rather_than_dropping_the_sites_names(monkeypatch): from agentenv_protocol import client as protocol_v1 diff --git a/tst/unit/env/gateway/website_websocket_test.py b/tst/unit/env/gateway/website_websocket_test.py index 5c14d750d..eea6d9c24 100644 --- a/tst/unit/env/gateway/website_websocket_test.py +++ b/tst/unit/env/gateway/website_websocket_test.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio +import contextlib import socket import pytest @@ -175,6 +176,28 @@ async def test_a_websocket_to_a_path_the_sites_card_advertises_is_refused(path): assert refused.value.response.status_code == 404 and [p for p, _ in site.handshakes] == ["/live"] +@pytest.mark.asyncio +async def test_a_message_from_the_site_over_the_limit_closes_both_sides_with_1009(stack, monkeypatch): + from agent_env.env.gateway import rest_proxy + + site, route = stack + monkeypatch.setattr(rest_proxy, "WEBSOCKET_MESSAGE_MAX_BYTES", 1024) + + async def big(websocket: WebSocket) -> None: + await websocket.accept() + await websocket.send_bytes(b"x" * 2048) + with contextlib.suppress(WebSocketDisconnect): + while (await websocket.receive())["type"] != "websocket.disconnect": + pass + + site.app.router.routes.insert(0, WebSocketRoute("/big", big)) + async with connect(f"{route}/big") as websocket: + with pytest.raises(ConnectionClosed) as closed: + await asyncio.wait_for(websocket.recv(), 5) + + assert closed.value.rcvd.code == 1009 + + @pytest.mark.asyncio async def test_a_websocket_is_refused_while_the_sites_card_cant_be_read(): site = EchoSite() From 1c225331e40f2ba77cbc518269ea7e80cce6b018 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 15:13:46 -0800 Subject: [PATCH 43/57] fix(computer): launch boot apps once sites are routed, keep the site proxy running as nobody, and pass CORS preflights (FD-4683) - With site_routing, a computer holds its boot apps' launches (AGENTENV_HOLD_BOOT_LAUNCH); the driver counts a held app ready, and the provider launches each through computer-app/v1's launch once the names, CA and proxy are in place, then waits until the computer is ready. - The site proxy drops to nobody where the base has it, so the agent can't stop it; keep, as root, starts it again should it end; its log sits in the root-owned config folder and is opened without following a link. - A website's gateway routes accept OPTIONS, so a CORS preflight between two sites reaches the site. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agentenv_protocol/computer/app_runtime.py | 18 ++++-- .../src/agentenv_protocol/computer/apps.py | 8 ++- .../tests/test_computer_app_server.py | 19 +++++++ .../env/envs/computer/utils/site_proxy.py | 56 ++++++++++++++----- .../env/envs/computer/utils/sites.py | 4 +- src/agent_env/env/gateway/gateway.py | 4 +- .../env_providers/env_computer_provider.py | 18 +++++- tst/unit/env/envs/test_computer_env.py | 31 ++++++++++ tst/unit/env/envs/test_computer_site_proxy.py | 47 ++++++++++++++++ .../gateway/website_reserved_paths_test.py | 20 +++++++ 10 files changed, 200 insertions(+), 25 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py index a5f0c88d2..013e70aed 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/app_runtime.py @@ -36,6 +36,7 @@ EXIT_WAIT_S = 5.0 #: How long a stopped app may take to exit before it's killed. STOP_TIMEOUT_S = 10.0 +HOLD_BOOT_LAUNCH_ENV = "AGENTENV_HOLD_BOOT_LAUNCH" class AppReadiness(BaseModel): @@ -74,9 +75,10 @@ def __init__(self, launch: Optional[Launch], ready_checks: dict[str, Callable[[] async def start(self) -> None: """Once its X display is up, when it has one, add the app's menu launcher on a desktop and launch a boot app, - unless the computer has launched it since it booted, at boot or on demand: a server that restarts leaves the - app be, or with ``relaunch_on_start``, for an app only the server that launched it can talk to, stops it and - launches it again, whether it was launched at boot or on demand.""" + unless the computer has launched it since it booted, at boot or on demand, or its boot launch is held + (``boot_launch_held``): a server that restarts leaves the app be, or with ``relaunch_on_start``, for an app + only the server that launched it can talk to, stops it and launches it again, whether it was launched at boot + or on demand.""" if self.launch is None: return if os.environ.get("DISPLAY"): @@ -85,7 +87,7 @@ async def start(self) -> None: session.add_launcher(os.environ["ENVIRONMENT_NAME"], Path.cwd()) launched = _launched_at() is not None relaunch = self.relaunch_on_start and launched - if relaunch or (self.launch.at == "boot" and not launched): + if relaunch or (self.launch.at == "boot" and not launched and not boot_launch_held()): try: if relaunch: await self.stop_app() @@ -190,6 +192,14 @@ async def _ready_check(self, name: str, fn: Callable[[], Any]) -> Optional[str]: return None if passed else f"@ready_check {name} returned {passed!r}" +def boot_launch_held() -> bool: + """Whether the computer holds its boot apps' launches (``HOLD_BOOT_LAUNCH_ENV`` is ``1`` in its environment): each + app's server then leaves its boot app unlaunched, the driver counts it ready until something launches it, and + whoever booted the computer launches each one through ``computer-app/v1``'s ``launch`` once it has set the computer + up for them, as a deploy routing the run's websites does once their names, CA and proxy are in place.""" + return os.environ.get(HOLD_BOOT_LAUNCH_ENV) == "1" + + def _mark() -> Path: return LAUNCHED / Path.cwd().name diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/apps.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/apps.py index 9de569ed5..9ea251d4f 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/apps.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/apps.py @@ -1,8 +1,8 @@ """The apps composed onto a computer, as its driver finds them, whether each is ready, and how the driver fronts them. Each app's own server answers on its folder's socket (``serve_app``): its card says how the app launches and which paths it owns, and its ``computer-app/v1`` ``ready`` whether it's ready. Every app's server must answer before the -computer is ready; an app that starts with the computer must also be ready, and a ``demand`` app is checked once -something launches it. The driver lists each app as a child environment named for its folder, its card's paths under +computer is ready; an app that starts with the computer must also be ready, and a ``demand`` app, or a boot app +while the computer holds its launch (``boot_launch_held``), is checked once something launches it. The driver lists each app as a child environment named for its folder, its card's paths under ``/svc//``, and forwards each request there to the app's server.""" from __future__ import annotations @@ -16,7 +16,7 @@ from .. import client from ..types import RPC_PATH, WELL_KNOWN_PATH, EnvironmentCard from .app import COMPUTER_APP, FAILURE, SOCKET -from .app_runtime import CHECK_TIMEOUT_S, AppReadiness +from .app_runtime import CHECK_TIMEOUT_S, AppReadiness, boot_launch_held from .operations import ReadyCheck #: Where a composed computer installs its apps, a folder each. @@ -44,6 +44,8 @@ async def check(folder: Path) -> ReadyCheck: return ReadyCheck(f"app {name}", False, _unanswered(folder)) if launch is not None and launch["at"] == "demand" and not answer.launched: return ReadyCheck(f"app {name}", True, "its server answers; it launches on demand") + if launch is not None and boot_launch_held() and not answer.launched: + return ReadyCheck(f"app {name}", True, "its server answers; its boot launch is held") if answer.ready: return ReadyCheck(f"app {name}", True, "ready") when = (f"not ready after {answer.timeout_s:.0f} s" if answer.waited_s > answer.timeout_s diff --git a/packages/agentenv-protocol/tests/test_computer_app_server.py b/packages/agentenv-protocol/tests/test_computer_app_server.py index 8c535345a..41624aa91 100644 --- a/packages/agentenv-protocol/tests/test_computer_app_server.py +++ b/packages/agentenv-protocol/tests/test_computer_app_server.py @@ -161,6 +161,25 @@ async def test_a_boot_app_is_launched_once_per_boot_its_clock_from_that_launch_a assert answer["launched"] is True and answer["waited_s"] >= 100 +@pytest.mark.asyncio +async def test_a_held_boot_launch_leaves_the_app_unlaunched_and_counted_ready_until_it_is_launched(desktop, monkeypatch): + shown, launched = desktop + monkeypatch.setattr(session, "add_launcher", lambda name, folder: None) + monkeypatch.setenv(app_runtime.HOLD_BOOT_LAUNCH_ENV, "1") + with tempfile.TemporaryDirectory(dir="/tmp", prefix="apps-") as root: + monkeypatch.setattr(apps, "APPS", Path(root)) + async with _serving(Mail(), Path(root) / "mail", monkeypatch) as mail: + await mail._computer_app.start() + held = await apps.check(Path(root) / "mail") + await mail._computer_app_launch() + shown.append({"instance": "mail", "class": "Mail", "title": "Inbox"}) + mail.synced = True + launched_check = await apps.check(Path(root) / "mail") + + assert held == ReadyCheck("app mail", True, "its server answers; its boot launch is held") + assert len(launched) == 1 and launched_check == ReadyCheck("app mail", True, "ready") + + @pytest.mark.asyncio async def test_a_restarted_server_finds_the_demand_app_it_launched_and_leaves_it_be(desktop, tmp_path, monkeypatch): _, launched = desktop diff --git a/src/agent_env/env/envs/computer/utils/site_proxy.py b/src/agent_env/env/envs/computer/utils/site_proxy.py index 049772098..55a9e8d0e 100644 --- a/src/agent_env/env/envs/computer/utils/site_proxy.py +++ b/src/agent_env/env/envs/computer/utils/site_proxy.py @@ -7,7 +7,8 @@ store, Firefox's policies, Chromium's managed policies and the session user's NSS databases, starts ``serve`` and checks, as the session user, that each name loads. ``serve`` listens on loopback: on 443 for a name served over HTTPS, on 80 to redirect those to HTTPS and to serve ``.localhost`` names over HTTP, and on 53 for DNS, over UDP and TCP, when a name is a wildcard, which a -hosts file can't list; it then drops to the session user. The site sees itself at ``/`` under its own name: the path goes upstream under +hosts file can't list; it then drops to ``SERVE_USER`` (``nobody``), else the session user, so the agent, which runs as +the session user, can't stop it, and ``keep``, as root, starts it again should it end. The site sees itself at ``/`` under its own name: the path goes upstream under the route's prefix, ``X-Forwarded-Host`` and ``X-Forwarded-Proto`` say the name and scheme the browser used, ``AgentEnv-Site-Root`` has the gateway tell the site no prefix, so the links it makes are rooted at ``/`` too, and a ``Location`` or cookie naming the route is rewritten back to the name. Hop-by-hop headers and the browser's own @@ -54,6 +55,8 @@ DATAGRAM_MAX = 65535 LISTEN_TIMEOUT_S = 15.0 CHECK_TIMEOUT_S = 15.0 +RESTART_DELAY_S = 1.0 +SERVE_USER = "nobody" FAILED = (("X-Agentenv-Site-Proxy", "failed"),) @@ -110,11 +113,12 @@ def tls(self, name: str) -> bool: @dataclass class Config: - """What ``serve`` reads: the session user it drops to, its ports, the sites and, with a wildcard name, the - nameservers it forwards every other question to.""" + """What ``serve`` reads: the session user, the user it drops to (``serve_user``, else the session user), its + ports, the sites and, with a wildcard name, the nameservers it forwards every other question to.""" user: Optional[str] sites: list[Site] + serve_user: Optional[str] = None listen: str = LOOPBACK https_port: int = 443 http_port: int = 80 @@ -126,7 +130,8 @@ def load(cls, path: str) -> Config: data = json.loads(Path(path).read_text()) sites = [Site(name=s["name"], upstream=s["upstream"], domains=list(s["domains"]), cert=s.get("cert"), key=s.get("key")) for s in data["sites"]] - return cls(user=data.get("user"), sites=sites, listen=data.get("listen", LOOPBACK), + return cls(user=data.get("user"), sites=sites, serve_user=data.get("serve_user"), + listen=data.get("listen", LOOPBACK), https_port=data.get("https_port", 443), http_port=data.get("http_port", 80), dns_port=data.get("dns_port"), nameservers=list(data.get("nameservers") or [])) @@ -562,7 +567,7 @@ def pick(connection: ssl.SSLObject, name: Optional[str], _: ssl.SSLContext) -> N async def serve(config: Config) -> None: - """Listen, drop to the session user, and serve until cancelled.""" + """Listen, drop to the user it serves as, and serve until cancelled.""" proxy = SiteProxy(config) servers = [] if (context := tls_context(config)) is not None: @@ -576,7 +581,7 @@ async def serve(config: Config) -> None: transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( lambda: resolver, local_addr=(config.listen, config.dns_port)) servers.append(await asyncio.start_server(resolver.handle_stream, config.listen, config.dns_port)) - drop_privileges(config.user) + drop_privileges(config.serve_user or config.user) try: await asyncio.gather(*(server.serve_forever() for server in servers)) finally: @@ -584,6 +589,15 @@ async def serve(config: Config) -> None: transport.close() +def serve_user(session_user: Optional[str]) -> Optional[str]: + """The user ``serve`` drops to: ``SERVE_USER`` where the base has it, else the session user.""" + try: + pwd.getpwnam(SERVE_USER) + except KeyError: + return session_user + return SERVE_USER + + def drop_privileges(user: Optional[str]) -> None: if user is None or os.geteuid() != 0: return @@ -771,7 +785,7 @@ def install(config_path: str, paths: Paths = Paths()) -> dict: write_in_place(paths.profile, f"export NODE_EXTRA_CA_CERTS={ca}\n" + (f"export REQUESTS_CA_BUNDLE={bundle}\n" if bundle else "")) trust["environment"] = str(paths.profile) - serving = {"user": user, "sites": sites, **{key: given[key] for key in ("listen", "https_port", "http_port") + serving = {"user": user, "serve_user": serve_user(user), "sites": sites, **{key: given[key] for key in ("listen", "https_port", "http_port") if key in given}} if wildcard: serving.update(dns_port=given.get("dns_port", 53), nameservers=nameservers) @@ -782,12 +796,11 @@ def install(config_path: str, paths: Paths = Paths()) -> dict: def start(serve_path: str, log: Optional[str]) -> None: - """Start ``serve`` apart from this process, and wait until it listens.""" + """Start ``keep``, which runs ``serve``, apart from this process, and wait until it listens. The log is opened + without following a link, so a link planted at its path can't have root write elsewhere.""" config = Config.load(serve_path) - if log: - Path(log).parent.mkdir(parents=True, exist_ok=True) - output = open(log, "ab") if log else subprocess.DEVNULL - subprocess.Popen([sys.executable, os.path.abspath(__file__), "serve", serve_path], stdin=subprocess.DEVNULL, + output = open_log(log) if log else subprocess.DEVNULL + subprocess.Popen([sys.executable, os.path.abspath(__file__), "keep", serve_path], stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT, start_new_session=True, close_fds=True) port = config.https_port if any(site.cert for site in config.sites) else config.http_port deadline = time.monotonic() + LISTEN_TIMEOUT_S @@ -803,6 +816,21 @@ def start(serve_path: str, log: Optional[str]) -> None: time.sleep(0.2) +def open_log(log: str) -> int: + Path(log).parent.mkdir(parents=True, exist_ok=True) + return os.open(log, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600) + + +def keep(serve_path: str) -> None: + """As root: run ``serve`` on ``serve_path``, and start it again ``RESTART_DELAY_S`` after it ends, for as long as + this runs.""" + while True: + ended = subprocess.run([sys.executable, os.path.abspath(__file__), "serve", serve_path], + stdin=subprocess.DEVNULL).returncode + print(f"the site proxy ended ({ended}); starting it again", flush=True) + time.sleep(RESTART_DELAY_S) + + def checks_as(user: Optional[str], serve_path: str) -> dict: """``check`` run as ``user``, given each site's timeout one after another and a margin for its start.""" sites = len(Config.load(serve_path).sites) @@ -841,12 +869,14 @@ def main(argv: list[str]) -> None: command, path = argv[0], argv[1] if command == "install": print(json.dumps(install(path))) + elif command == "keep": + keep(path) elif command == "serve": asyncio.run(serve(Config.load(path))) elif command == "check": print(json.dumps(check(Config.load(path)))) else: - raise SystemExit(f"usage: site_proxy.py install|serve|check , not {command!r}") + raise SystemExit(f"usage: site_proxy.py install|keep|serve|check , not {command!r}") if __name__ == "__main__": diff --git a/src/agent_env/env/envs/computer/utils/sites.py b/src/agent_env/env/envs/computer/utils/sites.py index ca98c0887..ef5cbeb4d 100644 --- a/src/agent_env/env/envs/computer/utils/sites.py +++ b/src/agent_env/env/envs/computer/utils/sites.py @@ -45,7 +45,7 @@ PROXY = FOLDER / "site_proxy.py" CONFIG = FOLDER / "config.json" CA_PATH = FOLDER / "ca.pem" -LOG = "/var/log/agentenv/site-proxy.log" +LOG = FOLDER / "site-proxy.log" SYNTHETIC, LIVE, ALLOWLIST = "synthetic", "live", "allowlist" RANKS = (SYNTHETIC, LIVE, ALLOWLIST) VALID_MARGIN = timedelta(days=1) @@ -267,7 +267,7 @@ def proxy_config(table: SiteTable, issued: Issued, *, user: Optional[str]) -> di if route.site in issued.leaves: site["cert_pem"], site["key_pem"] = issued.leaves[route.site] sites.append(site) - return {"user": user, "ca_pem": issued.ca_pem, "sites": sites, "log": LOG} + return {"user": user, "ca_pem": issued.ca_pem, "sites": sites, "log": str(LOG)} def reachable_problem(policy: Optional[NetworkPolicy], routes: list[Route]) -> Optional[str]: diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index b054fb147..f18af19b4 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -215,14 +215,14 @@ async def proxy_to_service(request: Request) -> Response: if self.website_urls: @self._mcp.custom_route( "/website/{service_name}/{path:path}", - methods=["GET", "POST", "PUT", "DELETE", "PATCH"], + methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], ) async def proxy_to_frontend(request: Request) -> Response: return await self._proxy_website_request(request) @self._mcp.custom_route( "/website/{service_name}", - methods=["GET", "POST", "PUT", "DELETE", "PATCH"], + methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], ) async def proxy_to_frontend_root(request: Request) -> Response: return await self._proxy_website_request(request) diff --git a/src/agent_env/providers/env_providers/env_computer_provider.py b/src/agent_env/providers/env_providers/env_computer_provider.py index af50abc76..40b7be6d6 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -9,6 +9,8 @@ from typing import TYPE_CHECKING, ClassVar, Optional from agentenv_protocol.computer import COMPUTER_DISPLAY, COMPUTER_RECORDING +from agentenv_protocol.computer.app import COMPUTER_APP +from agentenv_protocol.computer.app_runtime import HOLD_BOOT_LAUNCH_ENV from agent_env.artifact.artifacts.computer import ComputerArtifact from agent_env.attribution import Attribution @@ -142,6 +144,7 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par await _require_display(record, values.get("resolution")) if params.site_routing: record.metadata["computer"]["sites"] = await _route_sites(env, booted.sandbox, params, ttl_seconds) + await _launch_held_apps(env, record, booted.url) except BaseException: await discard(booted.sandbox) raise @@ -173,6 +176,7 @@ async def reboot(self, env: ComputerEnv, deployed: DeployedSandboxEnv) -> None: await _require_display(deployed, values.get("resolution")) if params.site_routing: record["sites"] = await _route_sites(env, sandbox, params, record["sites"]["valid_for_s"]) + await _launch_held_apps(env, deployed, deployed.environment_url) async def _route_sites(env: ComputerEnv, sandbox: Sandbox, params: computer_params.ComputerParams, @@ -184,6 +188,17 @@ async def _route_sites(env: ComputerEnv, sandbox: Sandbox, params: computer_para return {**routed, "valid_for_s": valid_for_s} +async def _launch_held_apps(env: ComputerEnv, record: DeployedSandboxEnv, url: str) -> None: + """Launch each app that starts with the computer, whose launch a deploy routing sites holds until the sites' names, + CA and proxy are in place, through its ``computer-app/v1`` ``launch``, then wait until the computer is ready.""" + os_type = env.computer.os.type + held = [app for app in env.composed_apps() if (launch := app.launch(os_type)) is not None and launch.at == "boot"] + for app in held: + await record.invoke(COMPUTER_APP, "launch", environment_name=app.environment_name) + if held: + await ready(url, ready_timeout(env.composed_apps(), os_type), record.control_token) + + async def end_recordings(record: DeployedSandboxEnv, reason: str) -> None: """Have the computer's driver end every recording not yet ended, each uploading what's left and its manifest under the grant it was started with, before the computer goes; a driver that doesn't record has none.""" @@ -206,7 +221,8 @@ def _environment(env: ComputerEnv, computer: ComputerArtifact, values: dict, return {**settings_environment(computer, values, environment_name=env.environment_name, driver_settings=params.driver_settings), **app_settings_environment(env.composed_apps(), computer_params.app_settings(env, params.app_settings), - env.computer.os.type)} + env.computer.os.type), + **({HOLD_BOOT_LAUNCH_ENV: "1"} if params.site_routing else {})} def _require_name(env: ComputerEnv, card: dict) -> None: diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index e000878a0..3e01e5e8e 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -23,6 +23,8 @@ import pytest from agentenv_protocol import CONTROL_TOKEN_ENV from agentenv_protocol.computer import COMPUTER_DISPLAY, COMPUTER_RECORDING +from agentenv_protocol.computer.app import COMPUTER_APP +from agentenv_protocol.computer.app_runtime import HOLD_BOOT_LAUNCH_ENV from agentenv_protocol.tool_access import DISABLE_TOOL_EXTENSION_URI from click.testing import CliRunner @@ -687,6 +689,7 @@ async def test_a_computer_with_site_routing_reaches_the_websites_the_run_deploye assert [site.model_dump() for site in sites] == [{"name": "notes", "url": "http://localhost:8080/website/notes/", "domains": ["notes.test"], "live": False, "allow_hosts": []}] assert options == {"user": "user", "network": None, "valid_for": timedelta(seconds=3600), "label": "desktop"} + assert boots.boots[0]["environment"][HOLD_BOOT_LAUNCH_ENV] == "1" record = context.deployed_envs[-1].metadata["computer"] assert record["sites"] == {"names": {"routes": {"notes": ["notes.test"]}}, "ca": "CA PEM", "valid_for_s": 3600} assert record["env_params"]["site_routing"] is True and record["env_params"]["sites"][0]["name"] == "notes" @@ -701,6 +704,34 @@ async def test_a_computer_without_site_routing_routes_nothing_whatever_websites_ await DeployEnvTaskStep(id="d", version=None, env_id=env.id).execute(context) assert routes == [] and "sites" not in context.deployed_envs[-1].metadata["computer"] + assert HOLD_BOOT_LAUNCH_ENV not in boots.boots[0]["environment"] + + +@pytest.mark.asyncio +async def test_a_computer_routing_sites_launches_its_held_boot_apps_once_routed_then_waits_until_ready(monkeypatch): + order = [] + apps = [SimpleNamespace(environment_name=name, launch=lambda os_type, at=at: SimpleNamespace(at=at)) + for name, at in [("mail", "boot"), ("music", "demand"), ("notes", "boot")]] + env = SimpleNamespace(computer=SimpleNamespace(os=SimpleNamespace(type="linux")), composed_apps=lambda: apps) + + async def invoke(uri, method, params=None, *, environment_name=None): + order.append((uri, method, environment_name)) + + async def ready(url, timeout, token): + order.append(("ready", url, token)) + return {} + + monkeypatch.setattr(env_computer_provider, "ready", ready) + monkeypatch.setattr(env_computer_provider, "ready_timeout", lambda apps, os_type: 30.0) + record = SimpleNamespace(invoke=invoke, control_token="t0ken") + await env_computer_provider._launch_held_apps(env, record, "http://computer") + + assert order == [(COMPUTER_APP, "launch", "mail"), (COMPUTER_APP, "launch", "notes"), + ("ready", "http://computer", "t0ken")] + order.clear() + apps[:] = [apps[1]] + await env_computer_provider._launch_held_apps(env, record, "http://computer") + assert order == [] @pytest.mark.asyncio diff --git a/tst/unit/env/envs/test_computer_site_proxy.py b/tst/unit/env/envs/test_computer_site_proxy.py index 314e968a9..af15cfc52 100644 --- a/tst/unit/env/envs/test_computer_site_proxy.py +++ b/tst/unit/env/envs/test_computer_site_proxy.py @@ -10,12 +10,14 @@ import email.message import io import json +import os import socket import ssl import struct import urllib.error from datetime import timedelta from pathlib import Path +from types import SimpleNamespace import pytest import uvicorn @@ -612,6 +614,51 @@ def test_a_wildcard_name_starts_dns_even_when_resolv_conf_names_no_other_nameser assert (root / "etc/resolv.conf").read_text() == "nameserver 127.0.0.1\n" +def test_the_proxy_serves_as_nobody_where_the_base_has_it_else_as_the_session_user(tmp_path, monkeypatch): + monkeypatch.setattr(site_proxy.pwd, "getpwnam", lambda name: SimpleNamespace(pw_name=name)) + assert site_proxy.serve_user("agent") == "nobody" + + def missing(name): + raise KeyError(name) + + monkeypatch.setattr(site_proxy.pwd, "getpwnam", missing) + assert site_proxy.serve_user("agent") == "agent" + serve_path = tmp_path / "serve.json" + serve_path.write_text(json.dumps({"user": "agent", "serve_user": "nobody", "sites": []})) + assert (Config.load(str(serve_path)).user, Config.load(str(serve_path)).serve_user) == ("agent", "nobody") + + +def test_keep_starts_serve_again_each_time_it_ends(monkeypatch): + runs = [] + + def run(argv, **kwargs): + runs.append(argv[-2:]) + return site_proxy.subprocess.CompletedProcess(argv, 1) + + def sleep(seconds): + if len(runs) == 3: + raise StopIteration + + monkeypatch.setattr(site_proxy.subprocess, "run", run) + monkeypatch.setattr(site_proxy.time, "sleep", sleep) + with pytest.raises(StopIteration): + site_proxy.keep("/etc/agentenv/sites/serve.json") + + assert runs == [["serve", "/etc/agentenv/sites/serve.json"]] * 3 + + +def test_the_log_is_root_owned_beside_the_config_and_a_link_at_its_path_is_refused(tmp_path): + assert sites.proxy_config(sites.site_table([]), SimpleNamespace(ca_pem=None, leaves={}), user=None)["log"] == str(sites.FOLDER / "site-proxy.log") + target, log = tmp_path / "elsewhere", tmp_path / "site-proxy.log" + target.write_text("kept") + log.symlink_to(target) + with pytest.raises(OSError): + site_proxy.open_log(str(log)) + assert target.read_text() == "kept" + os.close(site_proxy.open_log(str(tmp_path / "logs" / "fresh.log"))) + assert (tmp_path / "logs" / "fresh.log").stat().st_mode & 0o777 == 0o600 + + def test_the_proxy_is_standard_library_alone_so_the_bases_own_python_runs_it(): source = Path(site_proxy.__file__).read_text() imported = {line.split()[1].split(".")[0] for line in source.splitlines() diff --git a/tst/unit/env/gateway/website_reserved_paths_test.py b/tst/unit/env/gateway/website_reserved_paths_test.py index 372b1a2fe..306a31d76 100644 --- a/tst/unit/env/gateway/website_reserved_paths_test.py +++ b/tst/unit/env/gateway/website_reserved_paths_test.py @@ -132,3 +132,23 @@ class Site(WebsiteEnvironment): reserved = site_reserved_paths(card) assert find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP)["endpoint"] in reserved assert reserved_site_path("/agentenv/ext/website/ready", reserved) and not reserved_site_path("/", reserved) + + +@pytest.mark.asyncio +async def test_a_cors_preflight_on_a_sites_route_reaches_the_site(monkeypatch): + reached = [] + + def site(request): + reached.append((request.method, request.url.path)) + if request.url.path == WELL_KNOWN_PATH: + return httpx.Response(404) + return httpx.Response(200, headers={"Access-Control-Allow-Origin": "https://app.test"}, content=_streamed()) + + transport = httpx.MockTransport(site) + monkeypatch.setattr(rest_proxy_module.httpx, "AsyncClient", lambda **kw: _RealAsyncClient(transport=transport, **kw)) + gw = _gateway(_Changelog(), website_urls={"api": "http://api-website-backend:8000"}) + + preflight = {"origin": "https://app.test", "access-control-request-method": "POST"} + assert (await _http(gw, "OPTIONS", "/website/api/items", headers=preflight))[0] == 200 + assert (await _http(gw, "OPTIONS", "/website/api", headers=preflight))[0] == 200 + assert ("OPTIONS", "/items") in reached and ("OPTIONS", "/") in reached From 2a372b2225879a2ae7f95882028c6a17172ef08e Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 16:39:39 -0800 Subject: [PATCH 44/57] fix(computer): a site's certificate names it in its alternative names alone, so a name longer than a common name takes is signed (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/env/envs/computer/utils/sites.py | 4 ++-- tst/unit/env/envs/test_computer_site_proxy.py | 2 +- tst/unit/env/envs/test_computer_sites.py | 11 +++++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/agent_env/env/envs/computer/utils/sites.py b/src/agent_env/env/envs/computer/utils/sites.py index ef5cbeb4d..69b70c9f2 100644 --- a/src/agent_env/env/envs/computer/utils/sites.py +++ b/src/agent_env/env/envs/computer/utils/sites.py @@ -235,12 +235,12 @@ def issue(routes: list[Route], *, valid_for: timedelta, label: str, now: Optiona key = ec.generate_private_key(ec.SECP256R1()) cert = ( x509.CertificateBuilder() - .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, route.tls_names[0])])) + .subject_name(x509.Name([])) .issuer_name(subject) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(start).not_valid_after(end) - .add_extension(x509.SubjectAlternativeName([x509.DNSName(n) for n in route.tls_names]), critical=False) + .add_extension(x509.SubjectAlternativeName([x509.DNSName(n) for n in route.tls_names]), critical=True) .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) .add_extension(_key_usage(digital_signature=True), critical=True) .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False) diff --git a/tst/unit/env/envs/test_computer_site_proxy.py b/tst/unit/env/envs/test_computer_site_proxy.py index af15cfc52..5e37576d2 100644 --- a/tst/unit/env/envs/test_computer_site_proxy.py +++ b/tst/unit/env/envs/test_computer_site_proxy.py @@ -384,7 +384,7 @@ async def test_each_name_is_served_the_certificate_of_its_own_site_and_a_wildcar for name in ("notes.test", "chat.test", "eu.chat.test"): reader, writer = await asyncio.open_connection("127.0.0.1", port, ssl=ssl.create_default_context( cadata=issued.ca_pem), server_hostname=name) - seen[name] = writer.get_extra_info("peercert")["subject"][0][0][1] + seen[name] = writer.get_extra_info("peercert")["subjectAltName"][0][1] writer.close() with pytest.raises(ssl.SSLCertVerificationError): await asyncio.open_connection("127.0.0.1", port, ssl=ssl.create_default_context(cadata=issued.ca_pem), diff --git a/tst/unit/env/envs/test_computer_sites.py b/tst/unit/env/envs/test_computer_sites.py index e102c067a..e90c759e0 100644 --- a/tst/unit/env/envs/test_computer_sites.py +++ b/tst/unit/env/envs/test_computer_sites.py @@ -122,6 +122,17 @@ def test_a_deploys_ca_signs_each_sites_names_and_is_constrained_to_them(): assert "PRIVATE KEY" in issued.leaves["notes"][1] and "PRIVATE KEY" not in issued.ca_pem +def test_a_name_longer_than_a_common_name_takes_is_signed_by_its_alternative_names_alone(): + now = datetime.now(UTC).replace(microsecond=0) + name = f"{'a' * 63}.test" + issued = sites.issue(sites.site_table([_site("long", name)]).routes, valid_for=timedelta(hours=1), label="x", + now=now) + + leaf = x509.load_pem_x509_certificate(issued.leaves["long"][0].encode()) + assert len(leaf.subject) == 0 and leaf.extensions.get_extension_for_class(x509.SubjectAlternativeName).critical + _verify(issued.ca_pem, issued.leaves["long"][0], name, now) + + def test_no_ca_is_made_when_no_name_is_served_over_https(): assert sites.issue(sites.site_table([_site("bare")]).routes, valid_for=timedelta(hours=1), label="x") == \ sites.Issued(None, {}) From 6bdc0312cb5b243be584816df0c6928e0deaa078 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 17:16:09 -0800 Subject: [PATCH 45/57] fix(computer): the site proxy sends the gateway's provider headers upstream, kept where root alone reads them (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../env/envs/computer/utils/site_proxy.py | 25 +++++++++-- .../env/envs/computer/utils/sites.py | 15 +++++-- tst/unit/env/envs/test_computer_site_proxy.py | 45 +++++++++++++++++++ tst/unit/env/envs/test_computer_sites.py | 21 +++++++++ 4 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/agent_env/env/envs/computer/utils/site_proxy.py b/src/agent_env/env/envs/computer/utils/site_proxy.py index 55a9e8d0e..18319a361 100644 --- a/src/agent_env/env/envs/computer/utils/site_proxy.py +++ b/src/agent_env/env/envs/computer/utils/site_proxy.py @@ -47,6 +47,7 @@ FORWARDED = frozenset({"x-forwarded-host", "x-forwarded-proto", "x-forwarded-for", "x-forwarded-port", "x-forwarded-prefix", "forwarded", SITE_ROOT_HEADER.lower()}) RESPONSE_DROP = frozenset({"connection", "keep-alive", "proxy-connection", "alt-svc"}) +UPSTREAM_HEADERS_FILE = "upstream_headers.json" HEAD_LIMIT = 1 << 20 CHUNK = 1 << 16 UPSTREAM_TIMEOUT_S = 30.0 @@ -89,6 +90,7 @@ class Site: domains: list[str] cert: Optional[str] = None key: Optional[str] = None + upstream_headers: dict[str, str] = field(default_factory=dict) scheme: str = field(init=False) host: str = field(init=False) port: int = field(init=False) @@ -127,9 +129,17 @@ class Config: @classmethod def load(cls, path: str) -> Config: + """The config at ``path``, with each site's upstream headers from the private file it names, which root alone + reads: ``serve`` reads it before it drops privileges, and ``check``, as the session user, goes without.""" data = json.loads(Path(path).read_text()) + headers: dict = {} + if data.get("upstream_headers"): + try: + headers = json.loads(Path(data["upstream_headers"]).read_text()) + except PermissionError: + headers = {} sites = [Site(name=s["name"], upstream=s["upstream"], domains=list(s["domains"]), cert=s.get("cert"), - key=s.get("key")) for s in data["sites"]] + key=s.get("key"), upstream_headers=dict(headers.get(s["name"]) or {})) for s in data["sites"]] return cls(user=data.get("user"), sites=sites, serve_user=data.get("serve_user"), listen=data.get("listen", LOOPBACK), https_port=data.get("https_port", 443), http_port=data.get("http_port", 80), @@ -217,13 +227,15 @@ def upstream_request(method: str, target: str, headers: list[tuple[str, str]], s host: str, peer: str, upgrade: Optional[str]) -> bytes: """The request head sent to the site's route: the path under its prefix, the gateway as ``Host``, the browser's name and scheme in ``X-Forwarded-Host`` and ``X-Forwarded-Proto``, ``AgentEnv-Site-Root`` so the site is told it - is at ``/``, and a connection that closes after it, or upgrades when the browser asked to.""" + is at ``/``, the headers the gateway's sandbox provider asks for (``Site.upstream_headers``) in place of any the + browser sent by those names, and a connection that closes after it, or upgrades when the browser asked to.""" if not target.startswith("/"): parts = urlsplit(target) target = urlunsplit(("", "", parts.path or "/", parts.query, "")) - kept = [(key, value) for key, value in headers if key.lower() not in HOP_BY_HOP | FORWARDED] + provided = {key.lower() for key in site.upstream_headers} + kept = [(key, value) for key, value in headers if key.lower() not in HOP_BY_HOP | FORWARDED | provided] kept += [("Host", site.netloc), ("X-Forwarded-Host", host), ("X-Forwarded-Proto", scheme), - ("X-Forwarded-For", peer), (SITE_ROOT_HEADER, "1")] + ("X-Forwarded-For", peer), (SITE_ROOT_HEADER, "1"), *site.upstream_headers.items()] kept += [("Connection", "Upgrade"), ("Upgrade", upgrade)] if upgrade else [("Connection", "close")] lines = [f"{method} {site.prefix}{target} HTTP/1.1", *(f"{key}: {value}" for key, value in kept)] return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1") @@ -762,6 +774,9 @@ def install(config_path: str, paths: Paths = Paths()) -> dict: write_private(key, site["key_pem"]) served.update(cert=str(cert), key=str(key)) sites.append(served) + upstream_headers = {site["name"]: site["upstream_headers"] for site in given["sites"] if site.get("upstream_headers")} + if upstream_headers: + write_private(folder / UPSTREAM_HEADERS_FILE, json.dumps(upstream_headers)) os.chmod(config_path, 0o600) folder.chmod(0o755) names = [name for site in given["sites"] for name in site["domains"] if not name.startswith("*.")] @@ -789,6 +804,8 @@ def install(config_path: str, paths: Paths = Paths()) -> dict: if key in given}} if wildcard: serving.update(dns_port=given.get("dns_port", 53), nameservers=nameservers) + if upstream_headers: + serving["upstream_headers"] = str(folder / UPSTREAM_HEADERS_FILE) serve_path = folder / "serve.json" serve_path.write_text(json.dumps(serving)) start(str(serve_path), given.get("log")) diff --git a/src/agent_env/env/envs/computer/utils/sites.py b/src/agent_env/env/envs/computer/utils/sites.py index 69b70c9f2..507e33cb8 100644 --- a/src/agent_env/env/envs/computer/utils/sites.py +++ b/src/agent_env/env/envs/computer/utils/sites.py @@ -259,13 +259,18 @@ def _key_usage(**granted: bool) -> x509.KeyUsage: return x509.KeyUsage(**{usage: granted.get(usage, False) for usage in usages}) -def proxy_config(table: SiteTable, issued: Issued, *, user: Optional[str]) -> dict: - """What ``site_proxy.py install`` reads: the session user, the CA, and each route with its certificate and key.""" +def proxy_config(table: SiteTable, issued: Issued, *, user: Optional[str], + upstream_headers: Optional[dict[str, dict[str, str]]] = None) -> dict: + """What ``site_proxy.py install`` reads: the session user, the CA, and each route with its certificate and key, and + the headers its gateway's sandbox provider asks requests to it to carry (``upstream_headers``, by site), which the + proxy sends upstream alone.""" sites = [] for route in table.routes: site = {"name": route.site, "upstream": route.url, "domains": list(route.domains)} if route.site in issued.leaves: site["cert_pem"], site["key_pem"] = issued.leaves[route.site] + if (upstream_headers or {}).get(route.site): + site["upstream_headers"] = dict(upstream_headers[route.site]) sites.append(site) return {"user": user, "ca_pem": issued.ca_pem, "sites": sites, "log": str(LOG)} @@ -370,11 +375,15 @@ async def route(sandbox: Sandbox, sites: list[Site], *, user: Optional[str], net raise RuntimeError(f"site routing: {problem}") if not table.routes: return {"names": table.to_dict()} + from agent_env.providers.sandbox_providers.sandbox_provider import sandbox_request_headers_for_url + issued = issue(table.routes, valid_for=valid_for, label=label) + upstream_headers = {r.site: sandbox_request_headers_for_url(r.url) for r in table.routes} channel = RootChannel(sandbox) await channel.private_folder(FOLDER) await channel.write(PROXY, _PROXY_SOURCE.read_text()) - await channel.write(CONFIG, json.dumps(proxy_config(table, issued, user=user))) + await channel.write(CONFIG, json.dumps(proxy_config(table, issued, user=user, upstream_headers=upstream_headers))) + await channel.run(["chmod", "0600", str(CONFIG)]) output = await channel.run(["python3", str(PROXY), "install", str(CONFIG)]) report = json.loads(output.strip().splitlines()[-1]) failed = {name: result for name, result in report["checks"].items() if "error" in result} diff --git a/tst/unit/env/envs/test_computer_site_proxy.py b/tst/unit/env/envs/test_computer_site_proxy.py index 5e37576d2..af2cb83aa 100644 --- a/tst/unit/env/envs/test_computer_site_proxy.py +++ b/tst/unit/env/envs/test_computer_site_proxy.py @@ -149,6 +149,22 @@ async def test_a_request_goes_to_the_sites_route_with_the_name_and_scheme_the_br assert again[2] == b"hello" +@pytest.mark.asyncio +async def test_the_providers_headers_go_upstream_in_place_of_the_browsers_and_never_back(tmp_path): + async with Upstream() as upstream: + config, issued = _config(tmp_path, upstream.port) + config.sites[0].upstream_headers = {"CF-Access-Client-Secret": "s3cret"} + async with _proxy(config) as port: + start, headers, body, conn = await _exchange( + port, b"GET / HTTP/1.1\r\nHost: notes.test\r\ncf-access-client-secret: forged\r\n\r\n", + server_hostname="notes.test", cadata=issued.ca_pem) + conn[1].close() + + ((_, sent, _),) = upstream.requests + assert sent["cf-access-client-secret"] == "s3cret" + assert "s3cret" not in json.dumps(headers) and body == b"hello" + + async def _serve(app) -> tuple[uvicorn.Server, int]: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(("127.0.0.1", 0)) @@ -576,6 +592,35 @@ def test_install_writes_the_sites_files_names_and_trust_then_starts_the_proxy_on assert report["checks"] == {"notes.test": {"status": 200}} +def test_install_keeps_the_upstream_headers_in_a_file_root_alone_reads_and_serve_json_names_it(tmp_path, monkeypatch): + root, folder = tmp_path / "root", tmp_path / "sites" + (root / "etc").mkdir(parents=True) + folder.mkdir() + folder.chmod(0o700) + table = sites.site_table([SiteParams(name="notes", url="http://gw/website/notes/", domains=["notes.test"])]) + issued = sites.issue(table.routes, valid_for=timedelta(hours=1), label="t") + given = sites.proxy_config(table, issued, user=None, upstream_headers={"notes": {"CF-Access-Client-Secret": "s3cret"}}) + (folder / "config.json").write_text(json.dumps(given)) + started = [] + monkeypatch.setattr(site_proxy, "start", lambda path, log: started.append(Config.load(path))) + monkeypatch.setattr(site_proxy, "checks_as", lambda user, path: {}) + monkeypatch.setattr(site_proxy.shutil, "which", lambda tool: None) + + site_proxy.install(str(folder / "config.json"), site_proxy.Paths(root)) + + (config,) = started + assert config.sites[0].upstream_headers == {"CF-Access-Client-Secret": "s3cret"} + private = folder / site_proxy.UPSTREAM_HEADERS_FILE + assert private.stat().st_mode & 0o777 == 0o600 and (folder / "config.json").stat().st_mode & 0o777 == 0o600 + serving = (folder / "serve.json").read_text() + assert "s3cret" not in serving and json.loads(serving)["upstream_headers"] == str(private) + private.chmod(0) + try: + assert Config.load(str(folder / "serve.json")).sites[0].upstream_headers == {} + finally: + private.chmod(0o600) + + def test_the_checks_are_given_each_sites_timeout_in_turn(tmp_path, monkeypatch): serve_path = tmp_path / "serve.json" serve_path.write_text(json.dumps({"user": None, "sites": [ diff --git a/tst/unit/env/envs/test_computer_sites.py b/tst/unit/env/envs/test_computer_sites.py index e90c759e0..bd84c857d 100644 --- a/tst/unit/env/envs/test_computer_sites.py +++ b/tst/unit/env/envs/test_computer_sites.py @@ -188,12 +188,32 @@ async def test_route_installs_the_proxy_as_root_with_each_sites_route_as_the_com assert site["key_pem"] and config["ca_pem"] == routed["ca"] assert sandbox.scripts == ["docker exec -u 0 computer-1 mkdir -p /etc/agentenv/sites", "docker exec -u 0 computer-1 chmod 0700 /etc/agentenv/sites", + "docker exec -u 0 computer-1 chmod 0600 /etc/agentenv/sites/config.json", "docker exec -u 0 computer-1 python3 /etc/agentenv/sites/site_proxy.py install " "/etc/agentenv/sites/config.json"] + assert "upstream_headers" not in site assert routed["checks"] == _REPORT["checks"] and routed["names"]["routes"] == {"notes": ["notes.test"]} assert "PRIVATE KEY" not in json.dumps(routed) +@pytest.mark.asyncio +async def test_the_headers_the_gateways_provider_asks_for_go_in_the_private_config_alone(monkeypatch): + from agent_env.providers.sandbox_providers import sandbox_provider + + sandbox = FakeVm(_REPORT) + monkeypatch.setattr(sandbox_provider, "sandbox_request_headers_for_url", lambda url: { + "CF-Access-Client-Secret": "s3cret"} if url.startswith("http://host.docker.internal:8080/") else {}) + + routed = await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, + valid_for=timedelta(hours=1), label="desktop") + + (site,) = json.loads(sandbox.written["/etc/agentenv/sites/config.json"])["sites"] + assert site["upstream_headers"] == {"CF-Access-Client-Secret": "s3cret"} + private = sandbox.events.index("docker exec -u 0 computer-1 chmod 0600 /etc/agentenv/sites/config.json") + assert sandbox.events.index(("write", "/etc/agentenv/sites/config.json")) < private < len(sandbox.events) - 1 + assert "s3cret" not in json.dumps(routed) + + @pytest.mark.asyncio async def test_the_sites_keys_land_in_a_folder_root_alone_can_enter(): sandbox = FakeVm(_REPORT) @@ -267,6 +287,7 @@ async def write_file_from_text(content, path): assert calls == [("id", "-u"), ("mkdir", "-p", "/etc/agentenv/sites"), ("chmod", "0700", "/etc/agentenv/sites"), ("mkdir", "-p", "/etc/agentenv/sites"), ("write", "/etc/agentenv/sites/site_proxy.py"), ("mkdir", "-p", "/etc/agentenv/sites"), ("write", "/etc/agentenv/sites/config.json"), + ("chmod", "0600", "/etc/agentenv/sites/config.json"), ("python3", "/etc/agentenv/sites/site_proxy.py", "install", "/etc/agentenv/sites/config.json")] From ace47cc773e299fe20671004a987115954ccc980 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 17:41:37 -0800 Subject: [PATCH 46/57] fix(computer): the gateway keeps the provider's request headers from the sites it forwards to, and relays a refused handshake's headers (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/env/gateway/constants.py | 1 + src/agent_env/env/gateway/entrypoint.py | 3 +- src/agent_env/env/gateway/gateway.py | 13 ++-- src/agent_env/env/gateway/rest_proxy.py | 44 +++++++++----- .../env_providers/env_gateway_provider.py | 5 ++ .../sandbox_providers/sandbox_provider.py | 9 +++ .../env/gateway/website_websocket_test.py | 59 +++++++++++++++++++ .../env_gateway_provider_test.py | 27 ++++++++- .../test_reachability_contributions.py | 18 ++++++ 9 files changed, 156 insertions(+), 23 deletions(-) diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index 53c558b26..784eb080f 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -148,6 +148,7 @@ def sources(names: list[str]) -> str: # Sent by a computer's site proxy, which serves a website's route at the root of the site's own name. AGENT_ENV_SITE_ROOT_HEADER = "AgentEnv-Site-Root" GATEWAY_ROLE_KEY_ENV = "AGENTENV_ROLE_KEY" +GATEWAY_PROVIDER_HEADERS_ENV = "AGENTENV_PROVIDER_REQUEST_HEADERS" WEBSITE_ROLE_KEY_ENV = "WEBSITE_ROLE_KEY" # MCP request `_meta` keys the gateway stamps on every child tools/call: the caller's role, and on the MCP path a # key naming the inbound session, so a child can keep per-caller state behind the gateway's one shared session. diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index b6d469a17..2912fbdbc 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -7,7 +7,7 @@ import httpx from . import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode, InternalMCPServer -from .constants import DEFAULT_MCP_SERVER_NAME, GATEWAY_ROLE_KEY_ENV +from .constants import DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, GATEWAY_ROLE_KEY_ENV from .gateway import Gateway logger = logging.getLogger(__name__) @@ -153,5 +153,6 @@ def parse_name_url_map(env_str: str) -> dict[str, str]: gateway_mode=gateway_mode, service_db_url=service_db_url, role_key=os.environ.get(GATEWAY_ROLE_KEY_ENV) or None, + provider_headers=os.environ.get(GATEWAY_PROVIDER_HEADERS_ENV, "").split(","), ) gateway.run() diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index f18af19b4..9b6a673d4 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -11,6 +11,7 @@ import posixpath import socket import subprocess +from collections.abc import Iterable from contextlib import asynccontextmanager from datetime import datetime, timezone from pathlib import Path @@ -139,9 +140,12 @@ def __init__( gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, service_db_url: str | None = None, role_key: str | None = None, + provider_headers: Iterable[str] = (), ): """`role_key` is the deploy's secret: with one, a request is believed to act as a role other than `default` - only when it carries the key beside the role; without one, as before it, the role header alone is believed.""" + only when it carries the key beside the role; without one, as before it, the role header alone is believed. + `provider_headers` names the headers the sandbox provider in front of the gateway is sent, its credentials, + which the gateway takes off every request it forwards to a service behind it.""" self.host = host self.port = port self.server_name = server_name @@ -152,6 +156,7 @@ def __init__( self.gateway_mode = gateway_mode self._service_db_url = service_db_url self._role_key = role_key or None + self._withheld = frozenset(name.strip().lower().encode() for name in provider_headers if name.strip()) self._website_routes: dict[str, list[WebsiteRoute] | None] = {} self._db_conn: psycopg2.extensions.connection | None = None if service_db_url: @@ -637,7 +642,7 @@ async def _proxy_rest_request(self, request: Request, url_map: dict[str, str] | mcp_server = url_map is self.rest_proxy_urls and service_name.startswith("mcp-") if request.method in _SAFE_METHODS: return await forward(request, base_url, role=role, timeout=self.REST_PROXY_TIMEOUT, mcp_server=mcp_server, - role_key=self._role_key) + role_key=self._role_key, withheld=self._withheld) changelog_before = self._query_changelog_id() attribution = self._attribution(role) # at the start: a fire may be gone by the time the response ends @@ -662,7 +667,7 @@ async def log_write(status: int) -> None: }) return await forward(request, base_url, role=role, timeout=self.REST_PROXY_TIMEOUT, mcp_server=mcp_server, - role_key=self._role_key, on_done=log_write) + role_key=self._role_key, on_done=log_write, withheld=self._withheld) 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. @@ -706,7 +711,7 @@ async def _proxy_website_socket(self, websocket: WebSocket) -> None: await refuse_websocket(websocket, self._json_response({"error": f"Unknown service: {service_name}"}, 404)) return await relay_websocket(websocket, base_url, role=self._request_role(websocket), role_key=self._role_key, - open_timeout=self.REST_PROXY_CONNECT_TIMEOUT_S) + open_timeout=self.REST_PROXY_CONNECT_TIMEOUT_S, withheld=self._withheld) async def _fire_website_triggers(self, request: Request, app: str, status: int) -> tuple[list[asyncio.Task], float | None]: diff --git a/src/agent_env/env/gateway/rest_proxy.py b/src/agent_env/env/gateway/rest_proxy.py index b51f33c2d..e863187aa 100644 --- a/src/agent_env/env/gateway/rest_proxy.py +++ b/src/agent_env/env/gateway/rest_proxy.py @@ -83,14 +83,16 @@ # What the gateway's own WebSocket handshake answer writes for itself, so a site's copies aren't relayed. _WEBSOCKET_ANSWER_DROP = HOP_BY_HOP | {b"date", b"server", b"content-length", b"sec-websocket-accept", b"sec-websocket-protocol", b"sec-websocket-extensions"} +_WEBSOCKET_REFUSAL_DROP = HOP_BY_HOP | {b"date", b"server", b"content-length"} _HTTP_SCHEME = {"ws": "http", "wss": "https"} WEBSOCKET_MESSAGE_MAX_BYTES = 16 * 1024 * 1024 async def forward(request: Request, base_url: str, *, role: str, timeout: httpx.Timeout, mcp_server: bool, - role_key: str | None = None, on_done: Callable[[int], Awaitable[None]] | None = None) -> Response: + role_key: str | None = None, on_done: Callable[[int], Awaitable[None]] | None = None, + withheld: frozenset[bytes] = frozenset()) -> Response: """Forward `request` to the service at `base_url` as `role`, vouched for by `role_key` when the deploy has one, - and stream the response through. `mcp_server` says + without the headers named in `withheld`, and stream the response through. `mcp_server` says the service is a backing MCP server, whose own endpoint takes the role in its JSON-RPC `_meta` as well as in the header. A 400 names a request-target the proxy cannot route; a 502 names an upstream that never answered. `on_done` is awaited with the status once the response has ended, after its last byte when streamed.""" @@ -99,7 +101,7 @@ async def forward(request: Request, base_url: str, *, role: str, timeout: httpx. except ValueError as e: # an escaped slash inside the route prefix; refuse rather than forward a wrong path return await _done(_json_response({"error": str(e)}, 400), on_done) content = await request.body() # /svc and /website request bodies are small (JSON-RPC, forms) - headers = upstream_headers(request, role, role_key) + headers = upstream_headers(request, role, role_key, withheld=withheld) if mcp_server and request.method == "POST" and is_child_mcp_path(request.path_params.get("path", "")): # The gateway is the trust boundary for identity: a child's proxied MCP endpoint stays reachable, but the # role in its JSON-RPC `_meta` is the gateway's to write, like the header above. @@ -136,14 +138,17 @@ def upstream_url(base_url: str, request: HTTPConnection) -> str: return url -def upstream_headers(request: HTTPConnection, role: str, role_key: str | None = None) -> httpx.Headers: +def upstream_headers(request: HTTPConnection, role: str, role_key: str | None = None, *, + withheld: frozenset[bytes] = frozenset()) -> httpx.Headers: """One entry per raw header, so repeated fields (cookie, x-forwarded-for) keep their multiplicity. The role and role key headers are the gateway's to set: the client's copies are dropped and the role the gateway attributed the request to goes in their place, with `role_key` beside any role but `default`, so the upstream - never reads a value the gateway did not write. So are the forwarded-address headers (`forwarded_address`).""" + never reads a value the gateway did not write. So are the forwarded-address headers (`forwarded_address`). The + headers named in `withheld` (lower case), the credentials of the provider in front of the gateway, are dropped, + so no service behind it reads them.""" headers = httpx.Headers([(k, v) for k, v in request.headers.raw if k.lower() not in HOP_BY_HOP and k.lower() not in (ROLE_HEADER_BYTES, ROLE_KEY_HEADER_BYTES) - and k.lower() not in _FORWARDED]) + and k.lower() not in _FORWARDED and k.lower() not in withheld]) headers[AGENT_ENV_ROLE_HEADER] = role if role_key and role != DEFAULT_ROLE: headers[AGENT_ENV_ROLE_KEY_HEADER] = role_key @@ -234,20 +239,21 @@ def raw_remainder(raw_path: bytes, decoded_path: str, decoded_rest: str) -> byte async def relay_websocket(websocket: WebSocket, base_url: str, *, role: str, role_key: str | None = None, - open_timeout: float = 10.0) -> None: + open_timeout: float = 10.0, withheld: frozenset[bytes] = frozenset()) -> None: """Relay `websocket` to the service at `base_url` as `role`: a WebSocket of the gateway's own is opened to the same path, with the headers ``upstream_headers`` sends, asking for the subprotocols the client offered; `websocket` is accepted with the one the service chose and the headers its answer carried (a ``Set-Cookie`` among them, each field kept apart), but those the gateway's own answer writes. Each text and binary message is relayed as it came, and either side's close, with its code, closes the other; a message from the service larger than ``WEBSOCKET_MESSAGE_MAX_BYTES``, the size uvicorn takes from a client, closes both with 1009 rather than be held whole. A service that refuses the - handshake has its answer relayed as the refusal; one that never answers is a 502.""" + handshake has its answer relayed as the refusal, its headers as an accepted one's are; one that never answers is a + 502. The headers named in `withheld` don't go upstream, as with ``upstream_headers``.""" try: target_url = upstream_url(base_url, websocket) except ValueError as e: await refuse_websocket(websocket, _json_response({"error": str(e)}, 400)) return - headers = [(key, value) for key, value in upstream_headers(websocket, role, role_key).multi_items() + headers = [(key, value) for key, value in upstream_headers(websocket, role, role_key, withheld=withheld).multi_items() if key.lower() not in _WEBSOCKET_HANDSHAKE] offered = [protocol.strip() for value in websocket.headers.getlist("sec-websocket-protocol") for protocol in value.split(",") if protocol.strip()] @@ -256,23 +262,29 @@ async def relay_websocket(websocket: WebSocket, base_url: str, *, role: str, rol subprotocols=offered or None, user_agent_header=None, open_timeout=open_timeout, max_size=WEBSOCKET_MESSAGE_MAX_BYTES) except InvalidStatus as e: - answer = e.response - await refuse_websocket(websocket, Response(content=answer.body or b"", status_code=answer.status_code, - media_type=answer.headers.get("content-type"))) + refusal = Response(content=bytes(e.response.body or b""), status_code=e.response.status_code) + refusal.raw_headers = [*(item for item in refusal.raw_headers if item[0] == b"content-length"), + *_answer_headers(e.response.headers, drop=_WEBSOCKET_REFUSAL_DROP)] + await refuse_websocket(websocket, refusal) return except (InvalidHandshake, OSError, TimeoutError) as e: logger.error(f"WebSocket proxy to {target_url} failed: {e!r}") await refuse_websocket(websocket, _json_response({"error": f"Proxy request failed: {e!r}"}, 502)) return async with upstream: - answer = [(key.encode("ascii", "surrogateescape"), value.encode("ascii", "surrogateescape")) - for key, value in upstream.response.headers.raw_items()] await websocket.accept(subprotocol=upstream.subprotocol, - headers=[(key.lower(), value) for key, value in answer - if key.lower() not in _WEBSOCKET_ANSWER_DROP]) + headers=_answer_headers(upstream.response.headers, drop=_WEBSOCKET_ANSWER_DROP)) await _relay_messages(websocket, upstream) +def _answer_headers(headers: Any, *, drop: frozenset[bytes]) -> list[tuple[bytes, bytes]]: + """A site's handshake answer's headers as the gateway relays them, each field apart (a ``Set-Cookie`` per cookie), + but those named in `drop`, which the gateway's own answer writes.""" + return [(key.lower(), value) for key, value in + ((key.encode("ascii", "surrogateescape"), value.encode("ascii", "surrogateescape")) + for key, value in headers.raw_items()) if key.lower() not in drop] + + async def refuse_websocket(websocket: WebSocket, response: Response) -> None: """Refuse `websocket`'s handshake with `response`, or, on a server that can't send one, a 403.""" if "websocket.http.response" in websocket.scope.get("extensions", {}): 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 f2c1d31b3..58fee59ab 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -24,6 +24,7 @@ from agent_env.env.gateway import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode from agent_env.env.gateway.constants import ( DEFAULT_MCP_SERVER_NAME, + GATEWAY_PROVIDER_HEADERS_ENV, GATEWAY_ROLE_KEY_ENV, STATE_TOOL_NAME_CLASHES_KEY, WEBSITE_ROLE_KEY_ENV, @@ -63,6 +64,7 @@ SANDBOX_MODE_VM, Accepts, SandboxProvider, + sandbox_request_header_names, ) from agent_env.store.image_store.oci_registry_credentials import image_reference_problem @@ -508,6 +510,8 @@ def create_docker_compose( gateway_env.append(f" - SERVICE_DB_URL={state_provider.base_url(state_instance)}") if role_key: gateway_env.append(f" - {GATEWAY_ROLE_KEY_ENV}={role_key}") + if provider_headers := sandbox_request_header_names(): + gateway_env.append(f" - {GATEWAY_PROVIDER_HEADERS_ENV}={','.join(provider_headers)}") publish = host_port or (lambda port: port) exposed_gateway_ports = [] if expose_gateway_port: @@ -1074,6 +1078,7 @@ async def _deploy_via_containers( "SERVICE_DB_URL": self._state_provider.base_url(state_instance), "MCP_SERVER_NAME": mcp_server_name or DEFAULT_MCP_SERVER_NAME, **({GATEWAY_ROLE_KEY_ENV: role_key} if role_key else {}), + **({GATEWAY_PROVIDER_HEADERS_ENV: ",".join(names)} if (names := sandbox_request_header_names()) else {}), } gateway_sb = await sandbox_provider.create_container( image_name=gateway_env.docker_image_artifact.image_name, diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 4246ad97d..ddd9cc80a 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -621,6 +621,15 @@ def sandbox_request_headers_for_url(url: str) -> dict[str, str]: return headers +def sandbox_request_header_names() -> tuple[str, ...]: + """The names of every header any registered provider attaches to requests for its URLs + (``sandbox_request_headers_for_url``), lower-cased and sorted: what a gateway takes off the requests it forwards, + so no service behind it reads a provider's credentials. Names only, so no credential is resolved.""" + names = {header.lower() for cls in registered_sandbox_provider_classes() + for entries in (cls.REQUEST_HEADERS or {}).values() for header in entries} + return tuple(sorted(names)) + + def _resolve_container_env(provider_cls: type[SandboxProvider]) -> dict[str, str]: """``provider_cls.CONTAINER_ENV`` resolved to values. diff --git a/tst/unit/env/gateway/website_websocket_test.py b/tst/unit/env/gateway/website_websocket_test.py index eea6d9c24..c10798d90 100644 --- a/tst/unit/env/gateway/website_websocket_test.py +++ b/tst/unit/env/gateway/website_websocket_test.py @@ -8,6 +8,7 @@ import contextlib import socket +import httpx import pytest import pytest_asyncio import uvicorn @@ -217,6 +218,64 @@ async def test_a_websocket_is_refused_while_the_sites_card_cant_be_read(): assert refused.value.response.status_code == 503 and site.handshakes == [] +@pytest.mark.asyncio +async def test_the_providers_headers_never_reach_the_site_over_http_or_a_websocket(): + site, seen = EchoSite(), [] + + async def echo_headers(request): + seen.append(dict(request.headers)) + return JSONResponse(dict(request.headers)) + + site.app.router.routes.insert(0, Route("/whoami", echo_headers)) + site_server, site_port = await serve(site.app) + gateway = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], + website_urls={"notes": f"http://127.0.0.1:{site_port}"}, + provider_headers=["CF-Access-Client-Id", "cf-access-client-secret", " "]) + gateway_server, gateway_port = await serve(gateway._asgi_app()) + sent = {"CF-Access-Client-Id": "id", "CF-Access-Client-Secret": "secret", "X-Site": "kept"} + try: + async with httpx.AsyncClient() as client: + answer = await client.get(f"http://127.0.0.1:{gateway_port}/website/notes/whoami", headers=sent) + async with connect(f"ws://127.0.0.1:{gateway_port}/website/notes/live", additional_headers=sent) as websocket: + await websocket.send("ping") + assert await asyncio.wait_for(websocket.recv(), 5) == "PING" + finally: + await gateway_server.shutdown() + await site_server.shutdown() + + [(_, handshake)] = site.handshakes + for headers in (seen[0], handshake, answer.json()): + assert "cf-access-client-id" not in headers and "cf-access-client-secret" not in headers + assert headers["x-site"] == "kept" + + +@pytest.mark.asyncio +async def test_a_refused_handshake_keeps_the_sites_headers_each_cookie_apart(): + async def refuse(websocket: WebSocket) -> None: + answer = JSONResponse({"error": "signed out"}, status_code=401) + answer.raw_headers += [(b"set-cookie", b"s=; Max-Age=0; Path=/"), (b"set-cookie", b"t=; Max-Age=0; Path=/"), + (b"x-site", b"yes")] + await websocket.send_denial_response(answer) + + site_server, site_port = await serve(Starlette(routes=[WebSocketRoute("/live", refuse)])) + gateway = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], + website_urls={"notes": f"http://127.0.0.1:{site_port}"}) + gateway_server, gateway_port = await serve(gateway._asgi_app()) + try: + with pytest.raises(InvalidStatus) as refused: + async with connect(f"ws://127.0.0.1:{gateway_port}/website/notes/live"): + pass + finally: + await gateway_server.shutdown() + await site_server.shutdown() + + answer = refused.value.response + assert answer.status_code == 401 and answer.headers.get_all("set-cookie") == [ + "s=; Max-Age=0; Path=/", "t=; Max-Age=0; Path=/"] + assert answer.headers["x-site"] == "yes" and answer.headers["content-type"] == "application/json" + assert answer.headers.get_all("content-length") == [str(len(answer.body))] + + @pytest.mark.asyncio async def test_a_site_that_refuses_the_handshake_has_its_answer_relayed(): site_server, site_port = await serve(Starlette(routes=[])) 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 f7491b936..a25abab49 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -305,8 +305,10 @@ async def test_a_vm_gateway_refuses_an_image_its_vm_cant_get_before_creating_the @pytest.mark.asyncio -async def test_container_mode_constructs_internal_mcp_servers_url_format(): - """Container mode must pass INTERNAL_MCP_SERVERS as 'name=url,name=url' to the gateway.""" +async def test_container_mode_constructs_internal_mcp_servers_url_format(monkeypatch): + """Container mode must pass INTERNAL_MCP_SERVERS as 'name=url,name=url' to the gateway, and the names of the + providers' request headers, which it takes off what it forwards.""" + monkeypatch.setattr(env_gateway_provider, "sandbox_request_header_names", lambda: ("cf-access-client-id", "x-a")) provider = ModalSandboxProvider() gp = EnvironmentGatewayProvider() gp._state_provider = LocalPostgresStateProvider() # normally set by create_gateway @@ -397,6 +399,7 @@ async def _spy_acquire(self, ctx): assert env["MCP_SERVER_NAME"] == "crm" assert result.mcp_server_name == "crm" assert env["AGENTENV_ROLE_KEY"] == result.role_key == "deploy-secret" + assert env["AGENTENV_PROVIDER_REQUEST_HEADERS"] == "cf-access-client-id,x-a" # Each MCP got DATABASE_URL with its scoped search_path mcp_calls = [c for c in create_calls if c["image_name"].startswith("mcp-")] @@ -1193,6 +1196,26 @@ def test_redact_compose_secrets_masks_only_secret_env_lines(): assert "s3cr3t-token-value" in compose +@pytest.mark.parametrize("names", [("cf-access-client-id", "cf-access-client-secret"), ()], ids=["named", "none"]) +def test_compose_gives_the_gateway_the_names_of_the_providers_request_headers_alone(names, monkeypatch): + from agent_env.env.envs.service_db import ServiceDBConfig + + monkeypatch.setattr(env_gateway_provider, "sandbox_request_header_names", lambda: names) + compose = EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=[MCPServerConfig(image="mcp-slack", environment_name="slack")], + gateway_image="agent-gateway", + website_configs=[WebsiteConfig(backend_image="notes-be", frontend_image=None, environment_name="notes")], + state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance(), + ) + + services = yaml.safe_load(compose)["services"] + named = {name: [entry for entry in service.get("environment") or [] if "PROVIDER_REQUEST_HEADERS" in entry] + for name, service in services.items()} + expected = {GATEWAY_SERVICE_NAME: [f"AGENTENV_PROVIDER_REQUEST_HEADERS={','.join(names)}"]} if names else {} + assert {name: entries for name, entries in named.items() if entries} == expected + + @pytest.mark.parametrize("role_key", ["deploy-secret", None], ids=["keyed", "unkeyed"]) def test_compose_gives_the_role_key_to_the_gateway_and_every_website_server_and_masks_it_in_the_log(role_key): """The gateway believes a role only beside the deploy's key, and a site holds the same key to believe the role the diff --git a/tst/unit/providers/sandbox_providers/test_reachability_contributions.py b/tst/unit/providers/sandbox_providers/test_reachability_contributions.py index 9e1c10ad0..2f79f44a5 100644 --- a/tst/unit/providers/sandbox_providers/test_reachability_contributions.py +++ b/tst/unit/providers/sandbox_providers/test_reachability_contributions.py @@ -21,6 +21,7 @@ SandboxProvider, _resolve_container_env, all_sandbox_container_env, + sandbox_request_header_names, sandbox_request_headers_for_url, ) @@ -176,6 +177,23 @@ def test_headers_union_across_providers_and_a_raising_sibling_is_skipped(monkeyp assert "X-Typo" not in headers +def test_the_header_names_every_provider_attaches_are_listed_without_resolving_a_credential(monkeypatch, no_cf_creds, + tmp_path): + (tmp_path / "config.toml").write_text(textwrap.dedent( + """ + [sandbox.providers.static] + impl = "tst.unit.providers.sandbox_providers.test_reachability_contributions:_StaticHeaderProvider" + [sandbox.providers.extra] + impl = "tst.unit.providers.sandbox_providers.test_reachability_contributions:_ExtraHeaderProvider" + """ + ) + _CF_TOML) + monkeypatch.setenv("AGENT_ENV_CONFIG", str(tmp_path / "config.toml")) + reset_config() + + assert sandbox_request_header_names() == ("cf-access-client-id", "cf-access-client-secret", "x-extra", + "x-extra-static", "x-static") + + def test_static_headers_emit_without_any_resolution(monkeypatch, tmp_path): import agent_env.config as config_mod From 86eac36b02bf670db5887b9055ab60763eb5fa0c Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 20:04:25 -0800 Subject: [PATCH 47/57] fix(computer): sites of different envs that share a name are routed apart, each named for its env (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../env/envs/computer/utils/sites.py | 22 +++++++++++---- tst/unit/env/envs/test_computer_sites.py | 27 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/agent_env/env/envs/computer/utils/sites.py b/src/agent_env/env/envs/computer/utils/sites.py index 507e33cb8..88889d76c 100644 --- a/src/agent_env/env/envs/computer/utils/sites.py +++ b/src/agent_env/env/envs/computer/utils/sites.py @@ -402,7 +402,9 @@ async def deployed_sites(deployed_envs: list[DeployedEnv], only: Optional[list[s stored card doesn't hold, every site of a record without a card or one whose card the gateway couldn't read when it composed its own, is read live at ``/svc/``, and has no names when it serves no card (404 or no JSON). Reading one fails otherwise, after ``CARD_ATTEMPTS`` tries, rather than route the site by names it didn't declare. - ``only`` keeps the websites of the envs it names, each of which must be deployed.""" + ``only`` keeps the websites of the envs it names, each of which must be deployed. Sites of different envs that + share a name are each named for their env (``_env_qualified``), so they're routed apart, while two that claim one + domain are still refused.""" from agentenv_protocol import client as protocol_v1 from agentenv_protocol.website import WEBSITE_BROWSE_URL_PARAM, WEBSITE_EXTENSION_URI @@ -415,7 +417,7 @@ async def deployed_sites(deployed_envs: list[DeployedEnv], only: Optional[list[s raise ValueError(f"site_routing names {', '.join(missing)}, which this run hasn't deployed before the " "computer") records = [d for d in records if d.env_id in only] - found = [] + found: list[tuple[str, dict]] = [] for record in records: address = gateway_url_of(record) or getattr(record, "environment_url", None) frontends = getattr(record, "website_frontend_urls", None) or {} @@ -433,12 +435,22 @@ async def deployed_sites(deployed_envs: list[DeployedEnv], only: Optional[list[s "isn't routed", child.name, record.env_id) continue named.add(service) - found.append(_found(child.name, url, params)) + found.append((record.env_id, _found(child.name, url, params))) for name, url in frontends.items(): if name not in named: card = await _site_card(protocol_v1, f"{gateway_url_of(record)}/svc/{name}", name) - found.append(_found(name, url, protocol_v1.extension_params(card, WEBSITE_EXTENSION_URI))) - return found + found.append((record.env_id, _found(name, url, protocol_v1.extension_params(card, WEBSITE_EXTENSION_URI)))) + return _env_qualified(found) + + +def _env_qualified(found: list[tuple[str, dict]]) -> list[dict]: + """Each site found, a name that sites of different envs share made ``-`` for each of them, letters, + digits, ``-`` and ``_`` kept, so every site of the run has a name of its own.""" + envs: dict[str, set[str]] = {} + for env_id, site in found: + envs.setdefault(site["name"], set()).add(env_id) + return [{**site, "name": re.sub(r"[^A-Za-z0-9_-]", "-", f"{env_id}-{site['name']}")} + if len(envs[site["name"]]) > 1 else site for env_id, site in found] def _served_as(child) -> str: diff --git a/tst/unit/env/envs/test_computer_sites.py b/tst/unit/env/envs/test_computer_sites.py index bd84c857d..114913d2b 100644 --- a/tst/unit/env/envs/test_computer_sites.py +++ b/tst/unit/env/envs/test_computer_sites.py @@ -361,6 +361,33 @@ async def get_card(url, timeout=10, verify=True): await sites.deployed_sites([gateway], only=["other"]) +@pytest.mark.asyncio +async def test_two_envs_each_serving_a_site_of_one_name_are_routed_apart_and_one_domain_twice_is_still_refused(): + from agent_env.env.env import DeployedGatewayEnv + + def gateway(env_id, *domains): + extension = {"uri": "urn:agentenv:website/v1", + "params": {"domains": list(domains), "browse_url": "/website/notes/"}} + card = {"name": env_id, "children_environments": [ + {"name": "notes", "url": "/svc/notes/agentenv", "capabilities": {"extensions": [extension]}}]} + return DeployedGatewayEnv(env_id=env_id, env_version=1, sandbox_id=env_id, + gateway_url=f"https://{env_id}.example", environment_card=card) + + found = await sites.deployed_sites([gateway("team.a", "notes.test"), gateway("team-b", "notes.example")]) + + assert [(s["name"], s["url"], s["domains"]) for s in found] == [ + ("team-a-notes", "https://team.a.example/website/notes/", ["notes.test"]), + ("team-b-notes", "https://team-b.example/website/notes/", ["notes.example"])] + table = sites.site_table([Site(**s) for s in found]) + assert {route.site: route.domains for route in table.routes} == {"team-a-notes": ("notes.test",), + "team-b-notes": ("notes.example",)} + assert set(sites.issue(table.routes, valid_for=timedelta(hours=1), label="x").leaves) == {"team-a-notes", + "team-b-notes"} + clash = await sites.deployed_sites([gateway("team-a", "notes.test"), gateway("team-b", "notes.test")]) + with pytest.raises(sites.SiteConflict, match="'notes.test' \\(team-b-notes\\) overlaps 'notes.test' \\(team-a-notes\\)"): + sites.site_table([Site(**s) for s in clash]) + + @pytest.mark.asyncio async def test_the_sites_a_run_deployed_are_its_stored_cards_website_children_at_the_routes_the_card_names( monkeypatch): From 0e37aa9e2f40b986e3845453d64167109b01c892 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 18:27:47 -0800 Subject: [PATCH 48/57] refactor(website): a WebSocket on a site's route is refused at the paths its card advertises, with no state path (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/env/gateway/gateway.py | 1 + tst/unit/env/gateway/website_websocket_test.py | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 9b6a673d4..283fd90a7 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -54,6 +54,7 @@ CHILD_DISCOVERY_TIMEOUT_S, DATA_PLANE_LOAD_TIMEOUT_MAX_S, DEFAULT_ROLE, + EXT_WEBSITE_URI, GATEWAY_COMPOSE_HOST, GATEWAY_EXTENSIONS, GATEWAY_TRAJECTORY_FILE, diff --git a/tst/unit/env/gateway/website_websocket_test.py b/tst/unit/env/gateway/website_websocket_test.py index c10798d90..76ecac6b4 100644 --- a/tst/unit/env/gateway/website_websocket_test.py +++ b/tst/unit/env/gateway/website_websocket_test.py @@ -150,12 +150,13 @@ async def test_a_websocket_to_the_sites_data_plane_or_card_is_refused(stack, pat @pytest.mark.asyncio -@pytest.mark.parametrize("path", ["/go", "/go/", "/x/..%2Fgo", "/grading/now", "/agentenv/ext/website/state"]) +@pytest.mark.parametrize("path", ["/grade", "/grade/", "/x/..%2Fgrade", "/grading/now", "/agentenv/ext/website/ready"]) async def test_a_websocket_to_a_path_the_sites_card_advertises_is_refused(path): site = EchoSite() card = {"name": "notes", "url": "/agentenv", "capabilities": {"extensions": [{ - "uri": "urn:agentenv:website/v1", "params": {"state": {"path": "/go"}, "methods": { - "state": {"method": "GET", "endpoint": "/agentenv/ext/website/state"}}}}, + "uri": "urn:agentenv:website/v1", "params": {"methods": { + "ready": {"method": "GET", "endpoint": "/agentenv/ext/website/ready"}, + "grade": {"method": "GET", "endpoint": "/grade"}}}}, {"uri": "urn:example:grading/v1", "params": {"endpoint": "/grading"}}]}} site.app.router.routes.insert(0, Route("/.well-known/agent-env.json", lambda request: JSONResponse(card))) site_server, site_port = await serve(site.app) From 27a865f98148f38b160f10424fc9953cebbc496d Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 20:43:53 -0800 Subject: [PATCH 49/57] fix(computer): write the routing config's keys over stdin, check site_routing before reading it, and rewrite scheme-relative redirects and spaced cookie paths (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/env/envs/computer/env.py | 4 ++ .../env/envs/computer/utils/site_proxy.py | 11 ++++-- .../env/envs/computer/utils/sites.py | 17 ++++++++- tst/unit/env/envs/test_computer_env.py | 9 +++++ tst/unit/env/envs/test_computer_site_proxy.py | 17 ++++++++- tst/unit/env/envs/test_computer_sites.py | 37 +++++++++++++++++-- 6 files changed, 85 insertions(+), 10 deletions(-) diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index 7ae348fab..8b65d64cc 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -252,6 +252,10 @@ async def run_env_params(self, env_params: Optional[dict], deployed_envs: list[D """With ``site_routing`` and no ``sites``, ``env_params`` naming as its sites the websites the run deployed before the computer: all of them, or those of the env ids ``site_routing`` lists.""" routing = (env_params or {}).get("site_routing") + if routing is not None and routing is not False and routing is not True and not ( + isinstance(routing, list) and all(isinstance(env_id, str) for env_id in routing)): + raise ValueError(f"computer env {self.id!r}: env_params.site_routing is true or a list of env ids, not " + f"{routing!r}") if not routing or env_params.get("sites"): return env_params found = await sites.deployed_sites(deployed_envs, None if routing is True else list(routing)) diff --git a/src/agent_env/env/envs/computer/utils/site_proxy.py b/src/agent_env/env/envs/computer/utils/site_proxy.py index 18319a361..83ea8e9b1 100644 --- a/src/agent_env/env/envs/computer/utils/site_proxy.py +++ b/src/agent_env/env/envs/computer/utils/site_proxy.py @@ -192,12 +192,14 @@ def origin(scheme: str, netloc: str) -> tuple[str, str, int]: def public_location(location: str, site: Site, scheme: str, host: str) -> str: """A ``Location`` the upstream sent, as the browser follows it: one on the site's route, under the gateway's address, under the site's own name (as a site that ignores ``AgentEnv-Site-Root`` may send) or as a path, is rewritten onto - the site's name; any other is left alone.""" + the site's name; any other is left alone. A scheme-relative one is read with the scheme of the page it was sent + to: the route's for the gateway's address, the browser's for the site's name.""" parts = urlsplit(location) - if parts.scheme or parts.netloc: - if origin(parts.scheme, parts.netloc) not in (origin(site.scheme, site.netloc), origin(scheme, host)): + if parts.netloc: + pages = ((site.scheme, site.netloc), (scheme, host)) + if not any(origin(parts.scheme or own, parts.netloc) == origin(own, netloc) for own, netloc in pages): return location - elif not location.startswith("/") or location.startswith("//"): + elif parts.scheme or not location.startswith("/"): return location path = parts.path if not (path == site.prefix or path.startswith(site.prefix + "/")) or not site.prefix: @@ -214,6 +216,7 @@ def public_cookie(value: str, site: Site) -> str: kept = [first] for attribute in attributes: key, _, attr_value = attribute.strip().partition("=") + key, attr_value = key.strip(), attr_value.strip() if key.lower() == "domain" and attr_value.strip().lstrip(".").lower() == site.host.lower(): continue if key.lower() == "path" and site.prefix and (attr_value == site.prefix diff --git a/src/agent_env/env/envs/computer/utils/sites.py b/src/agent_env/env/envs/computer/utils/sites.py index 88889d76c..a07499913 100644 --- a/src/agent_env/env/envs/computer/utils/sites.py +++ b/src/agent_env/env/envs/computer/utils/sites.py @@ -342,6 +342,20 @@ async def write(self, path: PurePosixPath, text: str) -> None: await self.run(["mkdir", "-p", str(path.parent)]) await self.sandbox.write_file_from_text(text, str(path)) + async def write_secret(self, path: PurePosixPath, text: str) -> None: + """Write ``text``, which holds keys or credentials, to ``path``. On a VM it's staged on the host in a folder + its owner alone can open (``secret_dir``), written over the exec's stdin (``write_secret_file``), so it's + never on a command line, and moved into the container, its staged copy removed. Other sandboxes write files + through their provider's API, as ``write`` does.""" + if not isinstance(self.sandbox, VmSandbox): + await self.write(path, text) + return + await self.run(["mkdir", "-p", str(path.parent)]) + async with self.sandbox.secret_dir() as secrets: + staged = f"{secrets}/{path.name}" + await self.sandbox.write_secret_file(text.encode(), staged) + await self.sandbox.docker_cp(staged, f"{self.sandbox.container_name}:{path}", remove_source=True) + async def run(self, argv: list[str]) -> str: if isinstance(self.sandbox, VmSandbox): return await self.sandbox.exec_script(f"docker exec -u 0 {shlex.quote(self.sandbox.container_name)} " @@ -382,7 +396,8 @@ async def route(sandbox: Sandbox, sites: list[Site], *, user: Optional[str], net channel = RootChannel(sandbox) await channel.private_folder(FOLDER) await channel.write(PROXY, _PROXY_SOURCE.read_text()) - await channel.write(CONFIG, json.dumps(proxy_config(table, issued, user=user, upstream_headers=upstream_headers))) + await channel.write_secret(CONFIG, json.dumps(proxy_config(table, issued, user=user, + upstream_headers=upstream_headers))) await channel.run(["chmod", "0600", str(CONFIG)]) output = await channel.run(["python3", str(PROXY), "install", str(CONFIG)]) report = json.loads(output.strip().splitlines()[-1]) diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 3e01e5e8e..87f326988 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -695,6 +695,15 @@ async def test_a_computer_with_site_routing_reaches_the_websites_the_run_deploye assert record["env_params"]["site_routing"] is True and record["env_params"]["sites"][0]["name"] == "notes" +@pytest.mark.asyncio +@pytest.mark.parametrize("routing", ["web-env", 1, ["web-env", 2], {"web-env": True}]) +async def test_a_site_routing_that_is_neither_true_nor_a_list_of_env_ids_is_refused_before_it_is_read( + base, driver, routing): + env = _put(base, driver, display={"width": 1600, "height": 900}) + with pytest.raises(ValueError, match="site_routing is true or a list of env ids"): + await env.run_env_params({"site_routing": routing}, [_sites_gateway()]) + + @pytest.mark.asyncio async def test_a_computer_without_site_routing_routes_nothing_whatever_websites_the_run_deployed( base, driver, docker, boots, routes): diff --git a/tst/unit/env/envs/test_computer_site_proxy.py b/tst/unit/env/envs/test_computer_site_proxy.py index af2cb83aa..05cc658a4 100644 --- a/tst/unit/env/envs/test_computer_site_proxy.py +++ b/tst/unit/env/envs/test_computer_site_proxy.py @@ -239,12 +239,13 @@ async def respond(start, headers, body, reader, writer): ("https://notes.test/a", "https://notes.test/a"), ("/a", "/a"), ("a/b", "a/b"), - ("//gw:8080/website/notes/a", "//gw:8080/website/notes/a"), + ("//gw:8080/website/notes/a", "https://notes.test/a"), + ("//other.example/website/notes/a", "//other.example/website/notes/a"), ("https://notes.test/website/notes/app/", "https://notes.test/app/"), ("https://notes.test:443/website/notes", "https://notes.test/"), ("http://notes.test/website/notes/a", "http://notes.test/website/notes/a"), ], ids=["path", "prefix-alone", "gateway-url", "another-site", "outside-routes", "other-scheme", "site-url", - "site-path", "relative", "scheme-relative", "site-url-under-prefix", "site-url-default-port", + "site-path", "relative", "scheme-relative", "scheme-relative-elsewhere", "site-url-under-prefix", "site-url-default-port", "site-name-other-scheme"]) def test_only_a_location_on_the_sites_route_is_moved_onto_its_name(location, expected): site = Site("notes", "http://gw:8080/website/notes/", ["notes.test"]) @@ -252,6 +253,18 @@ def test_only_a_location_on_the_sites_route_is_moved_onto_its_name(location, exp assert site_proxy.public_location(location, site, "https", "notes.test") == expected +@pytest.mark.parametrize(("cookie", "expected"), [ + ("s=1; Path = /website/notes/x", "s=1; Path=/x"), + ("s=1; path=/website/notes", "s=1; Path=/"), + ("s=1;PATH= /website/notes/a/b ; HttpOnly", "s=1; Path=/a/b; HttpOnly"), + ("s=1; Path=/website/other", "s=1; Path=/website/other"), +], ids=["spaced", "lower-case", "upper-case-spaced", "another-route"]) +def test_a_cookie_path_on_the_route_is_rooted_however_it_is_spelled(cookie, expected): + site = Site("notes", "http://gw:8080/website/notes/", ["notes.test"]) + + assert site_proxy.public_cookie(cookie, site) == expected + + @pytest.mark.asyncio async def test_a_chunked_upload_and_a_chunked_answer_are_relayed_as_framed(tmp_path): async def respond(start, headers, body, reader, writer): diff --git a/tst/unit/env/envs/test_computer_sites.py b/tst/unit/env/envs/test_computer_sites.py index 114913d2b..950eeb560 100644 --- a/tst/unit/env/envs/test_computer_sites.py +++ b/tst/unit/env/envs/test_computer_sites.py @@ -3,6 +3,7 @@ from __future__ import annotations +import contextlib import json from datetime import UTC, datetime, timedelta from types import SimpleNamespace @@ -147,7 +148,7 @@ class FakeVm(VmSandbox): vnc_url = None def __init__(self, report): - self.report, self.scripts, self.written, self.events = report, [], {}, [] + self.report, self.scripts, self.written, self.events, self.staged = report, [], {}, [], {} @property def container_name(self): @@ -163,6 +164,20 @@ async def write_file_from_text(self, content, destination_path, container=None): self.written[destination_path] = content self.events.append(("write", destination_path)) + @contextlib.asynccontextmanager + async def secret_dir(self): + yield "/tmp/secret-1" + self.events.append(("removed", "/tmp/secret-1")) + + async def write_secret_file(self, data, vm_path): + self.staged[vm_path] = data.decode() + self.events.append(("secret", vm_path)) + + async def docker_cp(self, source, destination, *, remove_source=False): + container, path = destination.split(":", 1) + self.written[path] = self.staged.pop(source) if remove_source else self.staged[source] + self.events.append(("cp", source, destination)) + async def exec_script(self, script, *, max_retries=0): self.scripts.append(script) self.events.append(script) @@ -188,6 +203,7 @@ async def test_route_installs_the_proxy_as_root_with_each_sites_route_as_the_com assert site["key_pem"] and config["ca_pem"] == routed["ca"] assert sandbox.scripts == ["docker exec -u 0 computer-1 mkdir -p /etc/agentenv/sites", "docker exec -u 0 computer-1 chmod 0700 /etc/agentenv/sites", + "docker exec -u 0 computer-1 mkdir -p /etc/agentenv/sites", "docker exec -u 0 computer-1 chmod 0600 /etc/agentenv/sites/config.json", "docker exec -u 0 computer-1 python3 /etc/agentenv/sites/site_proxy.py install " "/etc/agentenv/sites/config.json"] @@ -196,6 +212,20 @@ async def test_route_installs_the_proxy_as_root_with_each_sites_route_as_the_com assert "PRIVATE KEY" not in json.dumps(routed) +@pytest.mark.asyncio +async def test_the_config_holding_the_keys_is_written_over_stdin_into_a_private_staging_folder_never_as_text(): + sandbox = FakeVm(_REPORT) + + await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, + valid_for=timedelta(hours=1), label="desktop") + + assert ("write", "/etc/agentenv/sites/config.json") not in sandbox.events + secret = sandbox.events.index(("secret", "/tmp/secret-1/config.json")) + copied = sandbox.events.index(("cp", "/tmp/secret-1/config.json", "computer-1:/etc/agentenv/sites/config.json")) + assert secret < copied < sandbox.events.index(("removed", "/tmp/secret-1")) and sandbox.staged == {} + assert not any("PRIVATE KEY" in script for script in sandbox.scripts) + + @pytest.mark.asyncio async def test_the_headers_the_gateways_provider_asks_for_go_in_the_private_config_alone(monkeypatch): from agent_env.providers.sandbox_providers import sandbox_provider @@ -210,7 +240,8 @@ async def test_the_headers_the_gateways_provider_asks_for_go_in_the_private_conf (site,) = json.loads(sandbox.written["/etc/agentenv/sites/config.json"])["sites"] assert site["upstream_headers"] == {"CF-Access-Client-Secret": "s3cret"} private = sandbox.events.index("docker exec -u 0 computer-1 chmod 0600 /etc/agentenv/sites/config.json") - assert sandbox.events.index(("write", "/etc/agentenv/sites/config.json")) < private < len(sandbox.events) - 1 + copied = ("cp", "/tmp/secret-1/config.json", "computer-1:/etc/agentenv/sites/config.json") + assert sandbox.events.index(copied) < private < len(sandbox.events) - 1 assert "s3cret" not in json.dumps(routed) @@ -222,7 +253,7 @@ async def test_the_sites_keys_land_in_a_folder_root_alone_can_enter(): valid_for=timedelta(hours=1), label="desktop") private = sandbox.events.index("docker exec -u 0 computer-1 chmod 0700 /etc/agentenv/sites") - assert private < sandbox.events.index(("write", "/etc/agentenv/sites/config.json")) + assert private < sandbox.events.index(("cp", "/tmp/secret-1/config.json", "computer-1:/etc/agentenv/sites/config.json")) assert "key_pem" in sandbox.written["/etc/agentenv/sites/config.json"] From 1e338262b37c50cfd249794039145dee9b23fe8e Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 13:34:08 -0800 Subject: [PATCH 50/57] feat(website): record a website env's traffic as PCAP, served through traffic/v1 (FD-4683) This PR's change, rebased as one commit onto #448. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 4 +- .../examples/website/notes/README.md | 4 + .../src/agentenv_protocol/__init__.py | 14 ++ .../src/agentenv_protocol/traffic.py | 86 ++++++++ .../agentenv-protocol/tests/test_traffic.py | 87 ++++++++ src/agent_env/cli/env/deploy.py | 7 +- src/agent_env/env/envs/_deployment.py | 8 +- src/agent_env/env/envs/multi_env.py | 3 +- src/agent_env/env/envs/website.py | 3 +- src/agent_env/env/gateway/Dockerfile | 2 +- src/agent_env/env/gateway/constants.py | 15 ++ src/agent_env/env/gateway/entrypoint.py | 5 +- src/agent_env/env/gateway/gateway.py | 95 ++++++++- .../env_providers/env_gateway_provider.py | 152 ++++++++++++- src/agent_env/task/episode/rules.py | 3 +- src/agent_env/task/teardown.py | 16 +- src/agent_env/task_step/registry.py | 2 + .../task_step/task_steps/deploy_env.py | 12 +- .../task_step/task_steps/traffic_capture.py | 147 +++++++++++++ tst/unit/env/envs/test_multi_env_name.py | 3 +- tst/unit/env/gateway/traffic_test.py | 200 ++++++++++++++++++ .../env/gateway/website_websocket_test.py | 20 ++ .../env_providers/traffic_capture_test.py | 199 +++++++++++++++++ .../deploy_env_capture_traffic_test.py | 123 +++++++++++ tst/unit/task_step/traffic_capture_test.py | 158 ++++++++++++++ 25 files changed, 1340 insertions(+), 28 deletions(-) create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/traffic.py create mode 100644 packages/agentenv-protocol/tests/test_traffic.py create mode 100644 src/agent_env/task_step/task_steps/traffic_capture.py create mode 100644 tst/unit/env/gateway/traffic_test.py create mode 100644 tst/unit/providers/env_providers/traffic_capture_test.py create mode 100644 tst/unit/task_step/deploy_env_capture_traffic_test.py create mode 100644 tst/unit/task_step/traffic_capture_test.py diff --git a/AGENTS.md b/AGENTS.md index a8b4d6a4d..fb35c3ec8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,9 +117,9 @@ 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, ready when its card's `website/v1` `ready` answers a `WebsiteReadiness` that says so, as `agentenv_protocol.website` (`website_readiness`, `env_readiness`) defines a website env's health for the deploy and `DeployedEnv.readiness()` alike, or, for a card without `ready`, when its `health` path answers 200, which is deprecated; the gateway container's `env/gateway/constants.py` mirrors the URI and op, a test keeping them equal; 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.]` (`env/envs/computer/utils/install.py`): files, apt `packages` with extra `sources`, a `python` requirements file for a venv, `downloads` by URL and sha256, `run` scripts, the services the base's init runs, and `ports`; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch on a builder of the put's sandbox provider, by default the env's (`utils/builder.py`; `--sandbox` on `env build`, `bundle register` and `run`): this machine's Docker for `local`, else a VM of the provider, which fetches the put's stored files by signed URL, takes the rest inline and pushes to the image store itself, so it builds only with stores a remote VM reaches; each arch goes to the first link that runs it natively, else one that emulates it (a VM registers QEMU; `--no-emulate` leaves it `skipped` instead, for a later put), and its pair records the provider that built it (`builder`); a run or register that reuses a computer env composes what it has left (`pending`), a run only the forms its sandbox's deploys boot (`booted_forms`: on each link, the first form it boots natively, by the deploy's own rule, `boot.first_native`; a compose of a disk adds its container), and a deploy composes first what it boots that no compose started (`ComputerEnv.compose_unstarted`); tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile, build context and lock so any env with the same inputs reuses it; every layer's packages, requirements and downloads are resolved at put in a container of the base for each arch (`utils/packages.py`, `utils/resolve_in_base.py`), stored in the object store by sha256 under `agentenv-packages/`, and listed in a lock stored by a digest of its inputs, which any env with the same inputs reuses, and builds install only the lock's files, every install step `RUN --network=none`; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 (`utils/probing.py`) then boots each native build on the put's sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`, which declares each OS with its own decorator (`@linux(...)`, or `@install(os_type, spec)` for any OS type) holding its install, its launch and, optionally, a skill and settings and build settings that add to the class's there, and install steps and ready checks for every OS or those their `os=` names; everything that reads its settings, launch or steps asks for the computer's OS type; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference, over its env.toml's `[settings]` and a computer env's `settings` beside it in `apps`, each by top-level field (`params.app_settings`; checked at put, a required one left to the deploy; the deploy record keeps the merged values as `app_settings`, secrets withheld); its env.toml's `build_settings`, values of its `BuildSettings` checked at put, are written to `.agentenv/build_settings.json` in its folder, so they're part of the image's key, its install steps read them, and a deploy can't change them). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `env/gateway/gateway.py` is the env-side gateway server, container-only code. 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`. | +| `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, ready when its card's `website/v1` `ready` answers a `WebsiteReadiness` that says so, as `agentenv_protocol.website` (`website_readiness`, `env_readiness`) defines a website env's health for the deploy and `DeployedEnv.readiness()` alike, or, for a card without `ready`, when its `health` path answers 200, which is deprecated; the gateway container's `env/gateway/constants.py` mirrors the URI and op, a test keeping them equal; 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.]` (`env/envs/computer/utils/install.py`): files, apt `packages` with extra `sources`, a `python` requirements file for a venv, `downloads` by URL and sha256, `run` scripts, the services the base's init runs, and `ports`; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch on a builder of the put's sandbox provider, by default the env's (`utils/builder.py`; `--sandbox` on `env build`, `bundle register` and `run`): this machine's Docker for `local`, else a VM of the provider, which fetches the put's stored files by signed URL, takes the rest inline and pushes to the image store itself, so it builds only with stores a remote VM reaches; each arch goes to the first link that runs it natively, else one that emulates it (a VM registers QEMU; `--no-emulate` leaves it `skipped` instead, for a later put), and its pair records the provider that built it (`builder`); a run or register that reuses a computer env composes what it has left (`pending`), a run only the forms its sandbox's deploys boot (`booted_forms`: on each link, the first form it boots natively, by the deploy's own rule, `boot.first_native`; a compose of a disk adds its container), and a deploy composes first what it boots that no compose started (`ComputerEnv.compose_unstarted`); tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile, build context and lock so any env with the same inputs reuses it; every layer's packages, requirements and downloads are resolved at put in a container of the base for each arch (`utils/packages.py`, `utils/resolve_in_base.py`), stored in the object store by sha256 under `agentenv-packages/`, and listed in a lock stored by a digest of its inputs, which any env with the same inputs reuses, and builds install only the lock's files, every install step `RUN --network=none`; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 (`utils/probing.py`) then boots each native build on the put's sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`, which declares each OS with its own decorator (`@linux(...)`, or `@install(os_type, spec)` for any OS type) holding its install, its launch and, optionally, a skill and settings and build settings that add to the class's there, and install steps and ready checks for every OS or those their `os=` names; everything that reads its settings, launch or steps asks for the computer's OS type; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference, over its env.toml's `[settings]` and a computer env's `settings` beside it in `apps`, each by top-level field (`params.app_settings`; checked at put, a required one left to the deploy; the deploy record keeps the merged values as `app_settings`, secrets withheld); its env.toml's `build_settings`, values of its `BuildSettings` checked at put, are written to `.agentenv/build_settings.json` in its folder, so they're part of the image's key, its install steps read them, and a deploy can't change them). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `env/gateway/gateway.py` is the env-side gateway server, container-only code. 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. A deploy with `capture_traffic` (`deploy_env`'s, `env deploy --capture-traffic`; a website env, or a multi env with a website, on a VM, refused for any other env and in container mode) records every packet each website's server and frontend containers send and receive: beside each runs a capture service, tcpdump from the gateway's image in that container's network namespace (`network_mode: "service:"`), the only service holding `NET_RAW` and `NET_ADMIN`, rotating its classic pcap files (10 of 20 MB each, the oldest overwritten) onto a volume no website container mounts and the gateway mounts read-only and serves as `urn:agentenv:traffic/v1` (`list`, `GET /traffic/captures`, oldest first; `get`, `GET /traffic/capture?name=`), which `agentenv_protocol.traffic` defines (`TrafficCapture`, `list_traffic_captures`, `fetch_traffic_capture`), the gateway's `constants.py` mirroring its names, a test keeping them equal. Those routes take the per-deploy control token the record keeps (`DeployedEnv.control_token`, the gateway's `AGENTENV_CONTROL_TOKEN`), which the gateway takes off every request before it proxies one, so no site reads it; a capture that isn't running once the gateway is up refuses the deploy. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | | `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Every artifact but an environment universe, whose `metadata` key holds its legacy metadata file ids, carries a `metadata` dict outside its versioned fields, changed by compare-and-swap (`merge_metadata`). Built-ins: `CliArtifact` (`cli`), `ComputerArtifact` (`computer`, a base computer: its architectures, OS, session user, settings and container form, whose image `put` reads from its registry into `metadata.validated_images`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`: its environment's files by path, stored as the one wrapped `file_artifact_ref` every environment artifact had when it has one file at its filename, else as `file_artifact_refs`; `get_file_artifacts()` reads either, and a load hands each file to the env's `data/add` with its path, which a pre-v1 load, taking one file, refuses), `EnvironmentUniverseArtifact` (`environment_universe`), `SkillArtifact` (`skill`); `[artifacts] type_aliases` maps another stored spelling of a type onto one of them. A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull, or a published image's tag with its `image_digest` and its build context, which sandboxes pull by the digest and build from the context when they can't (the infra images, and a bundle agent built from a shipped agent's sources, `Plan.published`), or, written by `put_context`, only its build context, which a VM sandbox builds before running it (`VmSandbox.load_docker_images`) and Modal builds before creating its container (`ModalSandboxProvider.prepare_image`, outside a fallback chain's deadline); anything else that runs images by name (a container on the local provider, a lone server on any provider but Modal) refuses such an image. A build context (`utils/docker/context.py`) is what `docker build` would send, `.dockerignore` applied, written so equal files give equal bytes and an equal `source_digest`. | -| `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, among them `start_computer_recording` and `end_computer_recording`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. A computer's framework-only routes (everything but MCP and the card) take the per-deploy control token its provider boots it with (`DeployedEnv.control_token`, `AGENTENV_CONTROL_TOKEN`); framework calls send `deployed.control_headers`, `Task.run` runs every step under `protocol_v1.authorized` with each deployed env's (a `verify` included), and a `run_code` script inside the computer gets `input["env"]["headers"]`. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each env still up closes (`Env.close`; a computer env ends any recording still open), what stayed open is registered from its namespace, then every sandbox is terminated by id. `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). | +| `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/`, among them `start_computer_recording` and `end_computer_recording`, `collect_traffic_capture`, which stores each traffic/v1 capture of an env deployed with `capture_traffic` as a pcap `FileArtifact` in one `FileArtifactUniverse` recorded under `context.metadata["traffic_captures"][env_id]`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. A computer's framework-only routes (everything but MCP and the card) take the per-deploy control token its provider boots it with (`DeployedEnv.control_token`, `AGENTENV_CONTROL_TOKEN`); framework calls send `deployed.control_headers`, `Task.run` runs every step under `protocol_v1.authorized` with each deployed env's (a `verify` included), and a `run_code` script inside the computer gets `input["env"]["headers"]`. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each capturing env still up whose traffic the run didn't collect is collected, 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 namespaces, `@local` and `@hf` (`is_local_id`), each with a documents file of its own: while the CLI or a `build_app` server runs, a local id and what a 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/`. | | `config/` | The `Config` singleton (`get_config`, `configure`, `reset_config`) in `config/runtime.py`, file discovery in `config/loader.py`, and `load_impl`, which resolves `module:Class` pointers. `agent_env.store` re-exports the config names for compatibility. | | `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra), `smol_vm` (the `smol` extra), `tensorlake` (the `tensorlake` extra), `tenki` (the `tenki` extra), `vercel` (the `vercel` extra), `boxd` (the `boxd` extra), `coreweave` (the `coreweave` extra), `daytona` (the `daytona` extra), `boat` (the `boat` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, and for VMs on this machine the address their requests to it arrive at, `REACHES_HOST_AT`; `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store), `EnvironmentGatewayProvider` (`gateway`, an env's default), which renders a docker-compose for the gateway and its MCP servers inside the sandbox, and `EnvironmentServerProvider` (`server`), one MCP server in its own container with no gateway in front and no state store; `providers/env_state/` holds env-state providers (`local_postgres` built in). | diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index 861d6d643..0d55da1c4 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -99,6 +99,10 @@ agent-env env website put --id notes-site \ Run it from the repo root. `environment_name` comes from the card, which the spec names `notes`. The stored env has a server image and no frontend image. Pass `--skip-validation` to register without deploying. +To record what the site sends and receives, deploy it with `--capture-traffic` (or `"capture_traffic": true` on a +task's `deploy_env` step): tcpdump runs in the server container's network namespace and the gateway serves its pcap +files as `traffic/v1`, which a `collect_traffic_capture` step with the env's `env_id` stores as file artifacts. + `env.toml` describes the same env for a bundle. Its keys are `type`, `spec` and `backend_image`; the env takes its `environment_name`, health path and domains from the spec. A bundle builds `backend_image` with this folder as the build context, but this Dockerfile needs the repo root. Until the protocol package is installed from a diff --git a/packages/agentenv-protocol/src/agentenv_protocol/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/__init__.py index 61f9cbab9..fc67f4f3a 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/__init__.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/__init__.py @@ -4,6 +4,14 @@ from .client import ExtensionCallError from .errors import Applied, AgentEnvError from .preflight import intake_fit_check +from . import traffic +from .traffic import ( + TRAFFIC_EXTENSION_URI, + TrafficCapture, + TrafficCaptureListing, + fetch_traffic_capture, + list_traffic_captures, +) from .tool_client import ToolResult, ToolSession, ToolSessionError from .agent_env_environment import ( HIDDEN_BY_DEFAULT_META_KEY, @@ -88,6 +96,12 @@ __all__ = [ "client", + "traffic", + "TRAFFIC_EXTENSION_URI", + "TrafficCapture", + "TrafficCaptureListing", + "list_traffic_captures", + "fetch_traffic_capture", "AgentEnvApplication", "AgentEnvEnvironment", "AgentEnvFastMCPApplication", diff --git a/packages/agentenv-protocol/src/agentenv_protocol/traffic.py b/packages/agentenv-protocol/src/agentenv_protocol/traffic.py new file mode 100644 index 000000000..98289a643 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/traffic.py @@ -0,0 +1,86 @@ +"""Traffic capture, ``urn:agentenv:traffic/v1``: an env that records the packets its containers send and receive serves +them as capture files through its card. ``list`` (``TRAFFIC_LIST_OP``) answers a ``TrafficCaptureListing``, each file a +``TrafficCapture`` named for the container it holds, oldest first; ``get`` (``TRAFFIC_GET_OP``) streams the file a +``list`` entry names, given as its ``name`` param, as ``PCAP_MEDIA_TYPE``. Both are framework-only: they take the env's +control token (``client.control_headers``). ``serves_traffic`` reads whether a card advertises it, +``list_traffic_captures`` and ``fetch_traffic_capture`` call it as a card advertises it.""" +from __future__ import annotations + +from datetime import datetime + +import httpx +from pydantic import BaseModel, ConfigDict + +from .client import ExtensionCallError, find_extension_method, invoke_extension, request_headers + +TRAFFIC_EXTENSION_URI = "urn:agentenv:traffic/v1" +TRAFFIC_LIST_OP = "list" +TRAFFIC_GET_OP = "get" +TRAFFIC_FORMAT = "pcap" +PCAP_MEDIA_TYPE = "application/vnd.tcpdump.pcap" +LIST_TIMEOUT_S = 30.0 +FETCH_TIMEOUT = httpx.Timeout(600.0, connect=10.0) + + +class TrafficCapture(BaseModel): + """One capture file a ``list`` names: its ``name``, which ``get`` takes, the ``container`` whose packets it holds, + its size in ``bytes`` and when it was last written. Fields it doesn't name are ignored, so a reader keeps reading + an env that says more.""" + + model_config = ConfigDict(extra="ignore") + + name: str + container: str + bytes: int + modified_at: datetime + + +class TrafficCaptureListing(BaseModel): + """``list``'s answer: the files' ``format`` and each ``TrafficCapture``, oldest first within a container, so its + files read in the order their packets were captured.""" + + model_config = ConfigDict(extra="ignore") + + format: str = TRAFFIC_FORMAT + captures: list[TrafficCapture] = [] + + +def serves_traffic(card: dict) -> bool: + """Whether ``card`` advertises traffic/v1's ``list``; no network.""" + return isinstance(card, dict) and find_extension_method(card, TRAFFIC_EXTENSION_URI, TRAFFIC_LIST_OP) is not None + + +async def list_traffic_captures(base_url: str, card: dict, *, timeout: float = LIST_TIMEOUT_S, verify: bool = True, + headers: dict[str, str] | None = None) -> TrafficCaptureListing: + """The captures the env whose ``card`` is read from ``base_url`` serves, through its ``list``. Raises + ``ValueError`` when the card doesn't advertise it or the answer isn't a ``TrafficCaptureListing``, httpx's error + when it can't be asked, and ``ExtensionCallError`` for a non-2xx answer.""" + answer = await invoke_extension(base_url, card, TRAFFIC_EXTENSION_URI, timeout=timeout, verify=verify, + method=TRAFFIC_LIST_OP, headers=headers) + return TrafficCaptureListing.model_validate(answer) + + +async def fetch_traffic_capture(base_url: str, card: dict, name: str, path: str, *, + timeout: httpx.Timeout | float = FETCH_TIMEOUT, verify: bool = True, + headers: dict[str, str] | None = None) -> int: + """Stream the capture ``name``, as a ``list`` entry names it, from the env whose ``card`` is read from + ``base_url`` through its ``get`` to the file ``path``, and return its size. Raises ``ValueError`` when the card + doesn't advertise ``get``, httpx's error when it can't be asked, and ``ExtensionCallError`` for a non-2xx answer, + with nothing written.""" + op = find_extension_method(card, TRAFFIC_EXTENSION_URI, TRAFFIC_GET_OP) + if op is None or not op["endpoint"]: + raise ValueError(f"card does not advertise {TRAFFIC_EXTENSION_URI} {TRAFFIC_GET_OP}") + url = f"{base_url}{op['endpoint']}" + params = {"name": name} + request = {"params": params} if op["method"] == "GET" else {"json": params} + size = 0 + async with httpx.AsyncClient(verify=verify, timeout=timeout) as client: + async with client.stream(op["method"], url, headers=request_headers(url, headers), **request) as response: + if response.status_code >= 400: + await response.aread() + raise ExtensionCallError(response) + with open(path, "wb") as out: + async for chunk in response.aiter_bytes(): + out.write(chunk) + size += len(chunk) + return size diff --git a/packages/agentenv-protocol/tests/test_traffic.py b/packages/agentenv-protocol/tests/test_traffic.py new file mode 100644 index 000000000..4c09e3a0c --- /dev/null +++ b/packages/agentenv-protocol/tests/test_traffic.py @@ -0,0 +1,87 @@ +"""traffic/v1's client helpers: a card says whether an env serves it, ``list_traffic_captures`` reads its listing as +typed captures and ``fetch_traffic_capture`` streams one to a file, each at the endpoint and verb the card advertises, +with the headers given or granted.""" +from __future__ import annotations + +from datetime import datetime, timezone + +import httpx +import pytest + +from agentenv_protocol import client as protocol_v1 +from agentenv_protocol.traffic import ( + PCAP_MEDIA_TYPE, + TRAFFIC_EXTENSION_URI, + TrafficCapture, + fetch_traffic_capture, + list_traffic_captures, + serves_traffic, +) + +_RealAsyncClient = httpx.AsyncClient +CARD = {"capabilities": {"extensions": [{"uri": TRAFFIC_EXTENSION_URI, "params": {"methods": { + "list": {"method": "GET", "endpoint": "/svc/x/traffic/captures"}, + "get": {"method": "GET", "endpoint": "/svc/x/traffic/capture"}}}}]}} +LISTING = {"format": "pcap", "extra": 1, "captures": [ + {"name": "site-backend.pcap0", "container": "site-backend", "bytes": 24, "modified_at": "1970-01-01T00:16:40+00:00", + "more": "ignored"}]} + + +@pytest.fixture +def seen(monkeypatch): + requests: list[httpx.Request] = [] + + def env(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.headers.get("authorization") != "Bearer tok": + return httpx.Response(401, json={"ok": False, "error": {"code": "unauthorized", "message": "no token"}}) + if request.url.path.endswith("/captures"): + return httpx.Response(200, json=LISTING) + return httpx.Response(200, content=b"pcap-bytes", headers={"content-type": PCAP_MEDIA_TYPE}) + + transport = httpx.MockTransport(env) + monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: _RealAsyncClient(transport=transport, **kw)) + return requests + + +def test_a_card_serves_traffic_only_when_it_advertises_list(): + assert serves_traffic(CARD) + assert not serves_traffic({"capabilities": {"extensions": []}}) + assert not serves_traffic(None) + + +@pytest.mark.asyncio +async def test_list_reads_the_advertised_endpoint_as_typed_captures(seen): + listing = await list_traffic_captures("http://env", CARD, headers=protocol_v1.control_headers("tok")) + assert listing.format == "pcap" + assert listing.captures == [TrafficCapture(name="site-backend.pcap0", container="site-backend", bytes=24, + modified_at=datetime(1970, 1, 1, 0, 16, 40, tzinfo=timezone.utc))] + assert [str(request.url) for request in seen] == ["http://env/svc/x/traffic/captures"] + + +@pytest.mark.asyncio +async def test_fetch_streams_the_named_capture_to_a_file_with_the_granted_headers(seen, tmp_path): + path = tmp_path / "out.pcap" + with protocol_v1.authorized({"http://env": protocol_v1.control_headers("tok")}): + size = await fetch_traffic_capture("http://env", CARD, "site-backend.pcap0", str(path)) + assert (size, path.read_bytes()) == (10, b"pcap-bytes") + assert str(seen[0].url) == "http://env/svc/x/traffic/capture?name=site-backend.pcap0" + + +@pytest.mark.asyncio +async def test_a_refusal_raises_an_extension_call_error_and_writes_nothing(seen, tmp_path): + path = tmp_path / "out.pcap" + with pytest.raises(protocol_v1.ExtensionCallError) as refused: + await fetch_traffic_capture("http://env", CARD, "site-backend.pcap0", str(path), headers={}) + with pytest.raises(protocol_v1.ExtensionCallError): + await list_traffic_captures("http://env", CARD, headers={}) + assert refused.value.code == "unauthorized" and not path.exists() + + +@pytest.mark.asyncio +async def test_a_card_without_traffic_is_refused_before_any_call(seen, tmp_path): + with pytest.raises(ValueError): + await list_traffic_captures("http://env", {}, headers={}) + with pytest.raises(ValueError): + await fetch_traffic_capture("http://env", {}, "x.pcap0", str(tmp_path / "x"), headers={}) + assert seen == [] diff --git a/src/agent_env/cli/env/deploy.py b/src/agent_env/cli/env/deploy.py index f7afb1040..cdd17e6fa 100644 --- a/src/agent_env/cli/env/deploy.py +++ b/src/agent_env/cli/env/deploy.py @@ -51,7 +51,10 @@ @click.option("--keyed-roles", is_flag=True, default=False, help="Draw a role key for the deploy: its gateway then believes a role other than default only from a " "caller that sends the key beside it.") -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, keyed_roles: bool): +@click.option("--capture-traffic", is_flag=True, default=False, + help="Record every packet the env's websites' containers send and receive, as rotating pcap files its " + "gateway serves as traffic/v1 (VM deploys of website or multi envs only).") +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, keyed_roles: bool, capture_traffic: bool): """Deploy an MCP server environment.""" from agent_env.providers import build_sandbox_provider from agent_env.config import get_config @@ -94,6 +97,8 @@ def deploy(env_id: str, env_version: int | None, ttl_seconds: int, gateway_mode: click.echo(f"Env state instance id: {env_state_instance_id}") if keyed_roles: deploy_kwargs["keyed_roles"] = True + if capture_traffic: + deploy_kwargs["capture_traffic"] = True try: with run_scope(env.id): deployed_env = asyncio.run(env.deploy(**deploy_kwargs)) diff --git a/src/agent_env/env/envs/_deployment.py b/src/agent_env/env/envs/_deployment.py index 7659455b0..6dc06a735 100644 --- a/src/agent_env/env/envs/_deployment.py +++ b/src/agent_env/env/envs/_deployment.py @@ -288,13 +288,19 @@ def provider_or_class(env: Env) -> EnvironmentProvider | type[EnvironmentProvide def deploy_refusal(env: Env, provider: EnvironmentProvider | type[EnvironmentProvider], options: dict) -> str | None: """Why deploying env through provider, or a provider of that class, is refused before anything is built, or None: the provider - can't deploy an env like it (see ``provider_refusal``), or an option it can't take.""" + can't deploy an env like it (see ``provider_refusal``), ``capture_traffic`` for an env that serves no website, or an option it + can't take.""" + from agent_env.env.envs.website import WebsiteEnv + provider_class = provider if isinstance(provider, type) else type(provider) reason = provider_refusal(provider_class, type(env), mcp_names=[child.environment_name for child in getattr(env, "mcp_server_envs", ())], website_names=[child.environment_name for child in getattr(env, "website_envs", ())]) if reason is not None: return f"env '{env.id}' has env_provider_type '{env.env_provider_type}', which {reason}" + if options.get("capture_traffic") and not (isinstance(env, WebsiteEnv) or getattr(env, "website_envs", ())): + return (f"env '{env.id}' serves no website, and capture_traffic records what a website's containers send and " + "receive") return option_refusal(env, provider, options) diff --git a/src/agent_env/env/envs/multi_env.py b/src/agent_env/env/envs/multi_env.py index d1da07009..3789e308b 100644 --- a/src/agent_env/env/envs/multi_env.py +++ b/src/agent_env/env/envs/multi_env.py @@ -157,12 +157,13 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: ctx.refuse(problems) return fields - async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None, keyed_roles: bool = False) -> DeployedEnv: + async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None, keyed_roles: bool = False, capture_traffic: bool = False) -> DeployedEnv: deployed_env = await deploy_through_provider( self, environment_name=None, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type, disk_size_gb=disk_size_gb, gateway_mode=gateway_mode, cpu=cpu, memory_mb=memory_mb, env_state_type=env_state_type, env_state_instance_id=env_state_instance_id, attribution=attribution, artifact_id=artifact_id, artifact_version=artifact_version, **({"keyed_roles": True} if keyed_roles else {}), + **({"capture_traffic": True} if capture_traffic else {}), ) self._gateway_mode = gateway_mode self._mcp_server_name = deployed_env.mcp_server_name diff --git a/src/agent_env/env/envs/website.py b/src/agent_env/env/envs/website.py index c70a5e206..48848e21c 100644 --- a/src/agent_env/env/envs/website.py +++ b/src/agent_env/env/envs/website.py @@ -210,12 +210,13 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: fields["domains"] = spec.site.domains return fields - async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None, keyed_roles: bool = False) -> DeployedEnv: + async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None, keyed_roles: bool = False, capture_traffic: bool = False) -> DeployedEnv: return await deploy_through_provider( self, environment_name=self.environment_name, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type, disk_size_gb=disk_size_gb, gateway_mode=gateway_mode, cpu=cpu, memory_mb=memory_mb, env_state_type=env_state_type, env_state_instance_id=env_state_instance_id, attribution=attribution, artifact_id=artifact_id, artifact_version=artifact_version, **({"keyed_roles": True} if keyed_roles else {}), + **({"capture_traffic": True} if capture_traffic else {}), ) def deploy_refusal(self, *, sandbox_type: str | None = None, **options) -> str | None: diff --git a/src/agent_env/env/gateway/Dockerfile b/src/agent_env/env/gateway/Dockerfile index 73000f01d..4957dd39f 100644 --- a/src/agent_env/env/gateway/Dockerfile +++ b/src/agent_env/env/gateway/Dockerfile @@ -1,6 +1,6 @@ FROM public.ecr.aws/docker/library/python:3.11-slim@sha256:c20888b6acdd1e63e1c433a185bf3ad162c0288fe484616ce062e0d28add2900 -RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y curl tcpdump && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index 784eb080f..0af3614a2 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -81,6 +81,11 @@ def data_plane_load_timeout_s(size_bytes: int | None) -> int: EXT_NOTIFICATIONS_URI = "urn:agentenv:notifications/v1" EXT_WEBSITE_URI = "urn:agentenv:website/v1" WEBSITE_READY_OP = "ready" +EXT_TRAFFIC_URI = "urn:agentenv:traffic/v1" +TRAFFIC_LIST_OP = "list" +TRAFFIC_GET_OP = "get" +TRAFFIC_CAPTURE_DIR_ENV = "TRAFFIC_CAPTURE_DIR" +PCAP_MEDIA_TYPE = "application/vnd.tcpdump.pcap" WEBSITE_BROWSE_URL_PARAM = "browse_url" # Rows per GET /changes page: the default, and the most a client may ask for. @@ -149,6 +154,7 @@ def sources(names: list[str]) -> str: AGENT_ENV_SITE_ROOT_HEADER = "AgentEnv-Site-Root" GATEWAY_ROLE_KEY_ENV = "AGENTENV_ROLE_KEY" GATEWAY_PROVIDER_HEADERS_ENV = "AGENTENV_PROVIDER_REQUEST_HEADERS" +CONTROL_TOKEN_ENV = "AGENTENV_CONTROL_TOKEN" WEBSITE_ROLE_KEY_ENV = "WEBSITE_ROLE_KEY" # MCP request `_meta` keys the gateway stamps on every child tools/call: the caller's role, and on the MCP path a # key naming the inbound session, so a child can keep per-caller state behind the gateway's one shared session. @@ -272,3 +278,12 @@ def random_mcp_server_name() -> str: "required": False, }, ] +TRAFFIC_EXTENSION = { + "uri": EXT_TRAFFIC_URI, + "description": "Traffic (urn:agentenv:traffic/v1): the packets each website's server and frontend containers sent and received, captured by tcpdump in each container's network namespace as rotating classic pcap files. list names each capture file with the container it holds, its size and when it was last written, oldest first; get streams one as application/vnd.tcpdump.pcap. A container's oldest file is overwritten once its rotation is full. Harness control path: with a control token, each call takes it as its bearer.", + "params": {"endpoint": "/traffic/captures", "format": "pcap", "methods": { + TRAFFIC_LIST_OP: {"method": "GET", "endpoint": "/traffic/captures", "request": {"type": "object", "properties": {}}}, + TRAFFIC_GET_OP: {"method": "GET", "endpoint": "/traffic/capture", "request": {"type": "object", "properties": { + "name": {"type": "string", "minLength": 1}}, "required": ["name"]}}}}, + "required": False, +} diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 2912fbdbc..451f718e5 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -7,7 +7,8 @@ import httpx from . import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode, InternalMCPServer -from .constants import DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, GATEWAY_ROLE_KEY_ENV +from .constants import (CONTROL_TOKEN_ENV, DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, GATEWAY_ROLE_KEY_ENV, + TRAFFIC_CAPTURE_DIR_ENV) from .gateway import Gateway logger = logging.getLogger(__name__) @@ -154,5 +155,7 @@ def parse_name_url_map(env_str: str) -> dict[str, str]: service_db_url=service_db_url, role_key=os.environ.get(GATEWAY_ROLE_KEY_ENV) or None, provider_headers=os.environ.get(GATEWAY_PROVIDER_HEADERS_ENV, "").split(","), + traffic_dir=os.environ.get(TRAFFIC_CAPTURE_DIR_ENV) or None, + control_token=os.environ.get(CONTROL_TOKEN_ENV) or None, ) gateway.run() diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 283fd90a7..0511c9398 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -9,6 +9,7 @@ import logging import os import posixpath +import re import socket import subprocess from collections.abc import Iterable @@ -62,12 +63,14 @@ METHOD_ADD, METHOD_GET, METHOD_RESET, + PCAP_MEDIA_TYPE, PROTOCOL_VERSION, RPC_PATH, STATE_TOOL_NAME_CLASHES_KEY, STEP_SOURCE_POST_TURN_HOOK, TOOL_DISABLE_ACTION, TOOL_ENABLE_ACTION, + TRAFFIC_EXTENSION, WEBSITE_BROWSE_URL_PARAM, WELL_KNOWN_PATH, WILDCARD, @@ -102,6 +105,9 @@ # RFC 9110's safe methods: a request using one asks for no change, so the proxy measures no write window for it. _SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"}) _STEP_API = {"source": "step_api"} +_CAPTURE_FILE = re.compile(r"(?P[A-Za-z0-9][A-Za-z0-9_.-]*)\.pcap(?P[0-9]*)") +_CONTROL_SCOPE_KEY = "agentenv.control" +_AUTHORIZATION = b"authorization" class ToolNameClash(ValueError): @@ -142,11 +148,19 @@ def __init__( service_db_url: str | None = None, role_key: str | None = None, provider_headers: Iterable[str] = (), + traffic_dir: str | None = None, + control_token: str | None = None, ): """`role_key` is the deploy's secret: with one, a request is believed to act as a role other than `default` only when it carries the key beside the role; without one, as before it, the role header alone is believed. `provider_headers` names the headers the sandbox provider in front of the gateway is sent, its credentials, - which the gateway takes off every request it forwards to a service behind it.""" + which the gateway takes off every request it forwards to a service behind it. + + `traffic_dir` is the folder the deploy's capture sidecars write their websites' pcap files to, mounted here + read-only; with one, the gateway serves traffic/v1 from it. `control_token` is the bearer its framework-only + routes take (traffic/v1's): a request carrying it has it taken off before anything else sees it, so no site or + backing server it is proxied to ever reads it; without one, those routes are open, as an env's are without + ``AGENTENV_CONTROL_TOKEN``.""" self.host = host self.port = port self.server_name = server_name @@ -158,6 +172,8 @@ def __init__( self._service_db_url = service_db_url self._role_key = role_key or None self._withheld = frozenset(name.strip().lower().encode() for name in provider_headers if name.strip()) + self._traffic_dir = Path(traffic_dir) if traffic_dir else None + self._control_bearer = f"Bearer {control_token}".encode() if control_token else None self._website_routes: dict[str, list[WebsiteRoute] | None] = {} self._db_conn: psycopg2.extensions.connection | None = None if service_db_url: @@ -414,6 +430,15 @@ async def notifications_clear(request: Request) -> Response: async def env_trajectory(request: Request) -> Response: return self._serve_trajectory() + if self._traffic_dir is not None: + @self._mcp.custom_route("/traffic/captures", methods=["GET"]) + async def traffic_captures(request: Request) -> Response: + return self._refuse_uncontrolled(request) or await asyncio.to_thread(self._serve_captures) + + @self._mcp.custom_route("/traffic/capture", methods=["GET"]) + async def traffic_capture(request: Request) -> Response: + return self._refuse_uncontrolled(request) or self._serve_capture(request.query_params.get("name", "")) + @self._mcp.custom_route("/changes", methods=["GET"]) async def env_changes(request: Request) -> Response: return await self._serve_changes(request) @@ -457,6 +482,8 @@ def _asgi_app(self): inner.router.lifespan_context = self._process_lifespan(inner.router.lifespan_context) async def app(scope, receive, send): + if scope["type"] in ("http", "websocket") and self._control_bearer is not None: + scope = self._take_control_token(scope) if scope["type"] != "http": await inner(scope, receive, send) return @@ -475,6 +502,22 @@ async def app(scope, receive, send): return app + def _take_control_token(self, scope: dict) -> dict: + """``scope`` without any Authorization header that carries the gateway's control token, and marked when one + did, so a framework-only route can tell and nothing the request is proxied to ever reads the token.""" + headers = scope.get("headers", ()) + kept = [(name, value) for name, value in headers + if not (name == _AUTHORIZATION and hmac.compare_digest(value, self._control_bearer))] + return {**scope, "headers": kept, _CONTROL_SCOPE_KEY: len(kept) != len(headers)} + + def _refuse_uncontrolled(self, request: Request) -> Response | None: + """The refusal of a framework-only route's request that didn't carry the control token, or None: with no token, + every request is let through.""" + if self._control_bearer is None or request.scope.get(_CONTROL_SCOPE_KEY): + return None + return self._json_response({"ok": False, "error": {"code": "unauthorized", "message": + "this route is agent-env's own: it takes the env's control token"}}, 401) + @asynccontextmanager async def _session_lifespan(self, mcp: FastMCP): """Once per inbound MCP session: the session's key joins the caller, and a connect gets to heal a failed @@ -821,7 +864,7 @@ async def _fetch_child_card(self, client: httpx.AsyncClient, key: str, base_url: return None if child is None else self._rewrite_child_card(key, child) def _gateway_extensions(self) -> list[dict]: - return GATEWAY_EXTENSIONS + return [*GATEWAY_EXTENSIONS, TRAFFIC_EXTENSION] if self._traffic_dir is not None else GATEWAY_EXTENSIONS async def _serve_env_card(self) -> Response: """Compose the env card: the gateway's own extensions + each backing server's card nested @@ -1566,6 +1609,54 @@ def _read_changelog(self, after_id: int, count: int) -> list[dict]: row["timestamp"] = row["timestamp"].isoformat() return rows + def _capture_files(self) -> list[tuple[Path, re.Match]]: + """Each capture file in the traffic folder, as tcpdump's rotation names it, with its name's parts; a link is + none, so nothing but what the captures wrote is ever served.""" + try: + entries = list(self._traffic_dir.iterdir()) + except FileNotFoundError: + return [] + return [(path, match) for path in entries + if (match := _CAPTURE_FILE.fullmatch(path.name)) and path.is_file() and not path.is_symlink()] + + def _serve_captures(self) -> Response: + """traffic/v1's list: each capture file's name, the container it holds, its size and when it was last written, + oldest first, so a container's files read in the order their packets were captured.""" + found = [] + for path, match in self._capture_files(): + try: + stat = path.stat() + except FileNotFoundError: + continue + found.append(((match["container"], stat.st_mtime_ns, path.name), { + "name": path.name, "container": match["container"], "bytes": stat.st_size, + "modified_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat()})) + captures = [capture for _, capture in sorted(found, key=lambda entry: entry[0])] + return self._json_response({"format": "pcap", "captures": captures}, 200) + + def _serve_capture(self, name: str) -> Response: + """traffic/v1's get: the capture file ``name``, one ``list`` names, streamed as it stands; tcpdump writes each + packet as it captures it, so a file still being written ends on a whole packet, or at worst one torn.""" + if not _CAPTURE_FILE.fullmatch(name): + return self._json_response({"ok": False, "error": {"code": "invalid_params", + "message": f"{name!r} is not a capture's name, as list gives it"}}, 400) + path = self._traffic_dir / name + try: + if not path.is_file() or path.is_symlink(): + raise FileNotFoundError(name) + handle = os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW), "rb") + except OSError: + return self._json_response({"ok": False, "error": {"code": "not_found", + "message": f"no capture named {name!r}"}}, 404) + + def chunks(): + with handle: + while chunk := handle.read(65536): + yield chunk + + return StreamingResponse(chunks(), media_type=PCAP_MEDIA_TYPE, + headers={"Content-Disposition": f'attachment; filename="{name}"'}) + def _serve_trajectory(self) -> Response: """Stream the whole env trajectory as JSONL; only a mid-write tail line can arrive torn.""" try: 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 58fee59ab..34f227c38 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -23,10 +23,12 @@ from agent_env.env.env import DeployedGatewayEnv, _mcp_url from agent_env.env.gateway import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode from agent_env.env.gateway.constants import ( + CONTROL_TOKEN_ENV, DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, GATEWAY_ROLE_KEY_ENV, STATE_TOOL_NAME_CLASHES_KEY, + TRAFFIC_CAPTURE_DIR_ENV, WEBSITE_ROLE_KEY_ENV, WELL_KNOWN_PATH, random_mcp_server_name, @@ -95,6 +97,13 @@ WEBSITE_READY_TIMEOUT_S = 300.0 WEBSITE_READY_POLL_S = 2.0 +TRAFFIC_CAPTURE_SUFFIX = "traffic" +TRAFFIC_VOLUME = "traffic-captures" +TRAFFIC_GATEWAY_DIR = "/var/lib/agentenv/traffic" +TRAFFIC_SIDECAR_DIR = "/captures" +TRAFFIC_CAPTURE_FILE_MB = 20 +TRAFFIC_CAPTURE_FILES = 10 + @dataclass class MCPServerConfig: @@ -123,6 +132,7 @@ class DeployedGateway: environment_card: dict | None = None environment_card_read_at_utc: str | None = None role_key: str | None = None + control_token: str | None = None @dataclass @@ -298,6 +308,8 @@ def create_docker_compose( extra_hosts: tuple[str, ...] = (), mcp_server_name: str | None = None, role_key: str | None = None, + capture_traffic: bool = False, + control_token: str | None = None, ) -> str: """Generate docker-compose.yml content for a gateway deployment onto a VM/laptop/arbitrary machine. @@ -311,6 +323,11 @@ def create_docker_compose( website_configs: Optional list of website configurations (frontend + backend pairs). role_key: The deploy's role key, given to the gateway as AGENTENV_ROLE_KEY and to each website server as WEBSITE_ROLE_KEY; without one, both believe the AgentEnv-Role header as before it. + capture_traffic: Add a capture service beside each website's server container, and its deprecated + frontend container when it has one (see ``traffic_capture_services``): tcpdump, from the gateway's + image, in that container's network namespace, the only service holding NET_RAW and NET_ADMIN, writing + rotating pcap files to a volume the gateway mounts read-only and serves as traffic/v1. + control_token: The token the gateway's framework-only routes take, given to it as AGENTENV_CONTROL_TOKEN. Returns: docker-compose.yml content as a string. @@ -398,7 +415,8 @@ def create_docker_compose( env-network: driver: bridge """ - problems = _compose_name_problems(mcp_servers, website_configs, sidecars, images=(gateway_image,)) + problems = _compose_name_problems(mcp_servers, website_configs, sidecars, images=(gateway_image,), + capture_traffic=capture_traffic) if mcp_server_name and ("\n" in mcp_server_name or "\r" in mcp_server_name): problems.append(f"MCP server name {mcp_server_name!r} must be one line") if problems: @@ -474,6 +492,9 @@ def create_docker_compose( dep_lines += [f" {name}:", " condition: service_healthy"] for backend_name in website_backend_servers.values(): dep_lines += [f" {backend_name}:", " condition: service_healthy"] + captures = traffic_capture_services(website_configs) if capture_traffic else {} + for capture_name in captures: + dep_lines += [f" {capture_name}:", " condition: service_started"] if dep_lines: lines.append(" depends_on:") lines.extend(dep_lines) @@ -512,6 +533,12 @@ def create_docker_compose( gateway_env.append(f" - {GATEWAY_ROLE_KEY_ENV}={role_key}") if provider_headers := sandbox_request_header_names(): gateway_env.append(f" - {GATEWAY_PROVIDER_HEADERS_ENV}={','.join(provider_headers)}") + if control_token: + gateway_env.append(f" - {CONTROL_TOKEN_ENV}={control_token}") + gateway_volumes = [] + if capture_traffic: + gateway_env.append(f" - {TRAFFIC_CAPTURE_DIR_ENV}={TRAFFIC_GATEWAY_DIR}") + gateway_volumes = [" volumes:", f" - {_traffic_mount(TRAFFIC_GATEWAY_DIR, read_only=True)}"] publish = host_port or (lambda port: port) exposed_gateway_ports = [] if expose_gateway_port: @@ -526,6 +553,7 @@ def create_docker_compose( *gateway_env, *exposed_gateway_ports, *gateway_extra_hosts, + *gateway_volumes, " restart: on-failure", " healthcheck:", f' test: ["CMD-SHELL", "python3 -c \\"import socket; s=socket.create_connection((\'localhost\',{gateway_port}),2); s.close()\\""]', @@ -583,6 +611,9 @@ def create_docker_compose( "", ]) + for capture_name, container in captures.items(): + lines.extend([f" {capture_name}:", *_traffic_capture_service(gateway_image, container), ""]) + # Add sidecar services. # Secrets go through the compose environment block, never a docker-run command line. for sc in sidecars or []: @@ -609,6 +640,8 @@ def create_docker_compose( " env-network:", " driver: bridge", ]) + if capture_traffic: + lines.extend(["volumes:", f" {TRAFFIC_VOLUME}: {{}}"]) return "\n".join(lines) async def deploy( @@ -627,18 +660,24 @@ async def deploy( artifact_id: str | None = None, artifact_version: int | None = None, keyed_roles: bool = False, + capture_traffic: bool = False, ) -> DeployedGatewayEnv: """A gateway in front of the env, with the state store it acquires (local Postgres by default); returns the record. A MultiEnv's gateway fronts its MCP servers and websites, a WebsiteEnv's its website, and any other env's its one MCP server. - With ``keyed_roles``, the deploy draws a role key (see ``create_gateway``). + With ``keyed_roles``, the deploy draws a role key (see ``create_gateway``). With ``capture_traffic``, it captures + its websites' traffic (see ``create_gateway``), and refuses an env that serves no website. """ from agent_env.config import get_config, service_db_env_id from agent_env.env.env import Env from agent_env.providers.env_state import LOCAL_POSTGRES_STATE_TYPE, acquire_state_for_deploy topology = _gateway_topology(env) - if problems := _compose_name_problems(topology.mcp_servers, topology.website_configs): + if capture_traffic and not topology.website_configs: + raise ValueError(f"Can't capture {env.id!r}'s traffic: capture_traffic records what a website's containers " + "send and receive, and the env serves no website") + if problems := _compose_name_problems(topology.mcp_servers, topology.website_configs, + capture_traffic=capture_traffic): raise ValueError(f"Can't deploy {env.id!r} behind a gateway: {'; '.join(problems)}") state_instance = await acquire_state_for_deploy( env_state_type=env_state_type, ttl_seconds=ttl_seconds, name_hint=env.id, env_state_instance_id=env_state_instance_id, @@ -650,7 +689,7 @@ async def deploy( mcp_server_images=topology.mcp_server_images, website_configs=topology.website_configs, website_images=topology.website_images, mcp_server_name=topology.mcp_server_name, gateway_mode=gateway_mode, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, attribution=attribution, - keyed_roles=keyed_roles, + keyed_roles=keyed_roles, **({"capture_traffic": True} if capture_traffic else {}), ) return DeployedGatewayEnv( env_id=env.id, @@ -668,6 +707,7 @@ async def deploy( gateway_mode=gateway_mode.value, env_state_instance_ids=deployed.env_state_instance_ids, role_key=deployed.role_key, + control_token=deployed.control_token, ) async def create_gateway( @@ -691,6 +731,7 @@ async def create_gateway( *, attribution: Optional[Attribution] = None, keyed_roles: bool = False, + capture_traffic: bool = False, ) -> DeployedGateway: """Create and start a gateway from explicit topology, for a caller that builds its own record rather than calling ``deploy()``. @@ -708,22 +749,29 @@ async def create_gateway( ``keyed_roles`` draws a secret role key for the deploy, given to the gateway and each website server and kept on the record: the gateway then believes a role other than ``default`` only beside it, so every caller acting as one must send it. Off, as before the key, the role header alone is believed. + + ``capture_traffic`` records every packet each website's server and frontend containers send and receive, with + tcpdump in a capture service beside each, as rotating pcap files the gateway serves as traffic/v1, behind a + control token the deploy draws for it and keeps on the record (``control_token``). VM deploys only, since only + they serve websites; off, the stack is as before it. """ return await self._launch( sandbox_provider, env_id, state_instance=state_instance, mcp_servers=mcp_servers, mcp_server_images=mcp_server_images, gateway_port=gateway_port, website_configs=website_configs, website_images=website_images, gateway_mode=gateway_mode, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, existing_sandbox=existing_sandbox, sidecars=sidecars, mcp_server_name=mcp_server_name, - attribution=attribution, keyed_roles=keyed_roles, + attribution=attribution, keyed_roles=keyed_roles, **({"capture_traffic": True} if capture_traffic else {}), ) async def _launch(self, sandbox_provider: SandboxProvider, env_id: str | None, *, mcp_server_name: str | None = None, - keyed_roles: bool = False, **gateway) -> DeployedGateway: - """One gateway, deployed on the sandbox provider or each chained member in turn, then its tools probed.""" + keyed_roles: bool = False, capture_traffic: bool = False, **gateway) -> DeployedGateway: + """One gateway, deployed on the sandbox provider or each chained member in turn, then its tools probed. Its role + key and control token are drawn once, so every chained attempt holds the same.""" attempt = functools.partial( self._deploy_gateway, **gateway, mcp_server_name=mcp_server_name or random_mcp_server_name(), # drawn once, so every chained attempt presents the same name role_key=secrets.token_urlsafe(32) if keyed_roles else None, + **({"capture_traffic": True, "control_token": secrets.token_urlsafe(32)} if capture_traffic else {}), ) earlier_sandbox = self._sandbox result = await self._run(sandbox_provider, env_id, attempt) @@ -776,6 +824,8 @@ async def _deploy_via_vm( sidecars: list[SidecarConfig] | None = None, mcp_server_name: str | None = None, role_key: str | None = None, + capture_traffic: bool = False, + control_token: str | None = None, ) -> DeployedGateway: from agent_env.env.env import Env from agent_env.config import gateway_env_id, get_config, website_browser_env_id @@ -868,6 +918,7 @@ async def _stand_up(_spec): extra_hosts=sandbox.extra_hosts, mcp_server_name=mcp_server_name, role_key=role_key, + **({"capture_traffic": True, "control_token": control_token} if capture_traffic else {}), ) logger.info(f"Generated docker-compose.yml:\n{_redact_compose_secrets(compose_content)}") @@ -916,6 +967,8 @@ async def _stand_up(_spec): if not gateway_ready: down = await self._down_services(sandbox) raise RuntimeError("Gateway did not become ready in time" + (f":\n{down}" if down else "")) + if capture_traffic: + await self._refuse_stopped_captures(sandbox, list(traffic_capture_services(website_configs))) # Check container status only on the sidecars the provider actually rendered sidecar_names = ( @@ -964,6 +1017,7 @@ async def _stand_up(_spec): website_frontend_urls=website_frontend_urls, mcp_server_name=mcp_server_name, env_state_instance_ids=[state_instance.instance_id], environment_card=card, environment_card_read_at_utc=card_read_at_utc, role_key=role_key, + control_token=control_token, ) async def _deploy_via_containers( @@ -1211,6 +1265,32 @@ async def _down_services(self, sandbox: VmSandbox) -> str | None: logger.warning(f"Can't list the stack's services: no answer in {_DOWN_SERVICES_TIMEOUT_S:.0f}s") return None + async def _refuse_stopped_captures(self, sandbox: VmSandbox, capture_services: list[str]) -> None: + """Refuse a deploy whose capture services aren't all running once the gateway is up, as one is whose gateway + image has no tcpdump: it would record nothing. A lookup that fails is a warning, since it says nothing of them.""" + try: + exit_code, stdout, stderr = await sandbox.exec_with_output( + "sudo", "docker", "compose", "-f", DOCKER_COMPOSE_PATH, "ps", "-a", "--format", "json") + except Exception as e: + logger.warning(f"Can't check the traffic captures are running: {e}") + return + if exit_code != 0: + logger.warning(f"Can't check the traffic captures are running: docker compose ps exited {exit_code}: " + f"{stderr.strip()[-500:]}") + return + rows = {row.get("Service"): row for row in _compose_ps_rows(stdout)} + stopped = [] + for name in capture_services: + row = rows.get(name) + if row is None or row.get("State") != "running": + tail = await self._log_tail(sandbox, str((row or {}).get("ID") or "")) + why = _why_down(row) if row is not None else "has no container" + stopped.append(f"{name} {why or row.get('State')}" + (f":\n{tail}" if tail else "")) + if stopped: + raise RuntimeError("The env's traffic captures aren't running, so its traffic wouldn't be recorded (a gateway " + "image built before captures has no tcpdump: put the gateway env again):\n" + + "\n".join(stopped)) + async def _read_down_services(self, sandbox: VmSandbox) -> str | None: try: exit_code, stdout, stderr = await sandbox.exec_with_output( @@ -1415,9 +1495,11 @@ async def _deploy_gateway( mcp_server_name: str | None = None, attribution: Optional[Attribution] = None, role_key: str | None = None, + capture_traffic: bool = False, + control_token: str | None = None, ) -> DeployedGateway: """One gateway deploy on one (unchained) sandbox provider: a VM on one that creates VMs, else a container per - server on its private network.""" + server on its private network, which captures no traffic.""" from agent_env.providers.env_state import LocalPostgresStateProvider, build_state_provider # Every attempt starts from the caller's store: a failed attempt's close() cleared it. @@ -1429,6 +1511,9 @@ async def _deploy_gateway( ) deploy_kwargs = {"attribution": dict(attribution or {})} if not self.in_vm(sandbox_provider): + if capture_traffic: + raise ValueError(f"Can't capture traffic on {type(sandbox_provider).__name__}, which runs the gateway in " + "containers: a capture runs beside its website's container on the gateway's VM") if problem := self.sandbox_problem(sandbox_provider, websites=bool(website_configs)): raise ValueError(f"Can't deploy a gateway on {type(sandbox_provider).__name__}, which {problem}") if existing_sandbox is not None: @@ -1449,6 +1534,7 @@ async def _deploy_gateway( gateway_mode=gateway_mode, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, existing_sandbox=existing_sandbox, sidecars=sidecars, mcp_server_name=mcp_server_name, role_key=role_key, **deploy_kwargs, + **({"capture_traffic": True, "control_token": control_token} if capture_traffic else {}), ) except BaseException: await self._terminate_new_sandbox(earlier_sandbox=earlier_sandbox, existing_sandbox=existing_sandbox) @@ -1561,6 +1647,47 @@ def compose_env_entry(name: str, value: str) -> str: return '"' + "".join(escaped) + '"' +def traffic_capture_services(website_configs: list[WebsiteConfig] | None) -> dict[str, str]: + """The capture service a deploy capturing its websites' traffic runs for each website container, by name, each + with the compose service of the container whose network namespace it captures in: every website's server, and its + deprecated frontend when it has one.""" + containers = [f"{wc.environment_name}-{suffix}" for wc in website_configs or [] + for suffix in (AGENT_ENV_WEBSITE_BACKEND_SUFFIX, AGENT_ENV_WEBSITE_FRONTEND_SUFFIX) + if suffix == AGENT_ENV_WEBSITE_BACKEND_SUFFIX or wc.frontend_image] + return {f"{container}-{TRAFFIC_CAPTURE_SUFFIX}": container for container in containers} + + +def _traffic_mount(path: str, *, read_only: bool = False) -> str: + """The compose ``volumes:`` entry mounting ``TRAFFIC_VOLUME`` at ``path``.""" + return f"{TRAFFIC_VOLUME}:{path}" + (":ro" if read_only else "") + + +def _traffic_capture_service(gateway_image: str, container: str) -> list[str]: + """The compose lines of the service capturing ``container``'s traffic: tcpdump, from the gateway's image, on the + container's own interface, every packet whole and written as it's captured, rotating through + ``TRAFFIC_CAPTURE_FILES`` files of ``TRAFFIC_CAPTURE_FILE_MB`` million bytes named for the container, its oldest + overwritten once they're full, so a long run holds at most their product per container on the VM's disk. It joins + the container's network namespace, so it sees what the container sends and receives and nothing else, and is the + only service given the capabilities a capture needs. It writes to ``TRAFFIC_VOLUME``, which the gateway mounts + read-only and no website container mounts at all.""" + argv = ["-i", "eth0", "-n", "-s", "0", "-U", "-Z", "root", "-C", str(TRAFFIC_CAPTURE_FILE_MB), + "-W", str(TRAFFIC_CAPTURE_FILES), "-w", f"{TRAFFIC_SIDECAR_DIR}/{container}.pcap"] + return [ + f" image: {gateway_image}", + f' network_mode: "service:{container}"', + " depends_on:", + f" - {container}", + " cap_add:", + " - NET_RAW", + " - NET_ADMIN", + ' entrypoint: ["tcpdump"]', + f" command: {json.dumps(argv)}", + " volumes:", + f" - {_traffic_mount(TRAFFIC_SIDECAR_DIR)}", + " restart: unless-stopped", + ] + + 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 @@ -1608,10 +1735,12 @@ 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]: + sidecars: list[SidecarConfig] | None = None, *, images: tuple[str, ...] = (), + capture_traffic: bool = False) -> list[str]: """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.""" + MCP server's name; so are the capture services of a deploy with ``capture_traffic``), 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) if ref is not None)] @@ -1619,7 +1748,8 @@ def _compose_name_problems(mcp_servers: list[MCPServerConfig], website_configs: 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))]) + for suffix in (AGENT_ENV_WEBSITE_BACKEND_SUFFIX, AGENT_ENV_WEBSITE_FRONTEND_SUFFIX)), + *(traffic_capture_services(website_configs) if capture_traffic else ())]) 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] diff --git a/src/agent_env/task/episode/rules.py b/src/agent_env/task/episode/rules.py index fc5a77536..875d09946 100644 --- a/src/agent_env/task/episode/rules.py +++ b/src/agent_env/task/episode/rules.py @@ -148,7 +148,8 @@ def _rubrics_verifier(step: RubricsVerifierTaskStep, outside: Outside) -> list[s UNAFFECTED = frozenset({ # They name no agent. - "aggregate_verifiers", "apply_server_config", "build_mcp_cli", "combine_universe_verdicts", "configure_scheduled_checks", + "aggregate_verifiers", "apply_server_config", "build_mcp_cli", "collect_traffic_capture", "combine_universe_verdicts", + "configure_scheduled_checks", "deploy_env", "deploy_sandbox", "end_computer_recording", "start_computer_recording", "env_outcome_verifier", "modify_env_tool_access", "reset_env", "review", "run_container_unit_tests_verifier", "run_docker_container", "sign_in_browser", "snapshot_env", "snapshot_computer_env", "sync_env_clock", diff --git a/src/agent_env/task/teardown.py b/src/agent_env/task/teardown.py index 9f11627c4..a0aa9e709 100644 --- a/src/agent_env/task/teardown.py +++ b/src/agent_env/task/teardown.py @@ -3,7 +3,8 @@ ``teardown_run`` takes down everything a run's context records: its bare sandboxes, its envs and its agents. It removes compute only; the instance, its outputs and the artifacts it collected stay. What has to reach the stores goes first: the changelogs staged on its agents (``before_teardown``), the recordings the run left open, ended with fresh -grants whatever env serves them, and what each env saves as it closes (``Env.close``); a recording still open after +grants whatever env serves them, the traffic captures of each env deployed with ``capture_traffic`` that the run didn't +collect, and what each env saves as it closes (``Env.close``); a recording still open after that is registered from what its namespace holds. Then every sandbox is terminated by id, and a local one's work folder goes once it's down. ``agent-env run`` and ``agent-env eval run`` call it after each run, and the ``teardown_sandboxes`` step takes down part of a run the same way (``tear_down``). ``Task.run`` never does, so a @@ -31,6 +32,7 @@ from agent_env.task.interrupts import tearing_down from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_steps.computer_recording import end_open_recordings, register_open_recordings +from agent_env.task_step.task_steps.traffic_capture import collect_uncollected_captures logger = logging.getLogger(__name__) @@ -116,7 +118,8 @@ async def teardown_run(context: TaskStepContext, *, timeout: float = TERMINATE_T async def tear_down(context: TaskStepContext, sandboxes: Sequence[RecordedSandbox], envs: Sequence[DeployedEnv], *, timeout: float) -> TeardownReport: - """End the recordings the run left open on ``envs`` with fresh grants, close each env still up, register any + """End the recordings the run left open on ``envs`` with fresh grants, collect the traffic captures of each of them + that captures and that the run didn't collect, close each env still up, register any recording still open from its namespace, then terminate each of ``sandboxes`` by id, all at once and each within ``timeout`` seconds, and remove a local one's work folder once it's down. A close that fails is logged: its env's sandboxes are terminated by id regardless, which leaves one the close took down as it is. Cancelling it returns what it reached, with the rest in ``left``.""" @@ -129,8 +132,13 @@ async def tear_down(context: TaskStepContext, sandboxes: Sequence[RecordedSandbo await end_open_recordings(context, env_ids) except TimeoutError: logger.warning("teardown: the run's open recordings weren't all ended within %gs", timeout) - await asyncio.gather(*(_close(env, timeout) for env in envs - if any(sandbox_id in up for sandbox_id, _ in env_sandbox_ids(env)))) + still_up = [env for env in envs if any(sandbox_id in up for sandbox_id, _ in env_sandbox_ids(env))] + try: + async with asyncio.timeout(timeout): + await collect_uncollected_captures(context, still_up) + except TimeoutError: + logger.warning("teardown: the run's uncollected traffic captures weren't all collected within %gs", timeout) + await asyncio.gather(*(_close(env, timeout) for env in still_up)) await asyncio.to_thread(register_open_recordings, context, env_ids) except asyncio.CancelledError: return TeardownReport(left=pending) diff --git a/src/agent_env/task_step/registry.py b/src/agent_env/task_step/registry.py index 0c8ebd7a5..f482a64be 100644 --- a/src/agent_env/task_step/registry.py +++ b/src/agent_env/task_step/registry.py @@ -39,6 +39,7 @@ def _builtin_registry() -> dict[str, type["TaskStep"]]: from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep from agent_env.task_step.task_steps.reset_env import ResetEnvTaskStep from agent_env.task_step.task_steps.teardown_sandboxes import TeardownSandboxesTaskStep + from agent_env.task_step.task_steps.traffic_capture import CollectTrafficCaptureTaskStep from agent_env.task_step.task_steps.verifiers.env_outcome_verifier import EnvOutcomeVerifierTaskStep from agent_env.task_step.task_steps.load_artifact import LoadArtifactTaskStep from agent_env.task_step.task_steps.modify_env_tool_access import ModifyEnvToolAccessStep @@ -91,6 +92,7 @@ def _builtin_registry() -> dict[str, type["TaskStep"]]: _get_type(CollectArtifactsTaskStep): CollectArtifactsTaskStep, _get_type(StartComputerRecordingTaskStep): StartComputerRecordingTaskStep, _get_type(EndComputerRecordingTaskStep): EndComputerRecordingTaskStep, + _get_type(CollectTrafficCaptureTaskStep): CollectTrafficCaptureTaskStep, _get_type(DeployAgentTaskStep): DeployAgentTaskStep, _get_type(DeployEnvTaskStep): DeployEnvTaskStep, _get_type(DeployHumanAgentTaskStep): DeployHumanAgentTaskStep, diff --git a/src/agent_env/task_step/task_steps/deploy_env.py b/src/agent_env/task_step/task_steps/deploy_env.py index 22530034f..d12609f75 100644 --- a/src/agent_env/task_step/task_steps/deploy_env.py +++ b/src/agent_env/task_step/task_steps/deploy_env.py @@ -49,6 +49,7 @@ def __init__( artifact_version: Optional[int] = None, env_params: Optional[dict] = None, keyed_roles: bool = False, + capture_traffic: bool = False, ): super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error) self.env_id = env_id @@ -68,6 +69,7 @@ def __init__( # The env type's own deploy options, which this step passes to its deploy() without reading them. self.env_params = env_params self.keyed_roles = keyed_roles + self.capture_traffic = capture_traffic def run_state(self, user_overrides: Mapping[str, Any]) -> tuple[str | None, str | None]: """The env state type and attached state instance a run with ``user_overrides`` deploys on: each the run's, @@ -92,6 +94,8 @@ def to_dict(self) -> dict: base["metadata"] = self.metadata base["env_params"] = self.env_params base["keyed_roles"] = self.keyed_roles + if self.capture_traffic: + base["capture_traffic"] = True return base @classmethod @@ -113,6 +117,7 @@ def from_dict(cls, data: dict) -> DeployEnvTaskStep: metadata=dict(data.get("metadata") or {}), env_params=data.get("env_params"), keyed_roles=bool(data.get("keyed_roles", False)), + capture_traffic=bool(data.get("capture_traffic", False)), ) def preflight(self) -> list[str]: @@ -138,7 +143,8 @@ def preflight(self) -> list[str]: def _env_params_option(self) -> dict: return {**({"env_params": self.env_params} if self.env_params else {}), - **({"keyed_roles": True} if self.keyed_roles else {})} + **({"keyed_roles": True} if self.keyed_roles else {}), + **({"capture_traffic": True} if self.capture_traffic else {})} async def execute(self, context: TaskStepContext) -> TaskStepContext: from agent_env.env.env import Env @@ -197,6 +203,10 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: if "keyed_roles" not in params and not accepts_kwargs: raise ValueError(f"deploy_env '{self.id}': {env.type} envs take no keyed_roles") deploy_kwargs["keyed_roles"] = True + if self.capture_traffic: + if "capture_traffic" not in params and not accepts_kwargs: + raise ValueError(f"deploy_env '{self.id}': {env.type} envs take no capture_traffic") + deploy_kwargs["capture_traffic"] = True deployed_env = await env.deploy(**deploy_kwargs) # create_instance runs inside env.deploy(), so an annotation set after it # returns has to be persisted explicitly to reach the instance record. diff --git a/src/agent_env/task_step/task_steps/traffic_capture.py b/src/agent_env/task_step/task_steps/traffic_capture.py new file mode 100644 index 000000000..aa2e0b65d --- /dev/null +++ b/src/agent_env/task_step/task_steps/traffic_capture.py @@ -0,0 +1,147 @@ +"""Collect a website env's traffic captures. A website or multi env deployed with ``capture_traffic`` records every +packet its websites' containers send and receive as rotating pcap files, which its gateway's card serves as traffic/v1 +behind the deploy's control token. ``collect_traffic_capture`` stores each as a FileArtifact and registers them as one +FileArtifactUniverse, recorded in ``context.metadata[TRAFFIC_KEY][env_id]``. Teardown collects any capturing env a run +didn't, before the env closes (``collect_uncollected_captures``), so a capture isn't lost with its VM.""" +from __future__ import annotations + +import asyncio +import logging +import os +import tempfile +import time +from collections.abc import Sequence +from typing import ClassVar, Optional + +from agentenv_protocol.traffic import ( + PCAP_MEDIA_TYPE, + TRAFFIC_EXTENSION_URI, + TRAFFIC_LIST_OP, + TrafficCapture, + fetch_traffic_capture, + list_traffic_captures, + serves_traffic, +) + +from agent_env.artifact import FileArtifact +from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse +from agent_env.artifact.store import get_artifact_store +from agent_env.config import get_config +from agent_env.entity.refs import EntityRef +from agent_env.env.env import DeployedEnv, EnvCapabilityUnsupported +from agent_env.store.ids import derive_id, is_local_id, validate_local_id +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_step import TaskStep, TaskStepDependency + +logger = logging.getLogger(__name__) + +#: ``context.metadata[TRAFFIC_KEY][env_id]``: each env whose captures a run collected, or tried to. +TRAFFIC_KEY = "traffic_captures" +_ARTIFACT_TYPE = "traffic_captures" + + +class CollectTrafficCaptureTaskStep(TaskStep): + """Store the traffic captures of the deployed env ``env_id`` as pcap FileArtifacts. Best effort by default: a + failure is recorded and logged, and fails the task only with ``fail_task_on_error``.""" + + type: ClassVar[str] = "collect_traffic_capture" + entity_refs = (EntityRef.env("env_id"),) + + def __init__(self, id: str, version: Optional[int], env_id: str, + depends_on: Optional[list[TaskStepDependency]] = None, fail_task_on_error: bool = False): + super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error) + if not env_id: + raise ValueError("collect_traffic_capture needs the env_id of an env deployed with capture_traffic") + self.env_id = env_id + + def to_dict(self) -> dict: + return {**super().to_dict(), "env_id": self.env_id} + + @classmethod + def from_dict(cls, data: dict) -> CollectTrafficCaptureTaskStep: + base = cls._base_from_dict(data) + base["fail_task_on_error"] = data.get("fail_task_on_error", False) + return cls(**base, env_id=data["env_id"]) + + async def execute(self, context: TaskStepContext) -> TaskStepContext: + deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None) + if deployed is None: + error = RuntimeError(f"env {self.env_id!r} isn't deployed in this run") + context.metadata.setdefault(TRAFFIC_KEY, {})[self.env_id] = _failed(error) + logger.warning("collect_traffic_capture: %s", error) + if self.fail_task_on_error: + raise error + return context + entry = await collect_captures(context, deployed) + if self.fail_task_on_error and entry["state"] != "collected": + raise RuntimeError(f"env {self.env_id!r}'s traffic captures weren't collected: {entry.get('error')}") + return context + + +def captures_traffic(deployed: DeployedEnv) -> bool: + """Whether the stored card of ``deployed`` serves traffic/v1, as a deploy with ``capture_traffic`` does; no + network.""" + return isinstance(deployed, DeployedEnv) and serves_traffic(deployed.environment_card) + + +async def collect_uncollected_captures(context: TaskStepContext, envs: Sequence[DeployedEnv]) -> None: + """Collect the captures of each of ``envs`` that serves traffic/v1 and whose captures the run hasn't collected or + tried to, as teardown does before the envs close; ``collect_captures`` records what happened to each.""" + tried = context.metadata.get(TRAFFIC_KEY) or {} + for deployed in envs: + if captures_traffic(deployed) and deployed.env_id not in tried: + await collect_captures(context, deployed) + + +async def collect_captures(context: TaskStepContext, deployed: DeployedEnv) -> dict: + """Store each capture ``deployed`` serves as a pcap FileArtifact, register them as one FileArtifactUniverse, and + record it in ``context.metadata[TRAFFIC_KEY]``. Each file is named for its container and its place among that + container's files, oldest first, so they read in the order their packets were captured. Never raises: the entry, + which it also returns, says what happened.""" + entries = context.metadata.setdefault(TRAFFIC_KEY, {}) + try: + if not captures_traffic(deployed): + raise EnvCapabilityUnsupported(TRAFFIC_EXTENSION_URI, TRAFFIC_LIST_OP, deployed.env_id, deployed.instance_id) + listing = await list_traffic_captures(deployed.environment_url, deployed.environment_card, + headers=deployed.control_headers) + entries[deployed.env_id] = await _store(context, deployed, listing.captures) + except Exception as e: + logger.warning("collect_traffic_capture %s: %s", deployed.env_id, e) + entries[deployed.env_id] = _failed(e) + return entries[deployed.env_id] + + +async def _store(context: TaskStepContext, deployed: DeployedEnv, captures: list[TrafficCapture]) -> dict: + universe_id = derive_id(context.instance_id or "run", f"traffic-{deployed.env_id}") + if is_local_id(universe_id): + validate_local_id(universe_id) + get_config().check_local_run_write(universe_id) + store, version = get_artifact_store(), int(time.time()) + files, total, seen = {}, 0, {} + for capture in captures: + container = capture.container + seen[container] = seen.get(container, -1) + 1 + filename = f"{container}-{seen[container]:02d}.pcap" + handle, path = tempfile.mkstemp(suffix=".pcap") + os.close(handle) + try: + total += await fetch_traffic_capture(deployed.environment_url, deployed.environment_card, capture.name, + path, headers=deployed.control_headers) + url = await asyncio.to_thread(store.put_object_file, _ARTIFACT_TYPE, universe_id, version, filename, path, + PCAP_MEDIA_TYPE) + finally: + os.unlink(path) + files[filename] = store.put_document(FileArtifact( + id=derive_id(universe_id, filename), filename=filename, content_type=PCAP_MEDIA_TYPE, object_url=url, + description=f"Traffic capture of {container} in env {deployed.env_id!r}, task instance {context.instance_id}")) + artifact = None + if files: + universe = FileArtifactUniverse.put(id=universe_id, file_artifacts=files) + artifact = {"id": universe.id, "version": universe.version} + logger.info("collect_traffic_capture %s: %d capture files, %d bytes", deployed.env_id, len(files), total) + return {"state": "collected", "artifact": artifact, "files": sorted(files), "bytes": total, + "containers": sorted(seen)} + + +def _failed(error: BaseException) -> dict: + return {"state": "failed", "error": f"{type(error).__name__}: {error}"[:500]} diff --git a/tst/unit/env/envs/test_multi_env_name.py b/tst/unit/env/envs/test_multi_env_name.py index 70a9ea224..7cbc360fb 100644 --- a/tst/unit/env/envs/test_multi_env_name.py +++ b/tst/unit/env/envs/test_multi_env_name.py @@ -67,7 +67,8 @@ async def test_a_deploy_records_the_mcp_url_and_name_its_gateway_card_gives(): card = {"name": "crm", "additionalInterfaces": [{"url": "/mcp", "transport": "mcp"}]} result = SimpleNamespace(gateway_url="https://gw.example", mcp_url="https://gw.example/mcp", db_web_url=None, db_mcp_url=None, website_frontend_urls=None, environment_card=card, environment_card_read_at_utc="t", - env_state_instance_ids=[], mcp_server_name="crm", role_key=None) + env_state_instance_ids=[], mcp_server_name="crm", role_key=None, + control_token=None) async def deploy_gateway(self, sandbox_provider, **kwargs): self._sandbox = SimpleNamespace(sandbox_id="gw", type="modal_vm") diff --git a/tst/unit/env/gateway/traffic_test.py b/tst/unit/env/gateway/traffic_test.py new file mode 100644 index 000000000..a5a410133 --- /dev/null +++ b/tst/unit/env/gateway/traffic_test.py @@ -0,0 +1,200 @@ +"""traffic/v1 on the gateway: a deploy that captures its websites' traffic mounts their pcap files into the gateway, +which lists them oldest first and streams each, only to a caller carrying its control token, which it takes off every +request before anything is proxied, so no site ever reads it; a site's route never reaches the captures. A gateway +without a capture folder serves and advertises none of it.""" + +from __future__ import annotations + +import os + +import httpx +import pytest +from agentenv_protocol import traffic as protocol_traffic + +from agent_env.env.gateway import constants as gw_constants +from agent_env.env.gateway import gateway as gw_mod +from agent_env.env.gateway import rest_proxy as rest_proxy_module +from agent_env.env.gateway.constants import EXT_TRAFFIC_URI, GATEWAY_EXTENSIONS, PCAP_MEDIA_TYPE, WELL_KNOWN_PATH +from agent_env.env.gateway.gateway import Gateway + +TOKEN = "control-secret" +_RealAsyncClient = httpx.AsyncClient +_PCAP_HEADER = bytes.fromhex("d4c3b2a1020004000000000000000000ffff000001000000") + + +@pytest.fixture +def traffic_dir(tmp_path, monkeypatch): + monkeypatch.setattr(gw_mod, "GATEWAY_TRAJECTORY_FILE", str(tmp_path / "trajectory.jsonl")) + folder = tmp_path / "traffic" + folder.mkdir() + return folder + + +def _gateway(traffic_dir=None, control_token=TOKEN, **kw) -> Gateway: + return Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], + traffic_dir=str(traffic_dir) if traffic_dir else None, control_token=control_token, **kw) + + +def _write(folder, name: str, body: bytes, mtime: int) -> None: + (folder / name).write_bytes(body) + os.utime(folder / name, (mtime, mtime)) + + +async def _get(gw: Gateway, path: str, headers=None, params=None) -> httpx.Response: + transport = httpx.ASGITransport(app=gw._asgi_app()) + async with _RealAsyncClient(transport=transport, base_url="http://gateway") as client: + return await client.get(path, headers=headers, params=params) + + +def _bearer(token: str = TOKEN) -> dict: + return {"Authorization": f"Bearer {token}"} + + +@pytest.mark.asyncio +async def test_list_names_each_capture_by_its_container_oldest_first_and_get_streams_it(traffic_dir): + _write(traffic_dir, "notes-website-backend.pcap1", _PCAP_HEADER + b"second", 2000) + _write(traffic_dir, "notes-website-backend.pcap0", _PCAP_HEADER + b"first", 1000) + _write(traffic_dir, "notes-website-frontend.pcap0", _PCAP_HEADER, 1500) + _write(traffic_dir, "stray.txt", b"not a capture", 1000) + gw = _gateway(traffic_dir) + + listing = (await _get(gw, "/traffic/captures", headers=_bearer())).json() + got = await _get(gw, "/traffic/capture", headers=_bearer(), params={"name": "notes-website-backend.pcap0"}) + + assert listing["format"] == "pcap" + assert [(c["name"], c["container"], c["bytes"]) for c in listing["captures"]] == [ + ("notes-website-backend.pcap0", "notes-website-backend", len(_PCAP_HEADER) + 5), + ("notes-website-backend.pcap1", "notes-website-backend", len(_PCAP_HEADER) + 6), + ("notes-website-frontend.pcap0", "notes-website-frontend", len(_PCAP_HEADER)), + ] + assert listing["captures"][0]["modified_at"].startswith("1970-01-01T00:16:40") + assert (got.status_code, got.headers["content-type"], got.content) == (200, PCAP_MEDIA_TYPE, _PCAP_HEADER + b"first") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name, status", [ + ("../trajectory.jsonl", 400), ("notes-website-backend.pcap0/../../x", 400), ("stray.txt", 400), + (".pcap0", 400), ("", 400), ("gone-website-backend.pcap0", 404), +]) +async def test_get_serves_only_a_capture_file_of_the_folder(traffic_dir, name, status): + (traffic_dir / "stray.txt").write_bytes(b"x") + response = await _get(_gateway(traffic_dir), "/traffic/capture", headers=_bearer(), params={"name": name}) + assert response.status_code == status, response.text + + +@pytest.mark.asyncio +async def test_an_empty_or_missing_folder_lists_no_captures(traffic_dir, tmp_path): + assert (await _get(_gateway(traffic_dir), "/traffic/captures", headers=_bearer())).json()["captures"] == [] + missing = _gateway(tmp_path / "never-mounted") + assert (await _get(missing, "/traffic/captures", headers=_bearer())).json()["captures"] == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("headers", [None, _bearer("guess"), {"Authorization": TOKEN}, {"AgentEnv-Role-Key": TOKEN}], + ids=["none", "wrong", "not-bearer", "role-key-header"]) +async def test_each_traffic_route_refuses_a_caller_without_the_control_token(traffic_dir, headers): + _write(traffic_dir, "notes-website-backend.pcap0", _PCAP_HEADER, 1000) + gw = _gateway(traffic_dir) + listed = await _get(gw, "/traffic/captures", headers=headers) + got = await _get(gw, "/traffic/capture", headers=headers, params={"name": "notes-website-backend.pcap0"}) + assert (listed.status_code, got.status_code) == (401, 401) + assert listed.json()["error"]["code"] == "unauthorized" and _PCAP_HEADER not in got.content + + +@pytest.mark.asyncio +async def test_the_inner_app_alone_refuses_the_traffic_routes_of_a_gateway_with_a_token(traffic_dir): + """Only the served app takes the token off a request and vouches for it, so the routes refuse anything else.""" + transport = httpx.ASGITransport(app=_gateway(traffic_dir)._mcp.streamable_http_app()) + async with _RealAsyncClient(transport=transport, base_url="http://gateway") as client: + assert (await client.get("/traffic/captures", headers=_bearer())).status_code == 401 + + +@pytest.mark.asyncio +async def test_a_gateway_with_no_token_serves_its_captures_to_anyone(traffic_dir): + assert (await _get(_gateway(traffic_dir, control_token=None), "/traffic/captures")).status_code == 200 + + +@pytest.mark.asyncio +async def test_the_card_advertises_traffic_only_with_a_capture_folder(traffic_dir): + capturing = (await _get(_gateway(traffic_dir), "/.well-known/agent-env.json")).json() + plain = _gateway(None, control_token=None) + card = (await _get(plain, "/.well-known/agent-env.json")).json() + + [traffic] = [e for e in capturing["capabilities"]["extensions"] if e["uri"] == EXT_TRAFFIC_URI] + assert {name: (op["method"], op["endpoint"]) for name, op in traffic["params"]["methods"].items()} == { + "list": ("GET", "/traffic/captures"), "get": ("GET", "/traffic/capture")} + assert plain._gateway_extensions() is GATEWAY_EXTENSIONS + assert EXT_TRAFFIC_URI not in [e["uri"] for e in card["capabilities"]["extensions"]] + assert (await _get(plain, "/traffic/captures")).status_code == 404 + + +@pytest.mark.asyncio +async def test_the_control_token_never_reaches_a_site_and_a_sites_route_never_reaches_the_captures(traffic_dir, + monkeypatch): + _write(traffic_dir, "notes-website-backend.pcap0", _PCAP_HEADER, 1000) + seen: list[httpx.Request] = [] + + async def body(): + yield b"the site's own page" + + def site(request: httpx.Request) -> httpx.Response: + if request.url.path == WELL_KNOWN_PATH: + return httpx.Response(404) + seen.append(request) + 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(traffic_dir, website_urls={"notes": "http://notes-website-backend:8000"}, + rest_proxy_urls={"notes": "http://notes-website-backend:8000"}) + + for path in ("/website/notes/traffic/captures", "/website/notes/traffic/capture", "/svc/notes/api/x"): + response = await _get(gw, path, headers={**_bearer(), "Authorization-Extra": "kept"}, + params={"name": "notes-website-backend.pcap0"}) + assert (response.status_code, response.content) == (200, b"the site's own page"), path + assert [request.url.path for request in seen] == ["/traffic/captures", "/traffic/capture", "/api/x"] + assert all("authorization" not in request.headers and request.headers["authorization-extra"] == "kept" + for request in seen) + + +@pytest.mark.asyncio +async def test_a_header_that_isnt_the_token_still_reaches_the_site(traffic_dir, monkeypatch): + seen: list[httpx.Request] = [] + + async def body(): + yield b"{}" + + def site(request: httpx.Request) -> httpx.Response: + if request.url.path == WELL_KNOWN_PATH: + return httpx.Response(404) + seen.append(request) + 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(traffic_dir, website_urls={"notes": "http://notes"}) + for authorization in ("Bearer user-token", f"Bearer {TOKEN}"): + await _get(gw, "/website/notes/api", headers={"Authorization": authorization}) + assert [request.headers.get("authorization") for request in seen] == ["Bearer user-token", None] + + +@pytest.mark.asyncio +async def test_a_link_in_the_folder_is_neither_listed_nor_served(traffic_dir, tmp_path): + secret = tmp_path / "secret" + secret.write_bytes(b"not traffic") + (traffic_dir / "notes-website-backend.pcap0").symlink_to(secret) + (traffic_dir / "notes-website-backend.pcap1").mkdir() + gw = _gateway(traffic_dir) + assert (await _get(gw, "/traffic/captures", headers=_bearer())).json()["captures"] == [] + for name in ("notes-website-backend.pcap0", "notes-website-backend.pcap1"): + response = await _get(gw, "/traffic/capture", headers=_bearer(), params={"name": name}) + assert response.status_code == 404 and b"not traffic" not in response.content + + +def test_the_gateways_traffic_constants_mirror_the_protocols(): + """The gateway's container can't import the protocol, so it keeps its own copies of traffic/v1's names.""" + assert (gw_constants.EXT_TRAFFIC_URI, gw_constants.TRAFFIC_LIST_OP, gw_constants.TRAFFIC_GET_OP, + gw_constants.PCAP_MEDIA_TYPE, gw_constants.TRAFFIC_EXTENSION["params"]["format"]) == ( + protocol_traffic.TRAFFIC_EXTENSION_URI, protocol_traffic.TRAFFIC_LIST_OP, protocol_traffic.TRAFFIC_GET_OP, + protocol_traffic.PCAP_MEDIA_TYPE, protocol_traffic.TRAFFIC_FORMAT) + assert protocol_traffic.serves_traffic({"capabilities": {"extensions": [gw_constants.TRAFFIC_EXTENSION]}}) diff --git a/tst/unit/env/gateway/website_websocket_test.py b/tst/unit/env/gateway/website_websocket_test.py index 76ecac6b4..20c3df49c 100644 --- a/tst/unit/env/gateway/website_websocket_test.py +++ b/tst/unit/env/gateway/website_websocket_test.py @@ -295,3 +295,23 @@ async def test_a_site_that_refuses_the_handshake_has_its_answer_relayed(): await site_server.shutdown() assert refused.value.response.status_code == 403 and unknown.value.response.status_code == 404 + + +@pytest.mark.asyncio +async def test_a_sites_own_authorization_reaches_it_and_the_control_token_never_does(): + site = EchoSite() + site_server, site_port = await serve(site.app) + gateway = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], control_token="control-secret", + website_urls={"notes": f"http://127.0.0.1:{site_port}"}) + gateway_server, gateway_port = await serve(gateway._asgi_app()) + route = f"ws://127.0.0.1:{gateway_port}/website/notes" + try: + for authorization in ("Bearer user-token", "Bearer control-secret"): + async with connect(f"{route}/live", additional_headers=[("Authorization", authorization)]) as websocket: + await websocket.send("ping") + assert await asyncio.wait_for(websocket.recv(), 5) == "PING" + finally: + await gateway_server.shutdown() + await site_server.shutdown() + + assert [headers.get("authorization") for _, headers in site.handshakes] == ["Bearer user-token", None] diff --git a/tst/unit/providers/env_providers/traffic_capture_test.py b/tst/unit/providers/env_providers/traffic_capture_test.py new file mode 100644 index 000000000..ecbc2f44a --- /dev/null +++ b/tst/unit/providers/env_providers/traffic_capture_test.py @@ -0,0 +1,199 @@ +"""A gateway deploy with ``capture_traffic``: its compose runs tcpdump beside each website container, in that +container's network namespace and holding the only capture capabilities, rotating within a size cap onto a volume the +gateway alone mounts, read-only; the deploy draws the control token the gateway's traffic routes take and keeps it on +the record; and a deploy that can't capture (no website, containers, a capture that isn't running) is refused. Off, +the compose is the one it always was.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import yaml + +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.env.envs.service_db import ServiceDBConfig +from agent_env.env.envs.website import WebsiteEnv +from agent_env.providers.env_providers import env_gateway_provider +from agent_env.providers.env_providers.constants import GATEWAY_SERVICE_NAME +from agent_env.providers.env_providers.env_gateway_provider import ( + TRAFFIC_CAPTURE_FILE_MB, + TRAFFIC_CAPTURE_FILES, + DeployedGateway, + EnvironmentGatewayProvider, + MCPServerConfig, + WebsiteConfig, + _redact_compose_secrets, + traffic_capture_services, +) +from agent_env.providers.env_state import LocalPostgresStateProvider +from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandboxProvider +from agent_env.providers.sandbox_providers.sandbox_provider import Accepts +from tst.util.sandbox_providers import VMS_AND_CONTAINERS + +_WEBSITES = [WebsiteConfig(backend_image="notes-be", frontend_image=None, environment_name="notes"), + WebsiteConfig(backend_image="mail-be", frontend_image="mail-fe", environment_name="mail")] + + +def _compose(**kw) -> str: + return EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=[MCPServerConfig(image="mcp-slack", environment_name="slack")], gateway_image="agent-gateway", + website_configs=_WEBSITES, state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance(), **kw) + + +def _vm_provider(**attrs) -> MagicMock: + provider = MagicMock(ON_THIS_MACHINE=False, SUPPORTED_SANDBOX_MODES=VMS_AND_CONTAINERS, + SANDBOX_ACCEPTS=Accepts.LOADABLE, **attrs) + provider.links = (provider,) + return provider + + +def test_each_website_container_gets_a_capture_in_its_own_network_namespace(): + services = yaml.safe_load(_compose(capture_traffic=True, control_token="ctl"))["services"] + + captures = {name: service for name, service in services.items() if name.endswith("-traffic")} + assert traffic_capture_services(_WEBSITES) == { + "notes-website-backend-traffic": "notes-website-backend", "mail-website-backend-traffic": "mail-website-backend", + "mail-website-frontend-traffic": "mail-website-frontend"} + assert set(captures) == set(traffic_capture_services(_WEBSITES)) + for name, container in traffic_capture_services(_WEBSITES).items(): + capture = captures[name] + assert capture["image"] == "agent-gateway" + assert capture["network_mode"] == f"service:{container}" and "networks" not in capture and "ports" not in capture + assert capture["depends_on"] == [container] + assert capture["cap_add"] == ["NET_RAW", "NET_ADMIN"] + assert capture["entrypoint"] == ["tcpdump"] + assert capture["command"] == ["-i", "eth0", "-n", "-s", "0", "-U", "-Z", "root", "-C", str(TRAFFIC_CAPTURE_FILE_MB), + "-W", str(TRAFFIC_CAPTURE_FILES), "-w", f"/captures/{container}.pcap"] + assert capture["volumes"] == ["traffic-captures:/captures"] + assert TRAFFIC_CAPTURE_FILE_MB * TRAFFIC_CAPTURE_FILES <= 500 # a long run can't fill the VM's disk + + +def test_only_the_captures_hold_capture_capabilities_and_only_the_gateway_reads_their_volume(): + compose = yaml.safe_load(_compose(capture_traffic=True, control_token="ctl")) + services = compose["services"] + + assert [name for name, s in services.items() if s.get("cap_add")] == sorted( + traffic_capture_services(_WEBSITES), key=list(services).index) + mounting = {name: s["volumes"] for name, s in services.items() + if any(str(v).startswith("traffic-captures:") for v in s.get("volumes") or [])} + assert mounting.pop(GATEWAY_SERVICE_NAME) == ["traffic-captures:/var/lib/agentenv/traffic:ro"] + assert set(mounting) == set(traffic_capture_services(_WEBSITES)) + assert compose["volumes"] == {"traffic-captures": {}} + gateway = services[GATEWAY_SERVICE_NAME] + assert {"AGENTENV_CONTROL_TOKEN=ctl", "TRAFFIC_CAPTURE_DIR=/var/lib/agentenv/traffic"} <= set(gateway["environment"]) + assert {name: gateway["depends_on"][name] for name in traffic_capture_services(_WEBSITES)} == dict.fromkeys( + traffic_capture_services(_WEBSITES), {"condition": "service_started"}) + + +def test_the_logged_compose_masks_the_control_token(): + compose = _compose(capture_traffic=True, control_token="ctl-secret-value") + assert "ctl-secret-value" in compose + assert "ctl-secret-value" not in _redact_compose_secrets(compose) + assert " - AGENTENV_CONTROL_TOKEN=***" in _redact_compose_secrets(compose) + + +def test_without_capture_traffic_the_compose_is_unchanged(): + plain = _compose() + assert plain == _compose(capture_traffic=False, control_token=None) + assert "traffic" not in plain and "tcpdump" not in plain and "cap_add" not in plain and "AGENTENV_CONTROL_TOKEN" not in plain + assert yaml.safe_load(_compose(capture_traffic=True, control_token="t"))["services"].keys() - yaml.safe_load( + plain)["services"].keys() == set(traffic_capture_services(_WEBSITES)) + + +def test_a_name_a_capture_would_take_is_refused(): + with pytest.raises(ValueError, match="'notes-website-backend-traffic'"): + EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=[MCPServerConfig(image="mcp", environment_name="notes-website-backend-traffic")], + website_configs=_WEBSITES, state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance(), capture_traffic=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("capture_traffic", [True, False], ids=["capturing", "plain"]) +async def test_create_gateway_draws_a_control_token_only_for_a_capturing_deploy(capture_traffic): + gp = EnvironmentGatewayProvider() + with patch.object(gp, "_deploy_via_vm", AsyncMock(return_value=DeployedGateway( + gateway_url="https://gw", mcp_url="https://gw/mcp", db_web_url=None))) as vm, \ + patch.object(env_gateway_provider, "_tool_names", AsyncMock(return_value=set())): + await gp.create_gateway(sandbox_provider=_vm_provider(), mcp_servers=[], mcp_server_images=[], + website_configs=_WEBSITES, env_id="notes", capture_traffic=capture_traffic) + + kwargs = vm.await_args.kwargs + if capture_traffic: + assert kwargs["capture_traffic"] is True and isinstance(kwargs["control_token"], str) + assert len(kwargs["control_token"]) >= 32 + else: + assert "capture_traffic" not in kwargs and "control_token" not in kwargs + + +@pytest.mark.asyncio +async def test_a_capturing_deploy_keeps_its_control_token_on_the_record(): + gp = EnvironmentGatewayProvider() + env = MagicMock(spec=WebsiteEnv, id="notes-env", version=1, environment_name="notes", health_path="/api/health", + settings={}, frontend_docker_image_artifact=None, + server_docker_image_artifact=DockerImageArtifact(id="be", description="d", image_name="notes-be")) + gp._sandbox = MagicMock(sandbox_id="vm-1", type="modal_vm") + launched = DeployedGateway(gateway_url="https://gw", mcp_url="https://gw/mcp", db_web_url=None, + environment_card={"name": "env1"}, control_token="drawn") + with patch("agent_env.providers.env_state.acquire_state_for_deploy", AsyncMock(return_value=MagicMock( + state_type="remote", instance_id="st"))), \ + patch.object(gp, "_launch", AsyncMock(return_value=launched)) as launch: + record = await gp.deploy(env, _vm_provider(), capture_traffic=True) + + assert launch.await_args.kwargs["capture_traffic"] is True + assert record.control_token == "drawn" and record.control_headers == {"Authorization": "Bearer drawn"} + + +@pytest.mark.asyncio +async def test_a_deploy_that_serves_no_website_is_refused_capture_before_anything_starts(): + gp = EnvironmentGatewayProvider() + env = MagicMock(id="slack-env", environment_name="slack", docker_image_artifact=MagicMock(image_name="mcp-slack")) + acquire = AsyncMock() + with patch("agent_env.providers.env_state.acquire_state_for_deploy", acquire), \ + pytest.raises(ValueError, match="serves no website"): + await gp.deploy(env, _vm_provider(), capture_traffic=True) + acquire.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_a_container_mode_deploy_is_refused_capture(): + gp = EnvironmentGatewayProvider() + with pytest.raises(ValueError, match="Can't capture traffic on ModalSandboxProvider"): + await gp._deploy_gateway(ModalSandboxProvider(), state_instance=None, mcp_servers=[], mcp_server_images=[], + website_configs=_WEBSITES, capture_traffic=True, control_token="t") + + +def _ps(*rows: dict) -> str: + return "\n".join(json.dumps(row) for row in rows) + + +@pytest.mark.asyncio +async def test_a_capture_that_isnt_running_refuses_the_deploy_with_its_output(): + gp, sandbox = EnvironmentGatewayProvider(), MagicMock() + rows = _ps({"Service": "notes-website-backend-traffic", "State": "running", "ID": "a"}, + {"Service": "mail-website-backend-traffic", "State": "restarting", "ExitCode": 127, "ID": "b"}) + + async def exec_with_output(*argv): + if "ps" in argv: + return 0, rows, "" + return 0, 'exec: "tcpdump": executable file not found in $PATH', "" + + sandbox.exec_with_output = exec_with_output + with pytest.raises(RuntimeError) as raised: + await gp._refuse_stopped_captures(sandbox, ["notes-website-backend-traffic", "mail-website-backend-traffic", + "mail-website-frontend-traffic"]) + message = str(raised.value) + assert "mail-website-backend-traffic keeps restarting (last exit 127)" in message and "tcpdump" in message + assert "mail-website-frontend-traffic has no container" in message + assert "notes-website-backend-traffic" not in message + + +@pytest.mark.asyncio +async def test_running_captures_let_the_deploy_through(): + gp, sandbox = EnvironmentGatewayProvider(), MagicMock() + sandbox.exec_with_output = AsyncMock(return_value=(0, _ps( + {"Service": "notes-website-backend-traffic", "State": "running"}), "")) + await gp._refuse_stopped_captures(sandbox, ["notes-website-backend-traffic"]) diff --git a/tst/unit/task_step/deploy_env_capture_traffic_test.py b/tst/unit/task_step/deploy_env_capture_traffic_test.py new file mode 100644 index 000000000..46e40be83 --- /dev/null +++ b/tst/unit/task_step/deploy_env_capture_traffic_test.py @@ -0,0 +1,123 @@ +"""``capture_traffic``, off by default, from where a deploy is asked for to the provider: the deploy_env step and +``env deploy --capture-traffic`` pass it only when set, a website or multi env hands it to its provider, and a deploy +that can't capture is refused before anything is built: an env that serves no website, an env type whose deploy +doesn't take it, or a provider that doesn't.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from click.testing import CliRunner + +from agent_env.cli.env import env as env_cli +from agent_env.env.env import DeployedGatewayEnv +from agent_env.env.envs.multi_env import MultiEnv +from agent_env.env.envs.website import WebsiteEnv +from agent_env.providers.env_providers.env_provider import EnvironmentProvider +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep +from tst.unit.task_step.deploy_env_test import _deployed_env, _env_with_fixed_deploy, _fake_env, _mcp_env + + +def _website(env_provider_type: str = "gateway") -> WebsiteEnv: + return WebsiteEnv(id="notes-env", version=1, server_docker_image_artifact=MagicMock(), environment_name="notes", + env_provider_type=env_provider_type) + + +def _preflight(env, **options) -> list[str]: + with patch("agent_env.env.env.Env.get", return_value=env): + return DeployEnvTaskStep(id="d", version=None, env_id=env.id, **options).preflight() + + +@pytest.mark.parametrize("capture_traffic", [True, False], ids=["capturing", "plain"]) +def test_capture_traffic_roundtrips_and_a_step_without_it_serializes_as_before(capture_traffic): + step = DeployEnvTaskStep(id="t.deploy_env", version=None, env_id="notes-env", capture_traffic=capture_traffic) + assert DeployEnvTaskStep.from_dict(step.to_dict()).capture_traffic is capture_traffic + assert ("capture_traffic" in step.to_dict()) is capture_traffic + assert DeployEnvTaskStep.from_dict({**step.to_dict(), "capture_traffic": None}).capture_traffic is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("capture_traffic", [True, False], ids=["capturing", "plain"]) +async def test_execute_asks_for_a_capture_only_for_a_capturing_step(capture_traffic): + env = _fake_env(_deployed_env(None)) + step = DeployEnvTaskStep(id="t.deploy_env", version=None, env_id="art-1", capture_traffic=capture_traffic) + with patch("agent_env.env.env.Env") as Env: + Env.get.return_value = env + await step.execute(TaskStepContext()) + assert env.deploy.await_args.kwargs.get("capture_traffic") is (True if capture_traffic else None) + + +@pytest.mark.asyncio +async def test_execute_refuses_a_capture_for_an_env_whose_deploy_does_not_take_one(): + env = _env_with_fixed_deploy(_deployed_env(None)) + env.type = "fixed" + step = DeployEnvTaskStep(id="d", version=None, env_id="art-1", capture_traffic=True) + with patch("agent_env.env.env.Env") as Env, pytest.raises(ValueError, match="fixed envs take no capture_traffic"): + Env.get.return_value = env + await step.execute(TaskStepContext()) + + +def test_preflight_passes_a_capture_of_a_website_env_behind_a_gateway(): + assert _preflight(_website(), capture_traffic=True) == [] + + +def test_preflight_refuses_a_capture_of_an_env_that_serves_no_website(): + assert _preflight(_mcp_env("gateway"), capture_traffic=True) == [ + "deploy_env 'd': env 'mcp-email' serves no website, and capture_traffic records what a website's containers " + "send and receive"] + multi = MultiEnv(id="crm", version=1, mcp_server_envs=[_mcp_env("gateway")]) + assert "env 'crm' serves no website" in _preflight(multi, capture_traffic=True)[0] + + +class _NamedOptionsProvider(EnvironmentProvider): + """A plugin's provider whose deploy names its options, and no capture_traffic among them.""" + + type = "named-options" + + async def deploy(self, env, sandbox_provider, *, ttl_seconds: int = 10800, attribution=None): + raise AssertionError("refused before deploying") + + async def close(self) -> None: + pass + + +def test_preflight_refuses_a_capture_a_plugins_provider_does_not_take(): + with patch("agent_env.env.envs.website.provider_or_class", return_value=_NamedOptionsProvider): + [refusal] = _preflight(_website("named-options"), capture_traffic=True) + assert refusal.endswith("which doesn't take capture_traffic; name it in the provider's deploy() or take **options") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("env", [_website(), MultiEnv(id="crm", version=1, mcp_server_envs=[], website_envs=[_website()])], + ids=["website", "multi"]) +@pytest.mark.parametrize("capture_traffic", [True, False], ids=["capturing", "plain"]) +async def test_a_website_or_multi_env_hands_a_capture_to_its_provider_only_when_asked(env, capture_traffic): + module = "website" if isinstance(env, WebsiteEnv) else "multi_env" + record = DeployedGatewayEnv(env_id=env.id, env_version=1, gateway_url="https://gw", sandbox_id="vm") + with patch(f"agent_env.env.envs.{module}.deploy_through_provider", AsyncMock(return_value=record)) as through: + await env.deploy(capture_traffic=capture_traffic) + assert through.await_args.kwargs.get("capture_traffic") is (True if capture_traffic else None) + + +def _cli_deploy_kwargs(args: list[str]) -> dict: + captured: dict = {} + + async def fake_deploy(**kwargs): + captured.update(kwargs) + return DeployedGatewayEnv(env_id="e1", env_version=1, gateway_url="http://gw", mcp_url="http://mcp", + db_web_url=None, sandbox_id="s1") + + fake_env = MagicMock(id="e1", version=1, type="website") + fake_env.deploy = fake_deploy + with patch("agent_env.cli.env.deploy.Env") as Env, patch("agent_env.providers.build_sandbox_provider"): + Env.get.return_value = fake_env + result = CliRunner().invoke(env_cli, ["deploy", "--id", "e1", *args]) + assert result.exit_code == 0, result.output + return captured + + +def test_env_deploy_captures_traffic_only_with_the_flag(): + assert _cli_deploy_kwargs(["--capture-traffic"])["capture_traffic"] is True + assert "capture_traffic" not in _cli_deploy_kwargs([]) diff --git a/tst/unit/task_step/traffic_capture_test.py b/tst/unit/task_step/traffic_capture_test.py new file mode 100644 index 000000000..576010234 --- /dev/null +++ b/tst/unit/task_step/traffic_capture_test.py @@ -0,0 +1,158 @@ +"""collect_traffic_capture against a real gateway serving traffic/v1 from a capture folder: each capture file stored +as a pcap FileArtifact, named for its container and its place among that container's files, in one universe the run +records; what's recorded when the env doesn't capture, isn't deployed or refuses the token; and teardown collecting a +capturing env the run didn't, before the env closes.""" + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from agent_env.artifact import Artifact +from agent_env.artifact.store import get_artifact_store +from agent_env.env.env import DeployedGatewayEnv +from agent_env.env.gateway import gateway as gw_mod +from agent_env.env.gateway.constants import PCAP_MEDIA_TYPE, TRAFFIC_EXTENSION +from agent_env.env.gateway.gateway import Gateway +from agent_env.task.teardown import recorded_sandboxes, tear_down +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.registry import get_task_step_registry +from agent_env.task_step.task_steps.traffic_capture import ( + TRAFFIC_KEY, + CollectTrafficCaptureTaskStep, + collect_uncollected_captures, +) + +TOKEN = "control-secret" +_RealAsyncClient = httpx.AsyncClient +_PCAP = bytes.fromhex("d4c3b2a1020004000000000000000000ffff000001000000") + + +@pytest.fixture +def gateway(tmp_path, monkeypatch, local_stores): + """A capturing gateway every httpx client in the test reaches, its folder holding two rotated files of the + server's capture, the newer named first, and one of its frontend's.""" + monkeypatch.setattr(gw_mod, "GATEWAY_TRAJECTORY_FILE", str(tmp_path / "trajectory.jsonl")) + folder = tmp_path / "traffic" + folder.mkdir() + for name, body, mtime in (("notes-website-backend.pcap0", b"newer", 2000), ("notes-website-backend.pcap1", b"older", 1000), + ("notes-website-frontend.pcap0", b"front", 1500)): + (folder / name).write_bytes(_PCAP + body) + os.utime(folder / name, (mtime, mtime)) + gw = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=[], traffic_dir=str(folder), + control_token=TOKEN) + app = gw._asgi_app() + monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: _RealAsyncClient( + transport=httpx.ASGITransport(app=app), **{k: v for k, v in kw.items() if k != "verify"})) + yield gw + gw._close_trajectory_file() + + +def _deployed(env_id: str = "notes-env", *, token: str | None = TOKEN, capturing: bool = True) -> DeployedGatewayEnv: + card = {"name": "env1", "capabilities": {"extensions": [TRAFFIC_EXTENSION] if capturing else []}} + return DeployedGatewayEnv(env_id=env_id, env_version=1, gateway_url="http://gateway", sandbox_id="vm-1", + environment_card_url="http://gateway/.well-known/agent-env.json", environment_card=card, + control_token=token) + + +def _context(*envs) -> TaskStepContext: + return TaskStepContext(instance_id="run-1", deployed_envs=list(envs), metadata={}) + + +def _step(**kw) -> CollectTrafficCaptureTaskStep: + return CollectTrafficCaptureTaskStep(id="collect", version=None, env_id="notes-env", **kw) + + +def test_the_step_is_registered_and_roundtrips(): + step = _step(fail_task_on_error=True) + assert get_task_step_registry()["collect_traffic_capture"] is CollectTrafficCaptureTaskStep + again = CollectTrafficCaptureTaskStep.from_dict(step.to_dict()) + assert (again.env_id, again.fail_task_on_error) == ("notes-env", True) + assert CollectTrafficCaptureTaskStep.from_dict({"id": "c", "env_id": "e"}).fail_task_on_error is False + with pytest.raises(ValueError, match="needs the env_id"): + CollectTrafficCaptureTaskStep(id="c", version=None, env_id="") + + +@pytest.mark.asyncio +async def test_each_capture_is_stored_as_a_pcap_in_one_universe_oldest_first(gateway): + context = await _step().execute(_context(_deployed())) + + entry = context.metadata[TRAFFIC_KEY]["notes-env"] + assert entry["state"] == "collected" + assert entry["files"] == ["notes-website-backend-00.pcap", "notes-website-backend-01.pcap", + "notes-website-frontend-00.pcap"] + assert entry["containers"] == ["notes-website-backend", "notes-website-frontend"] + assert entry["bytes"] == 3 * len(_PCAP) + 15 + universe = Artifact.get(entry["artifact"]["id"], version=entry["artifact"]["version"]) + files = universe.get_file_artifacts() + assert sorted(files) == entry["files"] + first = files["notes-website-backend-00.pcap"] + assert first.content_type == PCAP_MEDIA_TYPE + assert get_artifact_store().get_object(first.object_url) == _PCAP + b"older" + assert get_artifact_store().get_object(files["notes-website-backend-01.pcap"].object_url) == _PCAP + b"newer" + + +@pytest.mark.asyncio +async def test_an_env_that_doesnt_capture_is_recorded_as_failed_and_fails_the_task_only_when_asked(gateway): + context = await _step().execute(_context(_deployed(capturing=False))) + assert context.metadata[TRAFFIC_KEY]["notes-env"]["state"] == "failed" + assert "traffic/v1" in context.metadata[TRAFFIC_KEY]["notes-env"]["error"] + with pytest.raises(RuntimeError, match="weren't collected"): + await _step(fail_task_on_error=True).execute(_context(_deployed(capturing=False))) + + +@pytest.mark.asyncio +async def test_a_record_without_the_gateways_token_is_refused(gateway): + context = await _step().execute(_context(_deployed(token="stale"))) + entry = context.metadata[TRAFFIC_KEY]["notes-env"] + assert entry["state"] == "failed" and "401" in entry["error"] + + +@pytest.mark.asyncio +async def test_an_env_the_run_didnt_deploy_is_recorded(gateway): + context = await _step().execute(_context()) + assert context.metadata[TRAFFIC_KEY]["notes-env"] == { + "state": "failed", "error": "RuntimeError: env 'notes-env' isn't deployed in this run"} + with pytest.raises(RuntimeError, match="isn't deployed"): + await _step(fail_task_on_error=True).execute(_context()) + + +@pytest.mark.asyncio +async def test_teardown_collects_only_capturing_envs_the_run_didnt_collect(gateway): + capturing, plain, done = _deployed(), _deployed("plain-env", capturing=False), _deployed("done-env") + context = _context(capturing, plain, done) + context.metadata[TRAFFIC_KEY] = {"done-env": {"state": "collected", "artifact": None}} + + await collect_uncollected_captures(context, context.deployed_envs) + + assert context.metadata[TRAFFIC_KEY]["notes-env"]["state"] == "collected" + assert "plain-env" not in context.metadata[TRAFFIC_KEY] + assert context.metadata[TRAFFIC_KEY]["done-env"] == {"state": "collected", "artifact": None} + + +@pytest.mark.asyncio +async def test_tear_down_collects_a_capture_before_its_env_closes(gateway): + context = _context(_deployed()) + collected_at_close: list[bool] = [] + + async def close(env, timeout): + collected_at_close.append(context.metadata[TRAFFIC_KEY]["notes-env"]["state"] == "collected") + + with patch("agent_env.task.teardown._close", close), \ + patch("agent_env.task.teardown.terminate", AsyncMock(return_value=False)): + report = await tear_down(context, recorded_sandboxes(context), context.deployed_envs, timeout=30) + + assert collected_at_close == [True] and [s.sandbox_id for s in report.terminated] == ["vm-1"] + + +@pytest.mark.asyncio +async def test_tear_down_leaves_an_env_already_torn_down_alone(gateway): + context = _context(_deployed()) + context.metadata["torn_down_sandbox_ids"] = ["vm-1"] + with patch("agent_env.task.teardown._close", AsyncMock()), \ + patch("agent_env.task.teardown.terminate", AsyncMock(return_value=False)): + await tear_down(context, recorded_sandboxes(context), context.deployed_envs, timeout=30) + assert TRAFFIC_KEY not in context.metadata From e197792dc6f949923cf7ff97351e1afab29ae756 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 15:02:38 -0800 Subject: [PATCH 51/57] fix(website): serve each capture as a whole copy, upload it under a prefix of its own, and capture loopback too (FD-4683) - The gateway's traffic/v1 get copies a capture before sending any of it, copying again when tcpdump truncates the file to reuse it, and answers 503 when every copy was rewritten. - collect_traffic_capture writes each collection under its own attempt prefix, and the upload owns its temp file (finish_on_thread), so a cancelled collection never removes a file an upload still reads. - tcpdump captures every interface of the container, its loopback among them. - The read size and error limits are named. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/env/gateway/gateway.py | 46 ++++++++++++-- .../env_providers/env_gateway_provider.py | 13 ++-- .../task_step/task_steps/traffic_capture.py | 37 ++++++++--- tst/unit/env/gateway/traffic_test.py | 62 +++++++++++++++++++ .../env_providers/traffic_capture_test.py | 2 +- tst/unit/task_step/traffic_capture_test.py | 43 +++++++++++++ 6 files changed, 182 insertions(+), 21 deletions(-) diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 0511c9398..427b6320e 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -12,6 +12,7 @@ import re import socket import subprocess +import tempfile from collections.abc import Iterable from contextlib import asynccontextmanager from datetime import datetime, timezone @@ -135,6 +136,8 @@ class Gateway: REST_PROXY_TIMEOUT_S = float(DATA_PLANE_LOAD_TIMEOUT_MAX_S) # data-plane loads REST_PROXY_CONNECT_TIMEOUT_S = 10.0 # a service that never answers the TCP connect fails in seconds, not an hour REST_PROXY_TIMEOUT = httpx.Timeout(REST_PROXY_TIMEOUT_S, connect=REST_PROXY_CONNECT_TIMEOUT_S) + CAPTURE_READ_BYTES = 64 * 1024 + CAPTURE_SNAPSHOT_ATTEMPTS = 3 def __init__( self, @@ -437,7 +440,8 @@ async def traffic_captures(request: Request) -> Response: @self._mcp.custom_route("/traffic/capture", methods=["GET"]) async def traffic_capture(request: Request) -> Response: - return self._refuse_uncontrolled(request) or self._serve_capture(request.query_params.get("name", "")) + return self._refuse_uncontrolled(request) or await asyncio.to_thread( + self._serve_capture, request.query_params.get("name", "")) @self._mcp.custom_route("/changes", methods=["GET"]) async def env_changes(request: Request) -> Response: @@ -1635,8 +1639,10 @@ def _serve_captures(self) -> Response: return self._json_response({"format": "pcap", "captures": captures}, 200) def _serve_capture(self, name: str) -> Response: - """traffic/v1's get: the capture file ``name``, one ``list`` names, streamed as it stands; tcpdump writes each - packet as it captures it, so a file still being written ends on a whole packet, or at worst one torn.""" + """traffic/v1's get: the capture file ``name``, one ``list`` names, as a copy taken whole before any of it is + sent (``_snapshot_capture``), so a file tcpdump's rotation rewrites while it downloads never arrives mixed or + cut short; 503 when every copy was rewritten under it. A file still being written ends on a whole packet, or at + worst one torn.""" if not _CAPTURE_FILE.fullmatch(name): return self._json_response({"ok": False, "error": {"code": "invalid_params", "message": f"{name!r} is not a capture's name, as list gives it"}}, 400) @@ -1648,14 +1654,42 @@ def _serve_capture(self, name: str) -> Response: except OSError: return self._json_response({"ok": False, "error": {"code": "not_found", "message": f"no capture named {name!r}"}}, 404) + with handle: + snapshot = self._snapshot_capture(handle) + if snapshot is None: + return self._json_response({"ok": False, "error": {"code": "unavailable", + "message": f"capture {name!r} kept being rewritten while it was copied; " + "ask again"}}, 503) + size = os.fstat(snapshot.fileno()).st_size def chunks(): - with handle: - while chunk := handle.read(65536): + with snapshot: + while chunk := snapshot.read(self.CAPTURE_READ_BYTES): yield chunk return StreamingResponse(chunks(), media_type=PCAP_MEDIA_TYPE, - headers={"Content-Disposition": f'attachment; filename="{name}"'}) + headers={"Content-Disposition": f'attachment; filename="{name}"', + "Content-Length": str(size)}) + + def _snapshot_capture(self, handle: Any) -> Any | None: + """A copy of the open capture ``handle`` in an unlinked temp file, read back from its start, or None when each + of ``CAPTURE_SNAPSHOT_ATTEMPTS`` copies found the file shorter than what it had copied: tcpdump truncated it to + reuse it, so the copy would mix two rounds of packets. The copy is gone once it's closed.""" + for _ in range(self.CAPTURE_SNAPSHOT_ATTEMPTS): + handle.seek(0) + copy = tempfile.TemporaryFile() + copied, rewritten = 0, False + while chunk := handle.read(self.CAPTURE_READ_BYTES): + copy.write(chunk) + copied += len(chunk) + if os.fstat(handle.fileno()).st_size < copied: + rewritten = True + break + if not rewritten and os.fstat(handle.fileno()).st_size >= copied: + copy.seek(0) + return copy + copy.close() + return None def _serve_trajectory(self) -> Response: """Stream the whole env trajectory as JSONL; only a mid-write tail line can arrive torn.""" 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 34f227c38..7a877706c 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -85,6 +85,7 @@ # How much of a service's output a failed deploy's error carries: enough for the end of a traceback. _LOG_TAIL_LINES = 30 _LOG_LINE_CHARS = 300 +_CAPTURE_CHECK_ERROR_CHARS = 500 # Gathering it may stall with the connection; past this, the deploy raises the error it had. _DOWN_SERVICES_TIMEOUT_S = 60.0 @@ -1276,7 +1277,7 @@ async def _refuse_stopped_captures(self, sandbox: VmSandbox, capture_services: l return if exit_code != 0: logger.warning(f"Can't check the traffic captures are running: docker compose ps exited {exit_code}: " - f"{stderr.strip()[-500:]}") + f"{stderr.strip()[-_CAPTURE_CHECK_ERROR_CHARS:]}") return rows = {row.get("Service"): row for row in _compose_ps_rows(stdout)} stopped = [] @@ -1663,14 +1664,14 @@ def _traffic_mount(path: str, *, read_only: bool = False) -> str: def _traffic_capture_service(gateway_image: str, container: str) -> list[str]: - """The compose lines of the service capturing ``container``'s traffic: tcpdump, from the gateway's image, on the - container's own interface, every packet whole and written as it's captured, rotating through - ``TRAFFIC_CAPTURE_FILES`` files of ``TRAFFIC_CAPTURE_FILE_MB`` million bytes named for the container, its oldest - overwritten once they're full, so a long run holds at most their product per container on the VM's disk. It joins + """The compose lines of the service capturing ``container``'s traffic: tcpdump, from the gateway's image, on every + interface of the container's, its loopback among them, every packet whole and written as it's captured, rotating + through ``TRAFFIC_CAPTURE_FILES`` files of ``TRAFFIC_CAPTURE_FILE_MB`` million bytes named for the container, its + oldest overwritten once they're full, so a long run holds at most their product per container on the VM's disk. It joins the container's network namespace, so it sees what the container sends and receives and nothing else, and is the only service given the capabilities a capture needs. It writes to ``TRAFFIC_VOLUME``, which the gateway mounts read-only and no website container mounts at all.""" - argv = ["-i", "eth0", "-n", "-s", "0", "-U", "-Z", "root", "-C", str(TRAFFIC_CAPTURE_FILE_MB), + argv = ["-i", "any", "-n", "-s", "0", "-U", "-Z", "root", "-C", str(TRAFFIC_CAPTURE_FILE_MB), "-W", str(TRAFFIC_CAPTURE_FILES), "-w", f"{TRAFFIC_SIDECAR_DIR}/{container}.pcap"] return [ f" image: {gateway_image}", diff --git a/src/agent_env/task_step/task_steps/traffic_capture.py b/src/agent_env/task_step/task_steps/traffic_capture.py index aa2e0b65d..661c6932b 100644 --- a/src/agent_env/task_step/task_steps/traffic_capture.py +++ b/src/agent_env/task_step/task_steps/traffic_capture.py @@ -5,11 +5,10 @@ didn't, before the env closes (``collect_uncollected_captures``), so a capture isn't lost with its VM.""" from __future__ import annotations -import asyncio +import functools import logging import os import tempfile -import time from collections.abc import Sequence from typing import ClassVar, Optional @@ -32,12 +31,14 @@ from agent_env.store.ids import derive_id, is_local_id, validate_local_id from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_step import TaskStep, TaskStepDependency +from agent_env.task_step.thread_work import finish_on_thread logger = logging.getLogger(__name__) #: ``context.metadata[TRAFFIC_KEY][env_id]``: each env whose captures a run collected, or tried to. TRAFFIC_KEY = "traffic_captures" _ARTIFACT_TYPE = "traffic_captures" +_ERROR_CHARS = 500 class CollectTrafficCaptureTaskStep(TaskStep): @@ -116,7 +117,8 @@ async def _store(context: TaskStepContext, deployed: DeployedEnv, captures: list if is_local_id(universe_id): validate_local_id(universe_id) get_config().check_local_run_write(universe_id) - store, version = get_artifact_store(), int(time.time()) + store = get_artifact_store() + prefix = store.attempt_prefix(_ARTIFACT_TYPE, universe_id) files, total, seen = {}, 0, {} for capture in captures: container = capture.container @@ -127,10 +129,12 @@ async def _store(context: TaskStepContext, deployed: DeployedEnv, captures: list try: total += await fetch_traffic_capture(deployed.environment_url, deployed.environment_card, capture.name, path, headers=deployed.control_headers) - url = await asyncio.to_thread(store.put_object_file, _ARTIFACT_TYPE, universe_id, version, filename, path, - PCAP_MEDIA_TYPE) - finally: - os.unlink(path) + except BaseException: + _remove(path) + raise + url = await finish_on_thread(functools.partial(_upload, universe_id, prefix + filename, path), + f"Uploading traffic capture {filename} of env {deployed.env_id!r}", + if_never_run=functools.partial(_remove, path)) files[filename] = store.put_document(FileArtifact( id=derive_id(universe_id, filename), filename=filename, content_type=PCAP_MEDIA_TYPE, object_url=url, description=f"Traffic capture of {container} in env {deployed.env_id!r}, task instance {context.instance_id}")) @@ -143,5 +147,22 @@ async def _store(context: TaskStepContext, deployed: DeployedEnv, captures: list "containers": sorted(seen)} +def _upload(universe_id: str, object_url: str, path: str) -> str: + """Upload the capture at ``path`` to ``object_url``, write-once, and remove it: the upload owns the file, so a + cancelled collection never removes it from under the upload.""" + try: + return get_config().get_object_store_to_write(object_url, universe_id).put_file_at(object_url, path, + PCAP_MEDIA_TYPE) + finally: + _remove(path) + + +def _remove(path: str) -> None: + try: + os.unlink(path) + except FileNotFoundError: + pass + + def _failed(error: BaseException) -> dict: - return {"state": "failed", "error": f"{type(error).__name__}: {error}"[:500]} + return {"state": "failed", "error": f"{type(error).__name__}: {error}"[:_ERROR_CHARS]} diff --git a/tst/unit/env/gateway/traffic_test.py b/tst/unit/env/gateway/traffic_test.py index a5a410133..17f11abd5 100644 --- a/tst/unit/env/gateway/traffic_test.py +++ b/tst/unit/env/gateway/traffic_test.py @@ -71,6 +71,68 @@ async def test_list_names_each_capture_by_its_container_oldest_first_and_get_str assert (got.status_code, got.headers["content-type"], got.content) == (200, PCAP_MEDIA_TYPE, _PCAP_HEADER + b"first") +class _Rewritten: + """An open capture file tcpdump reuses ``times`` times, each time just after a read of it: it truncates the file + and writes its next round's first packets, shorter than what was read.""" + + def __init__(self, path, times: int): + self.path, self.times, self.handle = path, times, open(path, "rb") + self.round = path.read_bytes() + + def read(self, size: int) -> bytes: + chunk = self.handle.read(size) + if chunk and self.times: + self.times -= 1 + self.path.write_bytes(_PCAP_HEADER + b"next") + return chunk + + def seek(self, offset: int) -> None: + if self.times: + self.path.write_bytes(self.round) + self.handle.seek(offset) + + def fileno(self) -> int: + return self.handle.fileno() + + +def test_a_capture_rewritten_while_it_is_copied_is_copied_again_whole(traffic_dir): + path = traffic_dir / "notes-website-backend.pcap0" + path.write_bytes(_PCAP_HEADER + b"the round tcpdump overwrites") + gw = _gateway(traffic_dir) + + with gw._snapshot_capture(_Rewritten(path, times=1)) as copy: + assert copy.read() == _PCAP_HEADER + b"next" + path.write_bytes(_PCAP_HEADER + b"the round tcpdump overwrites") + assert gw._snapshot_capture(_Rewritten(path, times=gw.CAPTURE_SNAPSHOT_ATTEMPTS)) is None + + +@pytest.mark.asyncio +async def test_get_answers_503_when_every_copy_of_a_capture_was_rewritten(traffic_dir, monkeypatch): + _write(traffic_dir, "notes-website-backend.pcap0", _PCAP_HEADER + b"x", 1000) + monkeypatch.setattr(Gateway, "_snapshot_capture", lambda self, handle: None) + response = await _get(_gateway(traffic_dir), "/traffic/capture", headers=_bearer(), + params={"name": "notes-website-backend.pcap0"}) + assert response.status_code == 503 and response.json()["error"]["code"] == "unavailable" + + +@pytest.mark.asyncio +async def test_get_sends_the_copy_it_took_whatever_the_file_becomes_after(traffic_dir, monkeypatch): + path = traffic_dir / "notes-website-backend.pcap0" + path.write_bytes(_PCAP_HEADER + b"whole") + real = Gateway._snapshot_capture + + def snapshot_then_rewrite(self, handle): + copy = real(self, handle) + path.write_bytes(_PCAP_HEADER + b"the next round, longer") + return copy + + monkeypatch.setattr(Gateway, "_snapshot_capture", snapshot_then_rewrite) + response = await _get(_gateway(traffic_dir), "/traffic/capture", headers=_bearer(), + params={"name": "notes-website-backend.pcap0"}) + assert (response.content, response.headers["content-length"]) == (_PCAP_HEADER + b"whole", + str(len(_PCAP_HEADER) + 5)) + + @pytest.mark.asyncio @pytest.mark.parametrize("name, status", [ ("../trajectory.jsonl", 400), ("notes-website-backend.pcap0/../../x", 400), ("stray.txt", 400), diff --git a/tst/unit/providers/env_providers/traffic_capture_test.py b/tst/unit/providers/env_providers/traffic_capture_test.py index ecbc2f44a..6d75cf617 100644 --- a/tst/unit/providers/env_providers/traffic_capture_test.py +++ b/tst/unit/providers/env_providers/traffic_capture_test.py @@ -65,7 +65,7 @@ def test_each_website_container_gets_a_capture_in_its_own_network_namespace(): assert capture["depends_on"] == [container] assert capture["cap_add"] == ["NET_RAW", "NET_ADMIN"] assert capture["entrypoint"] == ["tcpdump"] - assert capture["command"] == ["-i", "eth0", "-n", "-s", "0", "-U", "-Z", "root", "-C", str(TRAFFIC_CAPTURE_FILE_MB), + assert capture["command"] == ["-i", "any", "-n", "-s", "0", "-U", "-Z", "root", "-C", str(TRAFFIC_CAPTURE_FILE_MB), "-W", str(TRAFFIC_CAPTURE_FILES), "-w", f"/captures/{container}.pcap"] assert capture["volumes"] == ["traffic-captures:/captures"] assert TRAFFIC_CAPTURE_FILE_MB * TRAFFIC_CAPTURE_FILES <= 500 # a long run can't fill the VM's disk diff --git a/tst/unit/task_step/traffic_capture_test.py b/tst/unit/task_step/traffic_capture_test.py index 576010234..3fa74601c 100644 --- a/tst/unit/task_step/traffic_capture_test.py +++ b/tst/unit/task_step/traffic_capture_test.py @@ -5,7 +5,9 @@ from __future__ import annotations +import asyncio import os +import threading from unittest.mock import AsyncMock, patch import httpx @@ -20,6 +22,7 @@ from agent_env.task.teardown import recorded_sandboxes, tear_down from agent_env.task_step.context import TaskStepContext from agent_env.task_step.registry import get_task_step_registry +from agent_env.task_step.task_steps import traffic_capture as traffic_capture_module from agent_env.task_step.task_steps.traffic_capture import ( TRAFFIC_KEY, CollectTrafficCaptureTaskStep, @@ -95,6 +98,46 @@ async def test_each_capture_is_stored_as_a_pcap_in_one_universe_oldest_first(gat assert get_artifact_store().get_object(files["notes-website-backend-01.pcap"].object_url) == _PCAP + b"newer" +@pytest.mark.asyncio +async def test_two_collections_of_one_env_in_the_same_second_are_each_stored_under_a_prefix_of_their_own(gateway): + context = _context(_deployed()) + first = dict(await traffic_capture_module.collect_captures(context, _deployed())) + second = await traffic_capture_module.collect_captures(context, _deployed()) + + assert (first["state"], second["state"]) == ("collected", "collected") + urls = [{name: artifact.object_url for name, artifact in + Artifact.get(entry["artifact"]["id"], version=entry["artifact"]["version"]).get_file_artifacts().items()} + for entry in (first, second)] + assert sorted(urls[0]) == sorted(urls[1]) and not set(urls[0].values()) & set(urls[1].values()) + + +@pytest.mark.asyncio +async def test_a_collection_cancelled_mid_upload_leaves_the_upload_its_file_to_finish(gateway, monkeypatch): + started, release, uploaded = threading.Event(), threading.Event(), [] + real = traffic_capture_module._upload + + def held(universe_id, object_url, path): + started.set() + release.wait(10) + uploaded.append((path, os.path.exists(path))) + return real(universe_id, object_url, path) + + monkeypatch.setattr(traffic_capture_module, "_upload", held) + collecting = asyncio.ensure_future(traffic_capture_module.collect_captures(_context(_deployed()), _deployed())) + assert await asyncio.to_thread(started.wait, 10) + collecting.cancel() + with pytest.raises(asyncio.CancelledError): + await collecting + release.set() + for _ in range(200): + if uploaded and not os.path.exists(uploaded[0][0]): + break + await asyncio.sleep(0.01) + + [(path, existed)] = uploaded + assert existed and not os.path.exists(path) + + @pytest.mark.asyncio async def test_an_env_that_doesnt_capture_is_recorded_as_failed_and_fails_the_task_only_when_asked(gateway): context = await _step().execute(_context(_deployed(capturing=False))) From 3e2e3cef4f576f898b6fc9e9eaffaf07c165f503 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 16:06:07 -0800 Subject: [PATCH 52/57] fix(website): teardown tries a failed traffic collection again before the env closes (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/task_step/task_steps/traffic_capture.py | 9 +++++---- tst/unit/task_step/traffic_capture_test.py | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/agent_env/task_step/task_steps/traffic_capture.py b/src/agent_env/task_step/task_steps/traffic_capture.py index 661c6932b..a429d3a73 100644 --- a/src/agent_env/task_step/task_steps/traffic_capture.py +++ b/src/agent_env/task_step/task_steps/traffic_capture.py @@ -86,11 +86,12 @@ def captures_traffic(deployed: DeployedEnv) -> bool: async def collect_uncollected_captures(context: TaskStepContext, envs: Sequence[DeployedEnv]) -> None: - """Collect the captures of each of ``envs`` that serves traffic/v1 and whose captures the run hasn't collected or - tried to, as teardown does before the envs close; ``collect_captures`` records what happened to each.""" - tried = context.metadata.get(TRAFFIC_KEY) or {} + """Collect the captures of each of ``envs`` that serves traffic/v1 and whose captures the run hasn't collected, a + collection that failed tried again, as teardown does before the envs close; ``collect_captures`` records what + happened to each.""" + entries = context.metadata.get(TRAFFIC_KEY) or {} for deployed in envs: - if captures_traffic(deployed) and deployed.env_id not in tried: + if captures_traffic(deployed) and (entries.get(deployed.env_id) or {}).get("state") != "collected": await collect_captures(context, deployed) diff --git a/tst/unit/task_step/traffic_capture_test.py b/tst/unit/task_step/traffic_capture_test.py index 3fa74601c..729e91342 100644 --- a/tst/unit/task_step/traffic_capture_test.py +++ b/tst/unit/task_step/traffic_capture_test.py @@ -164,10 +164,11 @@ async def test_an_env_the_run_didnt_deploy_is_recorded(gateway): @pytest.mark.asyncio -async def test_teardown_collects_only_capturing_envs_the_run_didnt_collect(gateway): +async def test_teardown_collects_each_capturing_env_the_run_didnt_collect_a_failed_collection_again(gateway): capturing, plain, done = _deployed(), _deployed("plain-env", capturing=False), _deployed("done-env") context = _context(capturing, plain, done) - context.metadata[TRAFFIC_KEY] = {"done-env": {"state": "collected", "artifact": None}} + context.metadata[TRAFFIC_KEY] = {"done-env": {"state": "collected", "artifact": None}, + "notes-env": {"state": "failed", "error": "ConnectError: refused"}} await collect_uncollected_captures(context, context.deployed_envs) From 9953df5d353e26ccfdb41d912e031ee2cf706109 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 16:43:47 -0800 Subject: [PATCH 53/57] fix(website): teardown saves the traffic it collects to the run, and collects a retry's rolled-back deployments too (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/agent_env/task/store.py | 15 +++++ src/agent_env/task/task.py | 6 +- src/agent_env/task/teardown.py | 9 ++- .../task_step/task_steps/traffic_capture.py | 61 +++++++++++++------ tst/unit/task_step/retry_test.py | 2 + tst/unit/task_step/traffic_capture_test.py | 41 ++++++++++++- 7 files changed, 110 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb35c3ec8..d1971eb4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,7 +119,7 @@ and deserialized through a registry. |---|---| | `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, ready when its card's `website/v1` `ready` answers a `WebsiteReadiness` that says so, as `agentenv_protocol.website` (`website_readiness`, `env_readiness`) defines a website env's health for the deploy and `DeployedEnv.readiness()` alike, or, for a card without `ready`, when its `health` path answers 200, which is deprecated; the gateway container's `env/gateway/constants.py` mirrors the URI and op, a test keeping them equal; 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.]` (`env/envs/computer/utils/install.py`): files, apt `packages` with extra `sources`, a `python` requirements file for a venv, `downloads` by URL and sha256, `run` scripts, the services the base's init runs, and `ports`; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch on a builder of the put's sandbox provider, by default the env's (`utils/builder.py`; `--sandbox` on `env build`, `bundle register` and `run`): this machine's Docker for `local`, else a VM of the provider, which fetches the put's stored files by signed URL, takes the rest inline and pushes to the image store itself, so it builds only with stores a remote VM reaches; each arch goes to the first link that runs it natively, else one that emulates it (a VM registers QEMU; `--no-emulate` leaves it `skipped` instead, for a later put), and its pair records the provider that built it (`builder`); a run or register that reuses a computer env composes what it has left (`pending`), a run only the forms its sandbox's deploys boot (`booted_forms`: on each link, the first form it boots natively, by the deploy's own rule, `boot.first_native`; a compose of a disk adds its container), and a deploy composes first what it boots that no compose started (`ComputerEnv.compose_unstarted`); tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile, build context and lock so any env with the same inputs reuses it; every layer's packages, requirements and downloads are resolved at put in a container of the base for each arch (`utils/packages.py`, `utils/resolve_in_base.py`), stored in the object store by sha256 under `agentenv-packages/`, and listed in a lock stored by a digest of its inputs, which any env with the same inputs reuses, and builds install only the lock's files, every install step `RUN --network=none`; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 (`utils/probing.py`) then boots each native build on the put's sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`, which declares each OS with its own decorator (`@linux(...)`, or `@install(os_type, spec)` for any OS type) holding its install, its launch and, optionally, a skill and settings and build settings that add to the class's there, and install steps and ready checks for every OS or those their `os=` names; everything that reads its settings, launch or steps asks for the computer's OS type; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference, over its env.toml's `[settings]` and a computer env's `settings` beside it in `apps`, each by top-level field (`params.app_settings`; checked at put, a required one left to the deploy; the deploy record keeps the merged values as `app_settings`, secrets withheld); its env.toml's `build_settings`, values of its `BuildSettings` checked at put, are written to `.agentenv/build_settings.json` in its folder, so they're part of the image's key, its install steps read them, and a deploy can't change them). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `env/gateway/gateway.py` is the env-side gateway server, container-only code. 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. A deploy with `capture_traffic` (`deploy_env`'s, `env deploy --capture-traffic`; a website env, or a multi env with a website, on a VM, refused for any other env and in container mode) records every packet each website's server and frontend containers send and receive: beside each runs a capture service, tcpdump from the gateway's image in that container's network namespace (`network_mode: "service:"`), the only service holding `NET_RAW` and `NET_ADMIN`, rotating its classic pcap files (10 of 20 MB each, the oldest overwritten) onto a volume no website container mounts and the gateway mounts read-only and serves as `urn:agentenv:traffic/v1` (`list`, `GET /traffic/captures`, oldest first; `get`, `GET /traffic/capture?name=`), which `agentenv_protocol.traffic` defines (`TrafficCapture`, `list_traffic_captures`, `fetch_traffic_capture`), the gateway's `constants.py` mirroring its names, a test keeping them equal. Those routes take the per-deploy control token the record keeps (`DeployedEnv.control_token`, the gateway's `AGENTENV_CONTROL_TOKEN`), which the gateway takes off every request before it proxies one, so no site reads it; a capture that isn't running once the gateway is up refuses the deploy. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | | `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Every artifact but an environment universe, whose `metadata` key holds its legacy metadata file ids, carries a `metadata` dict outside its versioned fields, changed by compare-and-swap (`merge_metadata`). Built-ins: `CliArtifact` (`cli`), `ComputerArtifact` (`computer`, a base computer: its architectures, OS, session user, settings and container form, whose image `put` reads from its registry into `metadata.validated_images`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`: its environment's files by path, stored as the one wrapped `file_artifact_ref` every environment artifact had when it has one file at its filename, else as `file_artifact_refs`; `get_file_artifacts()` reads either, and a load hands each file to the env's `data/add` with its path, which a pre-v1 load, taking one file, refuses), `EnvironmentUniverseArtifact` (`environment_universe`), `SkillArtifact` (`skill`); `[artifacts] type_aliases` maps another stored spelling of a type onto one of them. A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull, or a published image's tag with its `image_digest` and its build context, which sandboxes pull by the digest and build from the context when they can't (the infra images, and a bundle agent built from a shipped agent's sources, `Plan.published`), or, written by `put_context`, only its build context, which a VM sandbox builds before running it (`VmSandbox.load_docker_images`) and Modal builds before creating its container (`ModalSandboxProvider.prepare_image`, outside a fallback chain's deadline); anything else that runs images by name (a container on the local provider, a lone server on any provider but Modal) refuses such an image. A build context (`utils/docker/context.py`) is what `docker build` would send, `.dockerignore` applied, written so equal files give equal bytes and an equal `source_digest`. | -| `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, among them `start_computer_recording` and `end_computer_recording`, `collect_traffic_capture`, which stores each traffic/v1 capture of an env deployed with `capture_traffic` as a pcap `FileArtifact` in one `FileArtifactUniverse` recorded under `context.metadata["traffic_captures"][env_id]`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. A computer's framework-only routes (everything but MCP and the card) take the per-deploy control token its provider boots it with (`DeployedEnv.control_token`, `AGENTENV_CONTROL_TOKEN`); framework calls send `deployed.control_headers`, `Task.run` runs every step under `protocol_v1.authorized` with each deployed env's (a `verify` included), and a `run_code` script inside the computer gets `input["env"]["headers"]`. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each capturing env still up whose traffic the run didn't collect is collected, 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`). | +| `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/`, among them `start_computer_recording` and `end_computer_recording`, `collect_traffic_capture`, which stores each traffic/v1 capture of an env deployed with `capture_traffic` as a pcap `FileArtifact` in one `FileArtifactUniverse` recorded under `context.metadata["traffic_captures"][env_id]`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. A computer's framework-only routes (everything but MCP and the card) take the per-deploy control token its provider boots it with (`DeployedEnv.control_token`, `AGENTENV_CONTROL_TOKEN`); framework calls send `deployed.control_headers`, `Task.run` runs every step under `protocol_v1.authorized` with each deployed env's (a `verify` included), and a `run_code` script inside the computer gets `input["env"]["headers"]`. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each capturing env still up whose traffic the run didn't collect is collected, a retry's rolled-back ones too (kept as `capturing_envs` in its `retry_resets` marker, each under `-rolled-back-`), and what it records saved to the stored instance's `context.metadata`, 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 namespaces, `@local` and `@hf` (`is_local_id`), each with a documents file of its own: while the CLI or a `build_app` server runs, a local id and what a 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/`. | | `config/` | The `Config` singleton (`get_config`, `configure`, `reset_config`) in `config/runtime.py`, file discovery in `config/loader.py`, and `load_impl`, which resolves `module:Class` pointers. `agent_env.store` re-exports the config names for compatibility. | | `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra), `smol_vm` (the `smol` extra), `tensorlake` (the `tensorlake` extra), `tenki` (the `tenki` extra), `vercel` (the `vercel` extra), `boxd` (the `boxd` extra), `coreweave` (the `coreweave` extra), `daytona` (the `daytona` extra), `boat` (the `boat` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, and for VMs on this machine the address their requests to it arrive at, `REACHES_HOST_AT`; `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store), `EnvironmentGatewayProvider` (`gateway`, an env's default), which renders a docker-compose for the gateway and its MCP servers inside the sandbox, and `EnvironmentServerProvider` (`server`), one MCP server in its own container with no gateway in front and no state store; `providers/env_state/` holds env-state providers (`local_postgres` built in). | diff --git a/src/agent_env/task/store.py b/src/agent_env/task/store.py index 869db45d0..1a720a82a 100644 --- a/src/agent_env/task/store.py +++ b/src/agent_env/task/store.py @@ -789,6 +789,12 @@ def record_task_cancelled_sync( ), ) + def set_context_metadata_sync(self, instance_id: str, key: str, value: object) -> None: + """Set ``context.metadata[key]`` of a stored instance outside any step, as teardown records what it did after + the run's last step saved; not journaled, since nothing after teardown undoes it.""" + self._doc_store.update(TASK_INSTANCES_COLLECTION, Filter.of(instance_id=instance_id), + UpdateSpec(set={f"context.metadata.{key}": value})) + def append_step_attempt_failure_sync(self, instance_id: str, failure: "StepAttemptFailure") -> None: """Best-effort append of one attempt's failure, designed not to conflict with the ``record_task_failure`` write. ``add_to_set`` creates the array on the first append and @@ -898,6 +904,15 @@ def seed_task_instance_context(instance_id: str, context: "TaskStepContext") -> logger.warning("Failed to seed task instance context", exc_info=True) +def record_context_metadata(instance_id: str, key: str, value: object) -> None: + """Save ``context.metadata[key]`` of the stored instance ``instance_id``, as teardown does once the run's steps + have saved theirs; a failure is logged, never raised.""" + try: + get_task_instance_store().set_context_metadata_sync(instance_id, key, value) + except Exception: + logger.warning("Failed to save context.metadata[%r] of task instance %s", key, instance_id, exc_info=True) + + async def record_step_complete( instance_id: str, step_id: str, diff --git a/src/agent_env/task/task.py b/src/agent_env/task/task.py index 6840a58d8..c6ce57022 100644 --- a/src/agent_env/task/task.py +++ b/src/agent_env/task/task.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING, Callable, ClassVar, Optional from agentenv_protocol import client as protocol_v1 +from agentenv_protocol.traffic import serves_traffic from agent_env.config import run_env_ids from agent_env.plugins import _registration @@ -44,7 +45,9 @@ def _rolled_back_sandboxes(before: dict, after: dict) -> dict: context ``before`` an undo and absent ``after`` it, shaped like the context's own slices so the executor's cleanup can scan the marker exactly as it scans a context. The undo is what removes these from the live and persisted context, so without this record an - orphaned sandbox from the failed attempt is discoverable nowhere. + orphaned sandbox from the failed attempt is discoverable nowhere. ``capturing_envs`` keeps + the whole record of each env among them that serves traffic/v1, so teardown can still + collect its captures before its sandboxes go. """ def gone(key: str) -> list[dict]: kept = {_identity(e) for e in after.get(key) or []} @@ -64,6 +67,7 @@ def gone(key: str) -> list[dict]: {"sandbox_id": x.get("sandbox_id"), "sandbox_type": x.get("sandbox_type")} for x in gone("deployed_sandboxes") ], + "capturing_envs": [e for e in gone("deployed_envs") if serves_traffic(e.get("environment_card") or {})], } diff --git a/src/agent_env/task/teardown.py b/src/agent_env/task/teardown.py index a0aa9e709..2468b70c1 100644 --- a/src/agent_env/task/teardown.py +++ b/src/agent_env/task/teardown.py @@ -32,7 +32,7 @@ from agent_env.task.interrupts import tearing_down from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_steps.computer_recording import end_open_recordings, register_open_recordings -from agent_env.task_step.task_steps.traffic_capture import collect_uncollected_captures +from agent_env.task_step.task_steps.traffic_capture import collect_uncollected_captures, rolled_back_deployments logger = logging.getLogger(__name__) @@ -119,7 +119,8 @@ async def teardown_run(context: TaskStepContext, *, timeout: float = TERMINATE_T async def tear_down(context: TaskStepContext, sandboxes: Sequence[RecordedSandbox], envs: Sequence[DeployedEnv], *, timeout: float) -> TeardownReport: """End the recordings the run left open on ``envs`` with fresh grants, collect the traffic captures of each of them - that captures and that the run didn't collect, close each env still up, register any + that captures and that the run didn't collect, and of each capturing env a retry rolled back whose sandboxes are + among ``sandboxes``, close each env still up, register any recording still open from its namespace, then terminate each of ``sandboxes`` by id, all at once and each within ``timeout`` seconds, and remove a local one's work folder once it's down. A close that fails is logged: its env's sandboxes are terminated by id regardless, which leaves one the close took down as it is. Cancelling it returns what it reached, with the rest in ``left``.""" @@ -133,9 +134,11 @@ async def tear_down(context: TaskStepContext, sandboxes: Sequence[RecordedSandbo except TimeoutError: logger.warning("teardown: the run's open recordings weren't all ended within %gs", timeout) still_up = [env for env in envs if any(sandbox_id in up for sandbox_id, _ in env_sandbox_ids(env))] + rolled_back = [(key, env) for key, env in rolled_back_deployments(context) + if any(sandbox_id in up for sandbox_id, _ in env_sandbox_ids(env))] try: async with asyncio.timeout(timeout): - await collect_uncollected_captures(context, still_up) + await collect_uncollected_captures(context, still_up, rolled_back=rolled_back) except TimeoutError: logger.warning("teardown: the run's uncollected traffic captures weren't all collected within %gs", timeout) await asyncio.gather(*(_close(env, timeout) for env in still_up)) diff --git a/src/agent_env/task_step/task_steps/traffic_capture.py b/src/agent_env/task_step/task_steps/traffic_capture.py index a429d3a73..4a93e3919 100644 --- a/src/agent_env/task_step/task_steps/traffic_capture.py +++ b/src/agent_env/task_step/task_steps/traffic_capture.py @@ -2,9 +2,11 @@ packet its websites' containers send and receive as rotating pcap files, which its gateway's card serves as traffic/v1 behind the deploy's control token. ``collect_traffic_capture`` stores each as a FileArtifact and registers them as one FileArtifactUniverse, recorded in ``context.metadata[TRAFFIC_KEY][env_id]``. Teardown collects any capturing env a run -didn't, before the env closes (``collect_uncollected_captures``), so a capture isn't lost with its VM.""" +didn't, a retry's rolled-back ones included, before the env closes (``collect_uncollected_captures``), so a capture +isn't lost with its VM, and saves what it recorded to the run's stored instance.""" from __future__ import annotations +import asyncio import functools import logging import os @@ -85,36 +87,57 @@ def captures_traffic(deployed: DeployedEnv) -> bool: return isinstance(deployed, DeployedEnv) and serves_traffic(deployed.environment_card) -async def collect_uncollected_captures(context: TaskStepContext, envs: Sequence[DeployedEnv]) -> None: +async def collect_uncollected_captures(context: TaskStepContext, envs: Sequence[DeployedEnv], *, + rolled_back: Sequence[tuple[str, DeployedEnv]] = ()) -> None: """Collect the captures of each of ``envs`` that serves traffic/v1 and whose captures the run hasn't collected, a - collection that failed tried again, as teardown does before the envs close; ``collect_captures`` records what - happened to each.""" + collection that failed tried again, as teardown does before the envs close, and of each ``rolled_back`` deployment, + a retry's, under its own key (``rolled_back_deployments``); ``collect_captures`` records what happened to each, and + the entries are saved to the run's stored instance, since teardown runs after its steps saved theirs.""" entries = context.metadata.get(TRAFFIC_KEY) or {} - for deployed in envs: - if captures_traffic(deployed) and (entries.get(deployed.env_id) or {}).get("state") != "collected": - await collect_captures(context, deployed) - - -async def collect_captures(context: TaskStepContext, deployed: DeployedEnv) -> dict: + tried = False + for key, deployed in [*((deployed.env_id, deployed) for deployed in envs), *rolled_back]: + if captures_traffic(deployed) and (entries.get(key) or {}).get("state") != "collected": + await collect_captures(context, deployed, key=key) + tried = True + if tried and context.instance_id: + from agent_env.task.store import record_context_metadata + + await asyncio.to_thread(record_context_metadata, context.instance_id, TRAFFIC_KEY, + context.metadata[TRAFFIC_KEY]) + + +def rolled_back_deployments(context: TaskStepContext) -> list[tuple[str, DeployedEnv]]: + """Each capturing env a retry rolled back, with the key its captures are recorded under: + ``-rolled-back-`` for the ``n``-th of the run's ``retry_resets``.""" + found = [] + for n, reset in enumerate(context.metadata.get("retry_resets") or [], start=1): + for record in (reset.get("rolled_back_sandboxes") or {}).get("capturing_envs") or []: + deployed = DeployedEnv.from_dict(record) + found.append((f"{deployed.env_id}-rolled-back-{n}", deployed)) + return found + + +async def collect_captures(context: TaskStepContext, deployed: DeployedEnv, *, key: Optional[str] = None) -> dict: """Store each capture ``deployed`` serves as a pcap FileArtifact, register them as one FileArtifactUniverse, and - record it in ``context.metadata[TRAFFIC_KEY]``. Each file is named for its container and its place among that - container's files, oldest first, so they read in the order their packets were captured. Never raises: the entry, - which it also returns, says what happened.""" + record it in ``context.metadata[TRAFFIC_KEY][key]``, by default its env_id. Each file is named for its container + and its place among that container's files, oldest first, so they read in the order their packets were captured. + Never raises: the entry, which it also returns, says what happened.""" + key = key or deployed.env_id entries = context.metadata.setdefault(TRAFFIC_KEY, {}) try: if not captures_traffic(deployed): raise EnvCapabilityUnsupported(TRAFFIC_EXTENSION_URI, TRAFFIC_LIST_OP, deployed.env_id, deployed.instance_id) listing = await list_traffic_captures(deployed.environment_url, deployed.environment_card, headers=deployed.control_headers) - entries[deployed.env_id] = await _store(context, deployed, listing.captures) + entries[key] = await _store(context, deployed, listing.captures, key) except Exception as e: - logger.warning("collect_traffic_capture %s: %s", deployed.env_id, e) - entries[deployed.env_id] = _failed(e) - return entries[deployed.env_id] + logger.warning("collect_traffic_capture %s: %s", key, e) + entries[key] = _failed(e) + return entries[key] -async def _store(context: TaskStepContext, deployed: DeployedEnv, captures: list[TrafficCapture]) -> dict: - universe_id = derive_id(context.instance_id or "run", f"traffic-{deployed.env_id}") +async def _store(context: TaskStepContext, deployed: DeployedEnv, captures: list[TrafficCapture], key: str) -> dict: + universe_id = derive_id(context.instance_id or "run", f"traffic-{key}") if is_local_id(universe_id): validate_local_id(universe_id) get_config().check_local_run_write(universe_id) diff --git a/tst/unit/task_step/retry_test.py b/tst/unit/task_step/retry_test.py index 46c67094e..66dd3ea8d 100644 --- a/tst/unit/task_step/retry_test.py +++ b/tst/unit/task_step/retry_test.py @@ -173,6 +173,7 @@ async def test_retry_rolls_back_span_and_redispatches_on_fresh_world(store): "deployed_envs": [{"sandbox_id": "sb-s0-1", "sandbox_type": "fake", "sandbox_ids": {}}], "deployed_agents": [], "deployed_sandboxes": [], + "capturing_envs": [], } assert [f.get("retried") for f in result.metadata["failed_steps"]] == [True] @@ -773,6 +774,7 @@ def test_rolled_back_sandboxes_is_the_difference_between_stored_and_replayed(): "deployed_envs": [{"sandbox_id": "sb-e", "sandbox_type": "beta", "sandbox_ids": {"vm": "sb-vm"}}], "deployed_agents": [{"sandbox_id": "sb-a", "sandbox_type": "local"}], "deployed_sandboxes": [{"sandbox_id": "sb-x", "sandbox_type": None}], + "capturing_envs": [], } diff --git a/tst/unit/task_step/traffic_capture_test.py b/tst/unit/task_step/traffic_capture_test.py index 729e91342..f3a5a8c0b 100644 --- a/tst/unit/task_step/traffic_capture_test.py +++ b/tst/unit/task_step/traffic_capture_test.py @@ -19,6 +19,8 @@ from agent_env.env.gateway import gateway as gw_mod from agent_env.env.gateway.constants import PCAP_MEDIA_TYPE, TRAFFIC_EXTENSION from agent_env.env.gateway.gateway import Gateway +from agent_env.task.store import find_task_instance, get_task_instance_store +from agent_env.task.task import _rolled_back_sandboxes from agent_env.task.teardown import recorded_sandboxes, tear_down from agent_env.task_step.context import TaskStepContext from agent_env.task_step.registry import get_task_step_registry @@ -54,9 +56,10 @@ def gateway(tmp_path, monkeypatch, local_stores): gw._close_trajectory_file() -def _deployed(env_id: str = "notes-env", *, token: str | None = TOKEN, capturing: bool = True) -> DeployedGatewayEnv: +def _deployed(env_id: str = "notes-env", *, token: str | None = TOKEN, capturing: bool = True, + sandbox_id: str = "vm-1") -> DeployedGatewayEnv: card = {"name": "env1", "capabilities": {"extensions": [TRAFFIC_EXTENSION] if capturing else []}} - return DeployedGatewayEnv(env_id=env_id, env_version=1, gateway_url="http://gateway", sandbox_id="vm-1", + return DeployedGatewayEnv(env_id=env_id, env_version=1, gateway_url="http://gateway", sandbox_id=sandbox_id, environment_card_url="http://gateway/.well-known/agent-env.json", environment_card=card, control_token=token) @@ -200,3 +203,37 @@ async def test_tear_down_leaves_an_env_already_torn_down_alone(gateway): patch("agent_env.task.teardown.terminate", AsyncMock(return_value=False)): await tear_down(context, recorded_sandboxes(context), context.deployed_envs, timeout=30) assert TRAFFIC_KEY not in context.metadata + + +@pytest.mark.asyncio +async def test_what_teardown_collects_is_saved_to_the_runs_stored_instance(gateway): + instance = get_task_instance_store().create_instance("task-1", 1, total_steps=1) + context = _context(_deployed()) + context.instance_id = instance.instance_id + + await collect_uncollected_captures(context, context.deployed_envs) + + stored = find_task_instance(instance.instance_id).context["metadata"][TRAFFIC_KEY] + assert stored == context.metadata[TRAFFIC_KEY] and stored["notes-env"]["state"] == "collected" + + +@pytest.mark.asyncio +async def test_tear_down_collects_a_retrys_rolled_back_deployment_under_its_own_key_before_its_vm_goes(gateway): + rolled_back, live = _deployed(sandbox_id="vm-1"), _deployed(sandbox_id="vm-2") + gone = _rolled_back_sandboxes(TaskStepContext(deployed_envs=[rolled_back]).to_dict(), TaskStepContext().to_dict()) + context = _context(live) + context.metadata["retry_resets"] = [{"attempt": 2, "rolled_back_sandboxes": gone}] + collected_before_terminate: list[set] = [] + + async def terminate(sandbox): + collected_before_terminate.append({key for key, entry in context.metadata[TRAFFIC_KEY].items() + if entry["state"] == "collected"}) + return False + + with patch("agent_env.task.teardown._close", AsyncMock()), patch("agent_env.task.teardown.terminate", terminate): + report = await tear_down(context, recorded_sandboxes(context), context.deployed_envs, timeout=30) + + assert sorted(s.sandbox_id for s in report.terminated) == ["vm-1", "vm-2"] + assert collected_before_terminate == [{"notes-env", "notes-env-rolled-back-1"}] * 2 + assert context.metadata[TRAFFIC_KEY]["notes-env-rolled-back-1"]["artifact"] != \ + context.metadata[TRAFFIC_KEY]["notes-env"]["artifact"] From 29e406ab1ddee5ea97108516287d57dc2f0117fd Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 17:13:00 -0800 Subject: [PATCH 54/57] fix(website): teardown saves the captures it collected even when its time runs out partway (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../task_step/task_steps/traffic_capture.py | 23 ++++++++++--------- tst/unit/task_step/traffic_capture_test.py | 21 +++++++++++++++++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/agent_env/task_step/task_steps/traffic_capture.py b/src/agent_env/task_step/task_steps/traffic_capture.py index 4a93e3919..7d19485f2 100644 --- a/src/agent_env/task_step/task_steps/traffic_capture.py +++ b/src/agent_env/task_step/task_steps/traffic_capture.py @@ -6,7 +6,6 @@ isn't lost with its VM, and saves what it recorded to the run's stored instance.""" from __future__ import annotations -import asyncio import functools import logging import os @@ -92,18 +91,20 @@ async def collect_uncollected_captures(context: TaskStepContext, envs: Sequence[ """Collect the captures of each of ``envs`` that serves traffic/v1 and whose captures the run hasn't collected, a collection that failed tried again, as teardown does before the envs close, and of each ``rolled_back`` deployment, a retry's, under its own key (``rolled_back_deployments``); ``collect_captures`` records what happened to each, and - the entries are saved to the run's stored instance, since teardown runs after its steps saved theirs.""" + the entries are saved to the run's stored instance, since teardown runs after its steps saved theirs, even when + teardown's time runs out partway, so what was collected by then stays linked to the run.""" entries = context.metadata.get(TRAFFIC_KEY) or {} tried = False - for key, deployed in [*((deployed.env_id, deployed) for deployed in envs), *rolled_back]: - if captures_traffic(deployed) and (entries.get(key) or {}).get("state") != "collected": - await collect_captures(context, deployed, key=key) - tried = True - if tried and context.instance_id: - from agent_env.task.store import record_context_metadata - - await asyncio.to_thread(record_context_metadata, context.instance_id, TRAFFIC_KEY, - context.metadata[TRAFFIC_KEY]) + try: + for key, deployed in [*((deployed.env_id, deployed) for deployed in envs), *rolled_back]: + if captures_traffic(deployed) and (entries.get(key) or {}).get("state") != "collected": + tried = True + await collect_captures(context, deployed, key=key) + finally: + if tried and context.instance_id and TRAFFIC_KEY in context.metadata: + from agent_env.task.store import record_context_metadata + + record_context_metadata(context.instance_id, TRAFFIC_KEY, context.metadata[TRAFFIC_KEY]) def rolled_back_deployments(context: TaskStepContext) -> list[tuple[str, DeployedEnv]]: diff --git a/tst/unit/task_step/traffic_capture_test.py b/tst/unit/task_step/traffic_capture_test.py index f3a5a8c0b..758830166 100644 --- a/tst/unit/task_step/traffic_capture_test.py +++ b/tst/unit/task_step/traffic_capture_test.py @@ -217,6 +217,27 @@ async def test_what_teardown_collects_is_saved_to_the_runs_stored_instance(gatew assert stored == context.metadata[TRAFFIC_KEY] and stored["notes-env"]["state"] == "collected" +@pytest.mark.asyncio +async def test_what_teardown_collected_before_its_time_ran_out_is_still_saved_to_the_run(gateway, monkeypatch): + instance = get_task_instance_store().create_instance("task-1", 1, total_steps=1) + context = _context(_deployed(), _deployed("slow-env")) + context.instance_id = instance.instance_id + real = traffic_capture_module.collect_captures + + async def collect(context, deployed, *, key=None): + if deployed.env_id == "slow-env": + await asyncio.sleep(30) + return await real(context, deployed, key=key) + + monkeypatch.setattr(traffic_capture_module, "collect_captures", collect) + with pytest.raises(TimeoutError): + async with asyncio.timeout(1): + await collect_uncollected_captures(context, context.deployed_envs) + + stored = find_task_instance(instance.instance_id).context["metadata"][TRAFFIC_KEY] + assert stored["notes-env"]["state"] == "collected" and "slow-env" not in stored + + @pytest.mark.asyncio async def test_tear_down_collects_a_retrys_rolled_back_deployment_under_its_own_key_before_its_vm_goes(gateway): rolled_back, live = _deployed(sandbox_id="vm-1"), _deployed(sandbox_id="vm-2") From 14ff94f8a853dde49b50b8dedba47b32e78f8e33 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 17:40:04 -0800 Subject: [PATCH 55/57] fix(website): a traffic collection stopped partway keeps the files it stored linked to the run as partial (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../task_step/task_steps/traffic_capture.py | 56 ++++++++++++------- tst/unit/task_step/traffic_capture_test.py | 29 ++++++++++ 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/agent_env/task_step/task_steps/traffic_capture.py b/src/agent_env/task_step/task_steps/traffic_capture.py index 7d19485f2..ae9a2fff1 100644 --- a/src/agent_env/task_step/task_steps/traffic_capture.py +++ b/src/agent_env/task_step/task_steps/traffic_capture.py @@ -122,7 +122,9 @@ async def collect_captures(context: TaskStepContext, deployed: DeployedEnv, *, k """Store each capture ``deployed`` serves as a pcap FileArtifact, register them as one FileArtifactUniverse, and record it in ``context.metadata[TRAFFIC_KEY][key]``, by default its env_id. Each file is named for its container and its place among that container's files, oldest first, so they read in the order their packets were captured. - Never raises: the entry, which it also returns, says what happened.""" + Never raises: the entry, which it also returns, says what happened. A collection that stops partway, failed or + cancelled, still registers the files it stored by then and records them as ``partial``, which teardown collects + again.""" key = key or deployed.env_id entries = context.metadata.setdefault(TRAFFIC_KEY, {}) try: @@ -133,7 +135,8 @@ async def collect_captures(context: TaskStepContext, deployed: DeployedEnv, *, k entries[key] = await _store(context, deployed, listing.captures, key) except Exception as e: logger.warning("collect_traffic_capture %s: %s", key, e) - entries[key] = _failed(e) + partial = entries.get(key) or {} + entries[key] = {**partial, **_failed(e), "state": "partial"} if partial.get("state") == "partial" else _failed(e) return entries[key] @@ -145,29 +148,40 @@ async def _store(context: TaskStepContext, deployed: DeployedEnv, captures: list store = get_artifact_store() prefix = store.attempt_prefix(_ARTIFACT_TYPE, universe_id) files, total, seen = {}, 0, {} - for capture in captures: - container = capture.container - seen[container] = seen.get(container, -1) + 1 - filename = f"{container}-{seen[container]:02d}.pcap" - handle, path = tempfile.mkstemp(suffix=".pcap") - os.close(handle) - try: - total += await fetch_traffic_capture(deployed.environment_url, deployed.environment_card, capture.name, - path, headers=deployed.control_headers) - except BaseException: - _remove(path) - raise - url = await finish_on_thread(functools.partial(_upload, universe_id, prefix + filename, path), - f"Uploading traffic capture {filename} of env {deployed.env_id!r}", - if_never_run=functools.partial(_remove, path)) - files[filename] = store.put_document(FileArtifact( - id=derive_id(universe_id, filename), filename=filename, content_type=PCAP_MEDIA_TYPE, object_url=url, - description=f"Traffic capture of {container} in env {deployed.env_id!r}, task instance {context.instance_id}")) + try: + for capture in captures: + container = capture.container + seen[container] = seen.get(container, -1) + 1 + filename = f"{container}-{seen[container]:02d}.pcap" + handle, path = tempfile.mkstemp(suffix=".pcap") + os.close(handle) + try: + total += await fetch_traffic_capture(deployed.environment_url, deployed.environment_card, capture.name, + path, headers=deployed.control_headers) + except BaseException: + _remove(path) + raise + url = await finish_on_thread(functools.partial(_upload, universe_id, prefix + filename, path), + f"Uploading traffic capture {filename} of env {deployed.env_id!r}", + if_never_run=functools.partial(_remove, path)) + files[filename] = store.put_document(FileArtifact( + id=derive_id(universe_id, filename), filename=filename, content_type=PCAP_MEDIA_TYPE, object_url=url, + description=f"Traffic capture of {container} in env {deployed.env_id!r}, task instance {context.instance_id}")) + except BaseException as e: + if files: + context.metadata[TRAFFIC_KEY][key] = {**_entry(universe_id, files, total, seen), "state": "partial", + "error": _failed(e)["error"]} + raise + logger.info("collect_traffic_capture %s: %d capture files, %d bytes", deployed.env_id, len(files), total) + return _entry(universe_id, files, total, seen) + + +def _entry(universe_id: str, files: dict, total: int, seen: dict) -> dict: + """The ``collected`` entry of ``files``, registered as one FileArtifactUniverse when there are any.""" artifact = None if files: universe = FileArtifactUniverse.put(id=universe_id, file_artifacts=files) artifact = {"id": universe.id, "version": universe.version} - logger.info("collect_traffic_capture %s: %d capture files, %d bytes", deployed.env_id, len(files), total) return {"state": "collected", "artifact": artifact, "files": sorted(files), "bytes": total, "containers": sorted(seen)} diff --git a/tst/unit/task_step/traffic_capture_test.py b/tst/unit/task_step/traffic_capture_test.py index 758830166..c5aad7190 100644 --- a/tst/unit/task_step/traffic_capture_test.py +++ b/tst/unit/task_step/traffic_capture_test.py @@ -238,6 +238,35 @@ async def collect(context, deployed, *, key=None): assert stored["notes-env"]["state"] == "collected" and "slow-env" not in stored +@pytest.mark.asyncio +async def test_a_collection_stopped_partway_keeps_the_files_it_stored_linked_as_partial(gateway, monkeypatch): + instance = get_task_instance_store().create_instance("task-1", 1, total_steps=1) + context = _context(_deployed()) + context.instance_id = instance.instance_id + real, fetched = traffic_capture_module.fetch_traffic_capture, [] + + async def fetch(*args, **kwargs): + fetched.append(args[2]) + if len(fetched) == 2: + await asyncio.sleep(30) + return await real(*args, **kwargs) + + monkeypatch.setattr(traffic_capture_module, "fetch_traffic_capture", fetch) + with pytest.raises(TimeoutError): + async with asyncio.timeout(1): + await collect_uncollected_captures(context, context.deployed_envs) + + entry = context.metadata[TRAFFIC_KEY]["notes-env"] + assert entry["state"] == "partial" and entry["files"] == ["notes-website-backend-00.pcap"] + universe = Artifact.get(entry["artifact"]["id"], version=entry["artifact"]["version"]) + assert sorted(universe.get_file_artifacts()) == ["notes-website-backend-00.pcap"] + assert find_task_instance(instance.instance_id).context["metadata"][TRAFFIC_KEY]["notes-env"] == entry + + monkeypatch.setattr(traffic_capture_module, "fetch_traffic_capture", real) + await collect_uncollected_captures(context, context.deployed_envs) + assert context.metadata[TRAFFIC_KEY]["notes-env"]["state"] == "collected" + + @pytest.mark.asyncio async def test_tear_down_collects_a_retrys_rolled_back_deployment_under_its_own_key_before_its_vm_goes(gateway): rolled_back, live = _deployed(sandbox_id="vm-1"), _deployed(sandbox_id="vm-2") From 46aa361311878b725c03f397277dc7c3eb7bfed8 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 20:03:33 -0800 Subject: [PATCH 56/57] fix(website): traffic collection makes every store write on a worker thread, so teardown's time limit holds (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../task_step/task_steps/traffic_capture.py | 70 +++++++++++++------ tst/unit/task_step/traffic_capture_test.py | 66 ++++++++++++++++- 2 files changed, 112 insertions(+), 24 deletions(-) diff --git a/src/agent_env/task_step/task_steps/traffic_capture.py b/src/agent_env/task_step/task_steps/traffic_capture.py index ae9a2fff1..072192990 100644 --- a/src/agent_env/task_step/task_steps/traffic_capture.py +++ b/src/agent_env/task_step/task_steps/traffic_capture.py @@ -6,6 +6,8 @@ isn't lost with its VM, and saves what it recorded to the run's stored instance.""" from __future__ import annotations +import asyncio +import copy import functools import logging import os @@ -39,6 +41,7 @@ #: ``context.metadata[TRAFFIC_KEY][env_id]``: each env whose captures a run collected, or tried to. TRAFFIC_KEY = "traffic_captures" _ARTIFACT_TYPE = "traffic_captures" +_SAVING: set[asyncio.Future] = set() _ERROR_CHARS = 500 @@ -100,11 +103,31 @@ async def collect_uncollected_captures(context: TaskStepContext, envs: Sequence[ if captures_traffic(deployed) and (entries.get(key) or {}).get("state") != "collected": tried = True await collect_captures(context, deployed, key=key) - finally: - if tried and context.instance_id and TRAFFIC_KEY in context.metadata: - from agent_env.task.store import record_context_metadata + except BaseException: + if tried: + _save_in_background(context) + raise + if tried: + await _save(context) + + +async def _save(context: TaskStepContext) -> None: + """Save the run's ``traffic_captures`` entries to its stored instance on a worker thread, which finishes the + write even when teardown's time runs out while it waits.""" + if context.instance_id and TRAFFIC_KEY in context.metadata: + from agent_env.task.store import record_context_metadata - record_context_metadata(context.instance_id, TRAFFIC_KEY, context.metadata[TRAFFIC_KEY]) + await finish_on_thread(functools.partial(record_context_metadata, context.instance_id, TRAFFIC_KEY, + copy.deepcopy(context.metadata[TRAFFIC_KEY])), + f"Saving the traffic captures of task instance {context.instance_id}") + + +def _save_in_background(context: TaskStepContext) -> None: + """Start ``_save`` without waiting for it, as a collection teardown's time ran out on does, so the entries + collected by then are saved while teardown goes on to terminate the sandboxes.""" + saving = asyncio.ensure_future(_save(context)) + _SAVING.add(saving) + saving.add_done_callback(_SAVING.discard) def rolled_back_deployments(context: TaskStepContext) -> list[tuple[str, DeployedEnv]]: @@ -122,9 +145,9 @@ async def collect_captures(context: TaskStepContext, deployed: DeployedEnv, *, k """Store each capture ``deployed`` serves as a pcap FileArtifact, register them as one FileArtifactUniverse, and record it in ``context.metadata[TRAFFIC_KEY][key]``, by default its env_id. Each file is named for its container and its place among that container's files, oldest first, so they read in the order their packets were captured. - Never raises: the entry, which it also returns, says what happened. A collection that stops partway, failed or - cancelled, still registers the files it stored by then and records them as ``partial``, which teardown collects - again.""" + Never raises: the entry, which it also returns, says what happened. Each file stored is registered in the universe + as it lands, every store write on a worker thread, so a collection that stops partway, failed or cancelled, keeps + the files it stored by then recorded as ``partial``, which teardown collects again.""" key = key or deployed.env_id entries = context.metadata.setdefault(TRAFFIC_KEY, {}) try: @@ -147,7 +170,7 @@ async def _store(context: TaskStepContext, deployed: DeployedEnv, captures: list get_config().check_local_run_write(universe_id) store = get_artifact_store() prefix = store.attempt_prefix(_ARTIFACT_TYPE, universe_id) - files, total, seen = {}, 0, {} + files, total, seen, artifact = {}, 0, {}, None try: for capture in captures: container = capture.container @@ -164,26 +187,31 @@ async def _store(context: TaskStepContext, deployed: DeployedEnv, captures: list url = await finish_on_thread(functools.partial(_upload, universe_id, prefix + filename, path), f"Uploading traffic capture {filename} of env {deployed.env_id!r}", if_never_run=functools.partial(_remove, path)) - files[filename] = store.put_document(FileArtifact( + document = FileArtifact( id=derive_id(universe_id, filename), filename=filename, content_type=PCAP_MEDIA_TYPE, object_url=url, - description=f"Traffic capture of {container} in env {deployed.env_id!r}, task instance {context.instance_id}")) + description=f"Traffic capture of {container} in env {deployed.env_id!r}, task instance {context.instance_id}") + files[filename] = await finish_on_thread(functools.partial(store.put_document, document), + f"Storing traffic capture {filename} of env {deployed.env_id!r}") + artifact = await finish_on_thread(functools.partial(_register, universe_id, dict(files)), + f"Registering the traffic captures of env {deployed.env_id!r}") + context.metadata[TRAFFIC_KEY][key] = _entry("partial", artifact, files, total, seen) except BaseException as e: - if files: - context.metadata[TRAFFIC_KEY][key] = {**_entry(universe_id, files, total, seen), "state": "partial", + if artifact is not None: + context.metadata[TRAFFIC_KEY][key] = {**_entry("partial", artifact, files, total, seen), "error": _failed(e)["error"]} raise logger.info("collect_traffic_capture %s: %d capture files, %d bytes", deployed.env_id, len(files), total) - return _entry(universe_id, files, total, seen) + return _entry("collected", artifact, files, total, seen) + + +def _register(universe_id: str, files: dict) -> dict: + """Register ``files`` as the next version of the FileArtifactUniverse ``universe_id``.""" + universe = FileArtifactUniverse.put(id=universe_id, file_artifacts=files) + return {"id": universe.id, "version": universe.version} -def _entry(universe_id: str, files: dict, total: int, seen: dict) -> dict: - """The ``collected`` entry of ``files``, registered as one FileArtifactUniverse when there are any.""" - artifact = None - if files: - universe = FileArtifactUniverse.put(id=universe_id, file_artifacts=files) - artifact = {"id": universe.id, "version": universe.version} - return {"state": "collected", "artifact": artifact, "files": sorted(files), "bytes": total, - "containers": sorted(seen)} +def _entry(state: str, artifact: Optional[dict], files: dict, total: int, seen: dict) -> dict: + return {"state": state, "artifact": artifact, "files": sorted(files), "bytes": total, "containers": sorted(seen)} def _upload(universe_id: str, object_url: str, path: str) -> str: diff --git a/tst/unit/task_step/traffic_capture_test.py b/tst/unit/task_step/traffic_capture_test.py index c5aad7190..025437f60 100644 --- a/tst/unit/task_step/traffic_capture_test.py +++ b/tst/unit/task_step/traffic_capture_test.py @@ -8,12 +8,13 @@ import asyncio import os import threading +import time from unittest.mock import AsyncMock, patch import httpx import pytest -from agent_env.artifact import Artifact +from agent_env.artifact import Artifact, FileArtifact from agent_env.artifact.store import get_artifact_store from agent_env.env.env import DeployedGatewayEnv from agent_env.env.gateway import gateway as gw_mod @@ -217,6 +218,16 @@ async def test_what_teardown_collects_is_saved_to_the_runs_stored_instance(gatew assert stored == context.metadata[TRAFFIC_KEY] and stored["notes-env"]["state"] == "collected" +async def _saved(instance_id: str, timeout_s: float = 10.0) -> dict: + """The run's saved ``traffic_captures`` entries, once a save teardown left running has landed.""" + deadline = asyncio.get_running_loop().time() + timeout_s + while True: + stored = find_task_instance(instance_id).context.get("metadata", {}).get(TRAFFIC_KEY) + if stored is not None or asyncio.get_running_loop().time() > deadline: + return stored + await asyncio.sleep(0.05) + + @pytest.mark.asyncio async def test_what_teardown_collected_before_its_time_ran_out_is_still_saved_to_the_run(gateway, monkeypatch): instance = get_task_instance_store().create_instance("task-1", 1, total_steps=1) @@ -234,7 +245,7 @@ async def collect(context, deployed, *, key=None): async with asyncio.timeout(1): await collect_uncollected_captures(context, context.deployed_envs) - stored = find_task_instance(instance.instance_id).context["metadata"][TRAFFIC_KEY] + stored = await _saved(instance.instance_id) assert stored["notes-env"]["state"] == "collected" and "slow-env" not in stored @@ -260,13 +271,62 @@ async def fetch(*args, **kwargs): assert entry["state"] == "partial" and entry["files"] == ["notes-website-backend-00.pcap"] universe = Artifact.get(entry["artifact"]["id"], version=entry["artifact"]["version"]) assert sorted(universe.get_file_artifacts()) == ["notes-website-backend-00.pcap"] - assert find_task_instance(instance.instance_id).context["metadata"][TRAFFIC_KEY]["notes-env"] == entry + assert (await _saved(instance.instance_id))["notes-env"] == entry monkeypatch.setattr(traffic_capture_module, "fetch_traffic_capture", real) await collect_uncollected_captures(context, context.deployed_envs) assert context.metadata[TRAFFIC_KEY]["notes-env"]["state"] == "collected" +@pytest.mark.asyncio +async def test_a_slow_document_store_doesnt_hold_teardowns_time_and_what_was_stored_still_lands(gateway, monkeypatch): + instance = get_task_instance_store().create_instance("task-1", 1, total_steps=1) + context = _context(_deployed()) + context.instance_id = instance.instance_id + store = get_artifact_store() + real, stored = store.put_document, [] + + def slow_put(document): + if isinstance(document, FileArtifact): + if stored: + time.sleep(2) + stored.append(document.id) + return real(document) + + monkeypatch.setattr(store, "put_document", slow_put) + started = time.monotonic() + with pytest.raises(TimeoutError): + async with asyncio.timeout(0.5): + await collect_uncollected_captures(context, context.deployed_envs) + + assert time.monotonic() - started < 1.5 + entry = (await _saved(instance.instance_id))["notes-env"] + assert entry["state"] == "partial" and entry["files"] == ["notes-website-backend-00.pcap"] + + +@pytest.mark.asyncio +async def test_a_slow_run_save_doesnt_hold_teardowns_time_and_still_lands(gateway, monkeypatch): + from agent_env.task import store as task_store + + instance = get_task_instance_store().create_instance("task-1", 1, total_steps=1) + context = _context(_deployed()) + context.instance_id = instance.instance_id + real = task_store.record_context_metadata + + def slow_record(*args): + time.sleep(2) + real(*args) + + monkeypatch.setattr(task_store, "record_context_metadata", slow_record) + started = time.monotonic() + with pytest.raises(TimeoutError): + async with asyncio.timeout(1): + await collect_uncollected_captures(context, context.deployed_envs) + + assert time.monotonic() - started < 2 + assert (await _saved(instance.instance_id))["notes-env"]["state"] == "collected" + + @pytest.mark.asyncio async def test_tear_down_collects_a_retrys_rolled_back_deployment_under_its_own_key_before_its_vm_goes(gateway): rolled_back, live = _deployed(sandbox_id="vm-1"), _deployed(sandbox_id="vm-2") From ffa4b7b1282f3d48098cb2c2e41b83b1d4b92eeb Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 21:22:59 -0800 Subject: [PATCH 57/57] docs(website): traffic capture records what a website's containers send from just after each starts, not before (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/agent_env/cli/env/deploy.py | 4 ++-- src/agent_env/env/gateway/constants.py | 2 +- .../providers/env_providers/env_gateway_provider.py | 9 +++++---- src/agent_env/task_step/task_steps/traffic_capture.py | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d1971eb4d..5f7c4e8e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,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, ready when its card's `website/v1` `ready` answers a `WebsiteReadiness` that says so, as `agentenv_protocol.website` (`website_readiness`, `env_readiness`) defines a website env's health for the deploy and `DeployedEnv.readiness()` alike, or, for a card without `ready`, when its `health` path answers 200, which is deprecated; the gateway container's `env/gateway/constants.py` mirrors the URI and op, a test keeping them equal; 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.]` (`env/envs/computer/utils/install.py`): files, apt `packages` with extra `sources`, a `python` requirements file for a venv, `downloads` by URL and sha256, `run` scripts, the services the base's init runs, and `ports`; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch on a builder of the put's sandbox provider, by default the env's (`utils/builder.py`; `--sandbox` on `env build`, `bundle register` and `run`): this machine's Docker for `local`, else a VM of the provider, which fetches the put's stored files by signed URL, takes the rest inline and pushes to the image store itself, so it builds only with stores a remote VM reaches; each arch goes to the first link that runs it natively, else one that emulates it (a VM registers QEMU; `--no-emulate` leaves it `skipped` instead, for a later put), and its pair records the provider that built it (`builder`); a run or register that reuses a computer env composes what it has left (`pending`), a run only the forms its sandbox's deploys boot (`booted_forms`: on each link, the first form it boots natively, by the deploy's own rule, `boot.first_native`; a compose of a disk adds its container), and a deploy composes first what it boots that no compose started (`ComputerEnv.compose_unstarted`); tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile, build context and lock so any env with the same inputs reuses it; every layer's packages, requirements and downloads are resolved at put in a container of the base for each arch (`utils/packages.py`, `utils/resolve_in_base.py`), stored in the object store by sha256 under `agentenv-packages/`, and listed in a lock stored by a digest of its inputs, which any env with the same inputs reuses, and builds install only the lock's files, every install step `RUN --network=none`; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 (`utils/probing.py`) then boots each native build on the put's sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`, which declares each OS with its own decorator (`@linux(...)`, or `@install(os_type, spec)` for any OS type) holding its install, its launch and, optionally, a skill and settings and build settings that add to the class's there, and install steps and ready checks for every OS or those their `os=` names; everything that reads its settings, launch or steps asks for the computer's OS type; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference, over its env.toml's `[settings]` and a computer env's `settings` beside it in `apps`, each by top-level field (`params.app_settings`; checked at put, a required one left to the deploy; the deploy record keeps the merged values as `app_settings`, secrets withheld); its env.toml's `build_settings`, values of its `BuildSettings` checked at put, are written to `.agentenv/build_settings.json` in its folder, so they're part of the image's key, its install steps read them, and a deploy can't change them). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `env/gateway/gateway.py` is the env-side gateway server, container-only code. 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. A deploy with `capture_traffic` (`deploy_env`'s, `env deploy --capture-traffic`; a website env, or a multi env with a website, on a VM, refused for any other env and in container mode) records every packet each website's server and frontend containers send and receive: beside each runs a capture service, tcpdump from the gateway's image in that container's network namespace (`network_mode: "service:"`), the only service holding `NET_RAW` and `NET_ADMIN`, rotating its classic pcap files (10 of 20 MB each, the oldest overwritten) onto a volume no website container mounts and the gateway mounts read-only and serves as `urn:agentenv:traffic/v1` (`list`, `GET /traffic/captures`, oldest first; `get`, `GET /traffic/capture?name=`), which `agentenv_protocol.traffic` defines (`TrafficCapture`, `list_traffic_captures`, `fetch_traffic_capture`), the gateway's `constants.py` mirroring its names, a test keeping them equal. Those routes take the per-deploy control token the record keeps (`DeployedEnv.control_token`, the gateway's `AGENTENV_CONTROL_TOKEN`), which the gateway takes off every request before it proxies one, so no site reads it; a capture that isn't running once the gateway is up refuses the deploy. 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, ready when its card's `website/v1` `ready` answers a `WebsiteReadiness` that says so, as `agentenv_protocol.website` (`website_readiness`, `env_readiness`) defines a website env's health for the deploy and `DeployedEnv.readiness()` alike, or, for a card without `ready`, when its `health` path answers 200, which is deprecated; the gateway container's `env/gateway/constants.py` mirrors the URI and op, a test keeping them equal; 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.]` (`env/envs/computer/utils/install.py`): files, apt `packages` with extra `sources`, a `python` requirements file for a venv, `downloads` by URL and sha256, `run` scripts, the services the base's init runs, and `ports`; it deploys only as a computer env's driver), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch on a builder of the put's sandbox provider, by default the env's (`utils/builder.py`; `--sandbox` on `env build`, `bundle register` and `run`): this machine's Docker for `local`, else a VM of the provider, which fetches the put's stored files by signed URL, takes the rest inline and pushes to the image store itself, so it builds only with stores a remote VM reaches; each arch goes to the first link that runs it natively, else one that emulates it (a VM registers QEMU; `--no-emulate` leaves it `skipped` instead, for a later put), and its pair records the provider that built it (`builder`); a run or register that reuses a computer env composes what it has left (`pending`), a run only the forms its sandbox's deploys boot (`booted_forms`: on each link, the first form it boots natively, by the deploy's own rule, `boot.first_native`; a compose of a disk adds its container), and a deploy composes first what it boots that no compose started (`ComputerEnv.compose_unstarted`); tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile, build context and lock so any env with the same inputs reuses it; every layer's packages, requirements and downloads are resolved at put in a container of the base for each arch (`utils/packages.py`, `utils/resolve_in_base.py`), stored in the object store by sha256 under `agentenv-packages/`, and listed in a lock stored by a digest of its inputs, which any env with the same inputs reuses, and builds install only the lock's files, every install step `RUN --network=none`; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; gate 3 (`utils/probing.py`) then boots each native build on the put's sandbox provider and probes it at `full`, making it `ready` or `failed`, and a build no provider runs natively stays `unverified`. Its deploy, through the `computer` env provider (`providers/env_providers/env_computer_provider.py`), boots its first `ready` build on a sandbox provider that runs its arch natively (`native_platforms()`), with no gateway in front and the driver's card as the env card, then checks its `display`; `deploy_env` `env_params` take a `form`, `computer_settings` (deploy-phase values of the base's settings; a secret one, here or on the env, only as a bare `secret:`/`env:` reference) and `driver_settings` (the driver's `ComputerSettings`, such as its default coordinate space); a `network` that allows nothing runs it on local Docker's internal network behind a relay (`providers/sandbox_providers/local_docker/relay/`); `reset` and `close` have the driver end its recordings first (`computer-recording/v1` `end_all`, each uploading what's left and its manifest under the grant it started with), and `reset` then boots a fresh container in its place (`reset_container`), keeping the computer's address but not an agent's MCP session to the old driver; its `load_file_artifact_universe` writes files under the session user's home through the driver's `data/add`, `Downloads` by default, each file's bytes through a read grant when the store's grants reach it and its network allows everything, else inline (`env/envs/computer/utils/inside.py`)), `ComputerAppEnv` (`computer_app`, `env/envs/computer/app_env.py`: an app on a computer, a `ComputerAppEnvironment` class its env.toml's `module` names, read at put by the same `_declare`, which declares each OS with its own decorator (`@linux(...)`, or `@install(os_type, spec)` for any OS type) holding its install, its launch and, optionally, a skill and settings and build settings that add to the class's there, and install steps and ready checks for every OS or those their `os=` names; everything that reads its settings, launch or steps asks for the computer's OS type; deployed alone it is a one-app computer env, a `ComputerEnv` of the computer and driver its env.toml names, the composer installing it under `/opt/agentenv/apps//` with its services as supervisord programs beside its own server (`agentenv_protocol.computer.serve_app`, in a venv with the protocol's `app` extra, on `.agentenv/server.sock` in its folder), which adds its launcher, launches a `boot` app once per boot and says through `computer-app/v1` whether it's ready, as the driver's `computer-ready` asks, its card named after it, and `env_params` `app_settings` handing it values of its `Settings` in `AGENTENV_APP_SETTINGS`, a secret one only as a bare reference, over its env.toml's `[settings]` and a computer env's `settings` beside it in `apps`, each by top-level field (`params.app_settings`; checked at put, a required one left to the deploy; the deploy record keeps the merged values as `app_settings`, secrets withheld); its env.toml's `build_settings`, values of its `BuildSettings` checked at put, are written to `.agentenv/build_settings.json` in its folder, so they're part of the image's key, its install steps read them, and a deploy can't change them). An env type whose services take environment data defines `load_environment_artifact` and `load_environment_universe_artifact`; `load_artifact` refuses an environment artifact aimed at a type that doesn't, at save and before it loads. An env built on the protocol SDK can serve agents a skill (`@agent_skill`, `skill/v1`), which `add_skills` `env_ids` installs under its environment name (behind a gateway, each server's under that server's name); a skill with files beside its SKILL.md goes as a bundle stored once per content under `agentenv-env-skills/` in the object store. `env/gateway/gateway.py` is the env-side gateway server, container-only code. 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. A deploy with `capture_traffic` (`deploy_env`'s, `env deploy --capture-traffic`; a website env, or a multi env with a website, on a VM, refused for any other env and in container mode) records every packet each website's server and frontend containers send and receive from just after each starts (what a server sends while it starts up can come before its capture attaches): beside each runs a capture service, tcpdump from the gateway's image in that container's network namespace (`network_mode: "service:"`), the only service holding `NET_RAW` and `NET_ADMIN`, rotating its classic pcap files (10 of 20 MB each, the oldest overwritten) onto a volume no website container mounts and the gateway mounts read-only and serves as `urn:agentenv:traffic/v1` (`list`, `GET /traffic/captures`, oldest first; `get`, `GET /traffic/capture?name=`), which `agentenv_protocol.traffic` defines (`TrafficCapture`, `list_traffic_captures`, `fetch_traffic_capture`), the gateway's `constants.py` mirroring its names, a test keeping them equal. Those routes take the per-deploy control token the record keeps (`DeployedEnv.control_token`, the gateway's `AGENTENV_CONTROL_TOKEN`), which the gateway takes off every request before it proxies one, so no site reads it; a capture that isn't running once the gateway is up refuses the deploy. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | | `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Every artifact but an environment universe, whose `metadata` key holds its legacy metadata file ids, carries a `metadata` dict outside its versioned fields, changed by compare-and-swap (`merge_metadata`). Built-ins: `CliArtifact` (`cli`), `ComputerArtifact` (`computer`, a base computer: its architectures, OS, session user, settings and container form, whose image `put` reads from its registry into `metadata.validated_images`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`: its environment's files by path, stored as the one wrapped `file_artifact_ref` every environment artifact had when it has one file at its filename, else as `file_artifact_refs`; `get_file_artifacts()` reads either, and a load hands each file to the env's `data/add` with its path, which a pre-v1 load, taking one file, refuses), `EnvironmentUniverseArtifact` (`environment_universe`), `SkillArtifact` (`skill`); `[artifacts] type_aliases` maps another stored spelling of a type onto one of them. A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull, or a published image's tag with its `image_digest` and its build context, which sandboxes pull by the digest and build from the context when they can't (the infra images, and a bundle agent built from a shipped agent's sources, `Plan.published`), or, written by `put_context`, only its build context, which a VM sandbox builds before running it (`VmSandbox.load_docker_images`) and Modal builds before creating its container (`ModalSandboxProvider.prepare_image`, outside a fallback chain's deadline); anything else that runs images by name (a container on the local provider, a lone server on any provider but Modal) refuses such an image. A build context (`utils/docker/context.py`) is what `docker build` would send, `.dockerignore` applied, written so equal files give equal bytes and an equal `source_digest`. | | `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, among them `start_computer_recording` and `end_computer_recording`, `collect_traffic_capture`, which stores each traffic/v1 capture of an env deployed with `capture_traffic` as a pcap `FileArtifact` in one `FileArtifactUniverse` recorded under `context.metadata["traffic_captures"][env_id]`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. A computer's framework-only routes (everything but MCP and the card) take the per-deploy control token its provider boots it with (`DeployedEnv.control_token`, `AGENTENV_CONTROL_TOKEN`); framework calls send `deployed.control_headers`, `Task.run` runs every step under `protocol_v1.authorized` with each deployed env's (a `verify` included), and a `run_code` script inside the computer gets `input["env"]["headers"]`. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each capturing env still up whose traffic the run didn't collect is collected, a retry's rolled-back ones too (kept as `capturing_envs` in its `retry_resets` marker, each under `-rolled-back-`), and what it records saved to the stored instance's `context.metadata`, 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 namespaces, `@local` and `@hf` (`is_local_id`), each with a documents file of its own: while the CLI or a `build_app` server runs, a local id and what a 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/cli/env/deploy.py b/src/agent_env/cli/env/deploy.py index cdd17e6fa..c68103da7 100644 --- a/src/agent_env/cli/env/deploy.py +++ b/src/agent_env/cli/env/deploy.py @@ -52,8 +52,8 @@ help="Draw a role key for the deploy: its gateway then believes a role other than default only from a " "caller that sends the key beside it.") @click.option("--capture-traffic", is_flag=True, default=False, - help="Record every packet the env's websites' containers send and receive, as rotating pcap files its " - "gateway serves as traffic/v1 (VM deploys of website or multi envs only).") + help="Record every packet the env's websites' containers send and receive from just after each starts, as " + "rotating pcap files its gateway serves as traffic/v1 (VM deploys of website or multi envs only).") 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, keyed_roles: bool, capture_traffic: bool): """Deploy an MCP server environment.""" from agent_env.providers import build_sandbox_provider diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index 0af3614a2..03e910a3c 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -280,7 +280,7 @@ def random_mcp_server_name() -> str: ] TRAFFIC_EXTENSION = { "uri": EXT_TRAFFIC_URI, - "description": "Traffic (urn:agentenv:traffic/v1): the packets each website's server and frontend containers sent and received, captured by tcpdump in each container's network namespace as rotating classic pcap files. list names each capture file with the container it holds, its size and when it was last written, oldest first; get streams one as application/vnd.tcpdump.pcap. A container's oldest file is overwritten once its rotation is full. Harness control path: with a control token, each call takes it as its bearer.", + "description": "Traffic (urn:agentenv:traffic/v1): the packets each website's server and frontend containers sent and received, captured by tcpdump in each container's network namespace, from just after the container starts, as rotating classic pcap files. list names each capture file with the container it holds, its size and when it was last written, oldest first; get streams one as application/vnd.tcpdump.pcap. A container's oldest file is overwritten once its rotation is full. Harness control path: with a control token, each call takes it as its bearer.", "params": {"endpoint": "/traffic/captures", "format": "pcap", "methods": { TRAFFIC_LIST_OP: {"method": "GET", "endpoint": "/traffic/captures", "request": {"type": "object", "properties": {}}}, TRAFFIC_GET_OP: {"method": "GET", "endpoint": "/traffic/capture", "request": {"type": "object", "properties": { 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 7a877706c..ee7aa6f28 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -751,8 +751,8 @@ async def create_gateway( the record: the gateway then believes a role other than ``default`` only beside it, so every caller acting as one must send it. Off, as before the key, the role header alone is believed. - ``capture_traffic`` records every packet each website's server and frontend containers send and receive, with - tcpdump in a capture service beside each, as rotating pcap files the gateway serves as traffic/v1, behind a + ``capture_traffic`` records every packet each website's server and frontend containers send and receive once + its capture attaches, just after the container starts, with tcpdump in a capture service beside each, as rotating pcap files the gateway serves as traffic/v1, behind a control token the deploy draws for it and keeps on the record (``control_token``). VM deploys only, since only they serve websites; off, the stack is as before it. """ @@ -1668,8 +1668,9 @@ def _traffic_capture_service(gateway_image: str, container: str) -> list[str]: interface of the container's, its loopback among them, every packet whole and written as it's captured, rotating through ``TRAFFIC_CAPTURE_FILES`` files of ``TRAFFIC_CAPTURE_FILE_MB`` million bytes named for the container, its oldest overwritten once they're full, so a long run holds at most their product per container on the VM's disk. It joins - the container's network namespace, so it sees what the container sends and receives and nothing else, and is the - only service given the capabilities a capture needs. It writes to ``TRAFFIC_VOLUME``, which the gateway mounts + the container's network namespace, so it sees what the container sends and receives and nothing else, from when it + attaches, which is just after the container starts: what a server sends while it starts up can come before it, and + isn't recorded. It is the only service given the capabilities a capture needs. It writes to ``TRAFFIC_VOLUME``, which the gateway mounts read-only and no website container mounts at all.""" argv = ["-i", "any", "-n", "-s", "0", "-U", "-Z", "root", "-C", str(TRAFFIC_CAPTURE_FILE_MB), "-W", str(TRAFFIC_CAPTURE_FILES), "-w", f"{TRAFFIC_SIDECAR_DIR}/{container}.pcap"] diff --git a/src/agent_env/task_step/task_steps/traffic_capture.py b/src/agent_env/task_step/task_steps/traffic_capture.py index 072192990..f6dd48cd3 100644 --- a/src/agent_env/task_step/task_steps/traffic_capture.py +++ b/src/agent_env/task_step/task_steps/traffic_capture.py @@ -1,5 +1,5 @@ """Collect a website env's traffic captures. A website or multi env deployed with ``capture_traffic`` records every -packet its websites' containers send and receive as rotating pcap files, which its gateway's card serves as traffic/v1 +packet its websites' containers send and receive, from just after each starts, as rotating pcap files, which its gateway's card serves as traffic/v1 behind the deploy's control token. ``collect_traffic_capture`` stores each as a FileArtifact and registers them as one FileArtifactUniverse, recorded in ``context.metadata[TRAFFIC_KEY][env_id]``. Teardown collects any capturing env a run didn't, a retry's rolled-back ones included, before the env closes (``collect_uncollected_captures``), so a capture