diff --git a/AGENTS.md b/AGENTS.md index aa980e2e8..a8b4d6a4d 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, 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`). | +| `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`). | | `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/README.md b/packages/agentenv-protocol/README.md index 4e3b0de48..b9aeb8a91 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -430,6 +430,37 @@ 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, +`problems`, what keeps it from being ready, and optionally `waited_s` since its server started and the `timeout_s` it +may take (`SITE_READY_TIMEOUT_S` when it says nothing), which is how long a deploy waits for it. A +`WebsiteEnvironment` answers from its `@ready_check` methods, run at once, each within `CHECK_TIMEOUT_S` and a plain +`def` in a worker thread, and from `@website(ready_timeout_s=...)`; it serves no separate health path. The model is +strict about `ready` and `problems` (`"yes"` is no `ready`) and ignores fields it doesn't know, so a reader keeps +reading a newer site. A server whose card has no `ready` is still read at its health path (`DEFAULT_HEALTH_PATH` +unless it names one), which is deprecated. 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..c6bcb29fa --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py @@ -0,0 +1,41 @@ +"""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 with is its data plane's ``data/get``.""" + +from __future__ import annotations + +from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, ClockUnavailable, UniverseClock +from .environment import ( + SESSION_PATH, + WebsiteDeclaration, + WebsiteEnvironment, + host_problem, + ready_check, + website, +) +from .errors import ApiError, BadRequest, Conflict, Forbidden, NotFound, Unauthorized +from .readiness import ( + DEFAULT_HEALTH_PATH, + READY_TIMEOUT_S, + SITE_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, SESSION_SECRET_ENV, SessionSigner + +__all__ = [ + "WebsiteEnvironment", "WebsiteDeclaration", "website", "api", "ApiContext", "ApiRoute", "ready_check", "host_problem", + "ApiError", "BadRequest", "Unauthorized", "Forbidden", "NotFound", "Conflict", + "UniverseClock", "ClockUnavailable", "SessionSigner", "reserved_path", + "WEBSITE_EXTENSION_URI", "CLOCK_EXTENSION_URI", "UNIVERSE_NOW_HEADER", "DEFAULT_HEALTH_PATH", "SESSION_PATH", + "SESSION_COOKIE", "SESSION_SECRET_ENV", "ROLE_KEY_ENV", "ROLE_KEY_HEADER", + "WEBSITE_READY_OP", "READY_TIMEOUT_S", "SITE_READY_TIMEOUT_S", "WebsiteReadiness", + "website_readiness", "env_readiness", +] 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..949962478 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/clock.py @@ -0,0 +1,122 @@ +"""The universe's clock as a site reads it, the way MCP servers read it: ``clock/v1``'s ``sync_time`` hands the server +the gateway's ``/clock/time`` URL, and each request then reads that clock's ``{"virtual_time": }`` once, +the one instant everything the request does reads (``now()``) and its answer carries as ``X-Universe-Now``, RFC3339 in +UTC. An unsynced site reads wall time, and so does a synced one whose gateway clock isn't armed (404); one whose clock +can't be read raises ``ClockUnavailable``. ``sync_time`` with an empty URL goes back to wall time. ``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 contextvars +import datetime +import json +import re + +import httpx + +CLOCK_EXTENSION_URI = "urn:agentenv:clock/v1" +UNIVERSE_NOW_HEADER = "X-Universe-Now" +READ_TIMEOUT = httpx.Timeout(connect=0.5, read=1.0, write=1.0, pool=0.5) +READ_ATTEMPTS = 2 + +_FRACTION = re.compile(r"(\.\d+)(?=[+-]\d\d:?\d\d$|Z$|$)") +_request_now: contextvars.ContextVar[datetime.datetime | None] = contextvars.ContextVar("universe_now", default=None) + + +class ClockUnavailable(Exception): + """The synced clock couldn't be read: unreachable, a 5xx after a retry, or an answer that isn't a time.""" + + +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: + """An RFC3339 time, its fraction of a second of any length (Python 3.10 parses only 3 or 6 digits).""" + value = _FRACTION.sub(lambda m: m.group(1)[:7].ljust(7, "0"), value.replace("Z", "+00:00")) + moment = datetime.datetime.fromisoformat(value) + return moment if moment.tzinfo else moment.replace(tzinfo=datetime.timezone.utc) + + +def _wall() -> datetime.datetime: + return datetime.datetime.now(datetime.timezone.utc) + + +class UniverseClock: + """The time a site answers with: wall time until ``sync`` names a gateway clock, that clock's, read per request, + after.""" + + def __init__(self) -> None: + self.url: str | None = None + self.timezone: str | None = None + self._last: datetime.datetime | None = None + + @property + def synced(self) -> bool: + return self.url is not None + + def now(self) -> datetime.datetime: + """The instant the request being answered read, else the last one read, else wall time.""" + return _request_now.get() or self._last or _wall() + + async def read(self) -> datetime.datetime: + """The universe's time now: the synced clock's, wall time when none is synced or it isn't armed; raises + ``ClockUnavailable`` when it can't be read.""" + if self.url is None: + return _wall() + self._last = await _read(self.url) or _wall() + return self._last + + def answering_at(self, moment: datetime.datetime) -> contextvars.Token: + """Make ``moment`` what ``now()`` reads while the current request is answered.""" + return _request_now.set(moment) + + def answered(self, token: contextvars.Token) -> None: + _request_now.reset(token) + + 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), or wall time again + for an empty ``url``; raises ``ClockUnavailable`` when the clock can't be read, keeping the clock and zone it + followed.""" + if not url: + self.url, self.timezone, self._last = None, None, None + return _wall() + virtual = await _read(url) or _wall() + self.url, self.timezone, self._last = url, timezone, virtual + return virtual + + +async def _read(url: str) -> datetime.datetime | None: + """The clock at ``url``'s time, None when it isn't armed (404); a transport error or a 5xx is tried again once.""" + failure: Exception | None = None + for _attempt in range(READ_ATTEMPTS): + try: + async with httpx.AsyncClient(timeout=READ_TIMEOUT) as client: + response = await client.get(url) + except httpx.TransportError as e: + failure = e + continue + if response.status_code == 404: + return None + if response.status_code >= 500: + failure = httpx.HTTPStatusError(f"{response.status_code}", request=response.request, response=response) + continue + try: + response.raise_for_status() + return parse_time(response.json()["virtual_time"]) + except (httpx.HTTPStatusError, ValueError, KeyError, TypeError) as e: + raise ClockUnavailable(f"the universe's clock at {url} answered {response.status_code}: {e}") from e + raise ClockUnavailable(f"the universe's clock at {url} can't be read: {type(failure).__name__}: {failure}") + + +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..9f6c7debc --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -0,0 +1,358 @@ +"""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 (a warning +doesn't), 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, and the cookies those answers set: +none tied to a domain, and a warning for one whose lifetime is an absolute ``Expires`` with no ``Max-Age``. + +Every request goes to the server under check: a card's path or URL naming another scheme, host or port fails the check +that would have used it, with nothing sent there. 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 ends by syncing the server back to wall time (an +empty ``env_get_time_url``), since the checker's clock is gone once the check ends. That 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 .readiness import DEFAULT_HEALTH_PATH, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness + +Status = Literal["passed", "failed", "skipped", "warned"] +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._url(path), **kwargs)) + + def _url(self, where: str) -> str: + """Where ``where``, a path or a full URL a card names, is; raises ``OffServer`` for one on another server.""" + url = where if "://" in where else self.base_url + (where if where.startswith("/") else "/" + where) + if _origin(url) != _origin(self.base_url): + raise OffServer(f"the card points outside the server under check, at {url}") + return url + + 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 = {} + 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)), ("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, card, site), *(Result(name, *outcomes[name]) for name, _step in steps)] + + def _card(self, response: httpx.Response, card: dict, 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 find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP) is None and not ( + isinstance(site.get("health"), str) and site["health"].startswith("/")): + problems.append(f"it has no {WEBSITE_READY_OP}, and its params 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") + response = self._seen(self.client.request(ready.get("method", "GET"), self._url(ready["endpoint"]))) + try: + answer = response.json() + except ValueError: + return "failed", f"website/v1 ready answered {response.status_code} with no JSON: {response.text[:200]!r}" + 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 not response.is_success: + return "failed", (f"website/v1 ready answered {response.status_code}: " + f"{', '.join(readiness.problems) or 'no problem named'}") + 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)}" + url = card.get("url") + self.rpc_url = self._url(url if isinstance(url, str) and url else RPC_PATH) + exported = self._rpc(METHOD_GET).get("parts") or [] + 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 _clock(self, card: dict) -> tuple[Status, str]: + if find_extension(card, CLOCK_EXTENSION_URI) is None: + return "failed", f"the card declares no {CLOCK_EXTENSION_URI}" + method = find_extension_method(card, CLOCK_EXTENSION_URI, "sync_time") + if method is None or not method.get("endpoint"): + return "failed", f"{CLOCK_EXTENSION_URI} advertises no sync_time endpoint" + endpoint = self._url(method["endpoint"]) + with _clock_server(self.clock_host) as (url, virtual_now): + response = self._seen(self.client.request(method["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]}" + try: + seen = self.get("/").headers.get(UNIVERSE_NOW_HEADER) + expected = virtual_now() + finally: + with contextlib.suppress(Exception): + self.client.request(method["method"], endpoint, json={"env_get_time_url": ""}) + 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 = sorted({c.split("=", 1)[0] for c in cookies if "domain" in _attributes(c)}) + if scoped: + return "failed", f"cookies tied to a domain: {', '.join(scoped)}" + absolute = sorted({c.split("=", 1)[0] for c in cookies if "expires" in _attributes(c) and "max-age" not in _attributes(c)}) + if absolute: + return "warned", (f"cookies with an absolute Expires and no Max-Age, which a browser on another clock " + f"misjudges: {', '.join(absolute)}") + return "passed", f"{len(cookies)} cookie(s), none tied to a domain" + + +def _attributes(set_cookie: str) -> set[str]: + """The attribute names of a Set-Cookie header, lowercased.""" + return {a.split("=", 1)[0].strip().lower() for a in set_cookie.split(";")[1:]} + + +class OffServer(ValueError): + """A card named a path or URL on a server other than the one under check.""" + + +def _origin(url: str) -> tuple[str, str, int | None]: + """A URL's scheme, host and port, the port its scheme's default when it names none.""" + parsed = httpx.URL(url) + return parsed.scheme, parsed.host, parsed.port or {"http": 80, "https": 443}.get(parsed.scheme) + + +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": "----", "warned": "WARN"} + + +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, warned 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..d836dffe7 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py @@ -0,0 +1,432 @@ +"""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, and the data plane and card the base class serves, the card's ``website/v1`` +``ready`` saying whether the site is usable yet by its ``@ready_check`` methods. 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, read once per request as MCP servers read it. A site keeps its data where it likes, +usually its schema of the env's database (``database_url``), and serves it through the data plane's hooks +(``@reset_data``, ``@add_data``, ``@get_data``) as any AgentEnvEnvironment does: ``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 asyncio +import inspect +import math +import os +import re +import time +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 ( + AgentEnvEnvironment, + AgentEnvStarletteApplication, + _marked_members, + _maybe_await, + extension, +) +from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, ClockUnavailable, UniverseClock, clock_patch_script, iso +from .errors import ApiError, NotFound +from .readiness import SITE_READY_TIMEOUT_S, 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 + +SESSION_PATH = "/api/session" +CHECK_TIMEOUT_S = 10.0 +_WEBSITE_ATTR = "_aee_website" +_READY_ATTR = "_aee_website_ready" +_HEAD = re.compile(rb"]*)?>", re.IGNORECASE) +_PREAMBLE = re.compile(rb"\A(?:\s||]*>|]*)?>)*", re.IGNORECASE | re.DOTALL) +_HOST = re.compile(r"(\*\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*") + + +class WebsiteDeclaration(NamedTuple): + """What ``@website`` declares: the site's domains, its static folder (relative to the class's file), how long it + may take to be ready after its server starts, and whether its HTML pages read the universe's time.""" + + domains: tuple[str, ...] = () + static: Optional[str] = None + ready_timeout_s: float = SITE_READY_TIMEOUT_S + clock_patch: bool = False + + +def website(_cls: type | None = None, *, domains: list[str] | tuple[str, ...] = (), static: str | None = None, + ready_timeout_s: float = SITE_READY_TIMEOUT_S, clock_patch: bool = False) -> Any: + """Declare a WebsiteEnvironment's site.""" + if _cls is not None: + raise TypeError("use @website(...) with parentheses") + if isinstance(domains, str): + raise ValueError(f"domains is a list of host names, not the string {domains!r}") + if problems := [problem for host in domains if (problem := host_problem(host))]: + raise ValueError(f"domains: {problems[0]}") + if isinstance(ready_timeout_s, bool) or not isinstance(ready_timeout_s, (int, float)) or not ( + 0 < ready_timeout_s < math.inf): + raise ValueError(f"ready_timeout_s is a number of seconds above 0, not {ready_timeout_s!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, float(ready_timeout_s), clock_patch)) + return cls + + return deco + + +def host_problem(host: Any) -> str | None: + """Why ``host`` isn't a host name a site is reached at, None when it is: a name like ``github.com``, or + ``*.github.com`` for its subdomains, with no scheme, port or path.""" + if not isinstance(host, str) or not _HOST.fullmatch(host): + return f"{host!r} is not a host name such as example.com or *.example.com" + return None + + +def ready_check(fn: Any) -> Any: + """Mark a method that says whether the site is usable yet: ``website/v1``'s ``ready`` says ready once every one + returns a truthy value. They run at once, each within ``CHECK_TIMEOUT_S``, a plain ``def`` in a worker thread.""" + 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) + + def __init__(self) -> None: + self.clock = UniverseClock() + self._signer = SessionSigner() + self._started = time.monotonic() + self._checking: dict[str, asyncio.Future] = {} + + @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") + + def now(self) -> Any: + """The universe's time, an aware UTC datetime: the instant the request being answered read from 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: nobody unless a site says else, from its data say.""" + return 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() + 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) + if SESSION_PATH not in taken: + asgi.router.routes.append(Route(SESSION_PATH, self._session, methods=["GET", "DELETE"])) + 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), + "routes": [r.advertised() for r in self.routes()], "session_cookie": SESSION_COOKIE, + "session_path": SESSION_PATH} + return super().extension_params(uri) + + @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; an empty ``env_get_time_url`` goes back to wall time.""" + 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 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 storage state a browser adds " + "to start signed in as user, or signed out without one: {cookies, local_storage}, each cookie's lifetime a " + "max_age.")) + 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`` says + what each not yet true returned, raised or how long it ran, and ``waited_s`` how long since the server started, + against the ``ready_timeout_s`` it declared. A deploy reads it through the card.""" + problems = await self._not_ready() + return WebsiteReadiness(ready=not problems, problems=problems, waited_s=round(time.monotonic() - self._started, 1), + timeout_s=self.declaration().ready_timeout_s).model_dump() + + @extension(WEBSITE_EXTENSION_URI, op="session") + async def _website_session(self, user: Optional[str] = None) -> dict: + return {"cookies": [self._session_signer().cookie(user)], "local_storage": []} + + async def _not_ready(self) -> list[str]: + found = await asyncio.gather(*(self._checked(check) for check, _marker in _marked_members(self, _READY_ATTR))) + return [problem for problem in found if problem is not None] + + async def _checked(self, check: Any) -> Optional[str]: + """What ``check`` finds wrong, within ``CHECK_TIMEOUT_S``; one that runs past it or raises says so.""" + name = f"@ready_check {check.__name__}" + try: + passed = await asyncio.wait_for(self._run_check(check), CHECK_TIMEOUT_S) + except asyncio.TimeoutError: + return f"{name} ran past {CHECK_TIMEOUT_S:g} s" + except Exception as e: + return f"{name}: {type(e).__name__}: {e}" + return None if passed else f"{name} returned {passed!r}" + + async def _run_check(self, check: Any) -> Any: + """A plain ``def`` check runs in a worker thread, so it can't hold up the site's answers, and once at a time: + a thread can't be stopped, so a poll while one runs past its bound waits on that same call.""" + if inspect.iscoroutinefunction(check): + return await check() + running = self._checking.get(check.__name__) + if running is None or running.done(): + running = self._checking[check.__name__] = asyncio.ensure_future(asyncio.to_thread(check)) + running.add_done_callback(lambda done: done.cancelled() or done.exception()) + return await _maybe_await(await asyncio.shield(running)) + + 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 _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 + + +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: + """Reads the universe's clock once per request, the instant ``now()`` reads while it's answered, and stamps it on + the answer as ``X-Universe-Now``; with ``clock_patch`` it puts the clock script in the 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 as it's sent. A request the clock can't be read for answers 503, but the data plane and the card, + which need no time, answer without the header.""" + + HEAD_SCAN_BYTES = 64 * 1024 + + 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() + try: + now: Any = await clock.read() + except ClockUnavailable as e: + if not reserved_path(scope["path"]): + return await error_response(ApiError(str(e), code="clock_unavailable", status=503))(scope, receive, send) + now = None + token = clock.answering_at(now) + try: + await self.app(scope, receive, self._stamping(scope, send, now)) + finally: + clock.answered(token) + + def _stamping(self, scope: dict, send: Any, now: Any) -> Any: + patch = self.env.declaration().clock_patch and scope["method"] != "HEAD" and now is not None + held: dict = {} + + async def stamped(message: dict) -> None: + if message["type"] == "http.response.start": + headers = MutableHeaders(scope=message) + if now is not None: + headers[UNIVERSE_NOW_HEADER] = iso(now) + if (patch and headers.get("content-type", "").startswith("text/html") and "content-encoding" not in headers + and message.get("status") != 206 and "content-range" not in headers): + held.update(start=message, body=b"", script=clock_patch_script(now)) + return + elif held and message["type"] == "http.response.body": + held["body"] += message.get("body", b"") + more = message.get("more_body", False) + if more and not _HEAD.search(held["body"]) and len(held["body"]) < self.HEAD_SCAN_BYTES: + return + start, script = held.pop("start"), held.pop("script") + body = _with_script(held.pop("body"), script) + held.clear() + headers = MutableHeaders(scope=start) + if "content-length" in headers: + headers["content-length"] = str(int(headers["content-length"]) + len(script)) + await send(start) + message = {"type": "http.response.body", "body": body, "more_body": more} + await send(message) + + return stamped + + +def _with_script(html: bytes, script: bytes) -> bytes: + """``html`` with ``script`` after its ````, else after its doctype and ```` tag, so a page without a + head keeps its doctype first.""" + head = _HEAD.search(html) + at = head.end() if head else _PREAMBLE.match(html).end() + return html[:at] + script + html[at:] + + +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: + """The static folder's file at ``path``, a directory's ``index.html``; none under a dot-named segment, such as + ``.env``, or for a path that can't be a file's.""" + if self.root is None or any(part.startswith(".") for part in path.split("/")): + return None + try: + candidate = (self.root / path.lstrip("/")).resolve() + except (OSError, ValueError): + return None + 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..756938d36 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/readiness.py @@ -0,0 +1,81 @@ +"""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. A site may say how long it may take to be ready (``timeout_s``, +``SITE_READY_TIMEOUT_S`` when it says nothing). A server whose card has no ``ready`` is asked at its health path, +``DEFAULT_HEALTH_PATH`` unless it names one, which is deprecated.""" +from __future__ import annotations + +import asyncio +from typing import Optional + +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 +SITE_READY_TIMEOUT_S = 300.0 +DEFAULT_HEALTH_PATH = "/api/health" + + +class WebsiteReadiness(BaseModel): + """What a site says of its readiness, ``website/v1``'s ``ready``: whether it is ``ready``, the ``problems`` that + keep it from being so, each a line a person reads, such as a ``@ready_check`` method's and what it returned, and, + for a site that says, the ``waited_s`` since its server started against the ``timeout_s`` it may take. 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] = [] + waited_s: Optional[float] = None + timeout_s: Optional[float] = None + + +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..7a1e347a7 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py @@ -0,0 +1,377 @@ +"""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. A plain ``def`` method runs in a worker thread, as FastAPI runs one, so a +blocking call in it (a database query, say) holds up only its own request; an ``async def`` one runs on the event +loop.""" +from __future__ import annotations + +import dataclasses +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.concurrency import run_in_threadpool +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, SESSION_TTL_S, 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: + self._set_cookie.append(self._signer.encode(user)) + self.user, self.session = user, {"user": user} + + def apply(self, response: Response) -> Response: + for value in self._set_cookie: + response.set_cookie(SESSION_COOKIE, value, max_age=SESSION_TTL_S, 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``, a plain ``def`` one in a worker thread. + The request's session cookies go on a successful answer only.""" + params = plan(route) + threaded = not inspect.iscoroutinefunction(fn) + + 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}") + kwargs = await bind(params, request, context) + result = await _maybe_await(await run_in_threadpool(fn, **kwargs) if threaded else fn(**kwargs)) + 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 and response.status_code < 400 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"): + listed = _list_type(p.annotation) if p.kind == "query" else None + schema = _schema_for_annotation(p.annotation) if listed is None else { + "type": "array", **({"items": _schema_for_annotation(get_args(listed)[0])} if get_args(listed) else {})} + 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: + """``fn``'s type hints; a ``TypeError`` naming the route when one can't be resolved, as one naming a type imported + only under ``TYPE_CHECKING`` can't, rather than binding every parameter as an unresolved string.""" + try: + return get_type_hints(fn) + except Exception as e: + raise TypeError(f"@api {fn.__qualname__}: its type hints can't be resolved ({type(e).__name__}: {e}); " + "every type its parameters and answer name must be importable when the site runs") from e + + +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..dd068046a --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/session.py @@ -0,0 +1,55 @@ +"""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``, which a deploy sets for each site so its sessions outlive its process, 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, and its lifetime is a ``Max-Age``, never an absolute ``Expires``, so the browser's clock and the site's +needn't agree.""" +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import json +import os +import secrets +import time + +SESSION_COOKIE = "agentenv_session" +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(SESSION_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) -> str: + """The cookie value for ``user`` signed in, or signed out when None, good for ``ttl_s`` seconds.""" + payload = base64.urlsafe_b64encode(json.dumps({"u": user, "exp": int(time.time()) + ttl_s}).encode()) + payload = payload.decode().rstrip("=") + return f"{payload}.{self._sign(payload)}" + + 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 adds to be signed in as ``user``, in browser/v1's shape with ``max_age`` for its + lifetime; its domain is left to whoever adds it.""" + return {"name": SESSION_COOKIE, "value": self.encode(user, ttl_s), "path": "/", "max_age": ttl_s, + "httpOnly": True, "sameSite": "Lax"} + + def _sign(self, payload: str) -> str: + return hmac.new(self._key, payload.encode("utf-8", "surrogateescape"), hashlib.sha256).hexdigest() 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..04818386f --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_api.py @@ -0,0 +1,423 @@ +"""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 + [ids] = client.get("/agentenv/ext/website/openapi").json()["paths"]["/api/picked"]["get"]["parameters"] + assert ids["schema"] == {"type": "array", "items": {"type": "integer"}, "default": None} + + +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"} + answer = client.post("/agentenv", json={"jsonrpc": "2.0", "id": 1, "method": "data/get", "params": {}}).json() + assert answer["jsonrpc"] == "2.0" and answer["id"] == 1 and "captured" not in answer + assert client.get("/.well-known/agent-env.json").json()["name"] == "Wiki" + + +@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() + + +@pytest.mark.parametrize("domains", ["shop.example", ["https://shop.example"], ["shop.example:8080"], ["shop.example/x"], + [""], ["-shop.example"], ["shop..example"], ["*shop.example"], [None]]) +def test_a_sites_domains_are_a_list_of_host_names(domains: object) -> None: + with pytest.raises(ValueError, match="domains"): + website(domains=domains) + + +def test_a_wildcard_domain_names_a_sites_subdomains() -> None: + @website(domains=["shop.example", "*.shop.example", "intranet.localhost"]) + class Shop(WebsiteEnvironment): + pass + + assert Shop.declaration().domains == ("shop.example", "*.shop.example", "intranet.localhost") + + +@pytest.mark.parametrize("timeout", [0, -1, float("inf"), float("nan"), "60", True]) +def test_a_ready_timeout_is_a_number_of_seconds_above_zero(timeout: object) -> None: + with pytest.raises(ValueError, match="ready_timeout_s"): + website(ready_timeout_s=timeout) + + +@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..c475846f0 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_conformance.py @@ -0,0 +1,331 @@ +"""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 +import json +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 import DataPart, add_data, get_data, reset_data +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): + def __init__(self) -> None: + super().__init__() + self.data: dict = {"notes": [{"id": 1, "text": "hello"}]} + + def acting_user(self) -> str | None: + return "ann" + + @reset_data + def reset(self) -> None: + self.data = {} + + @add_data + def add(self, parts: list) -> None: + for part in parts: + self.data.update(part.data) + + @get_data + def export(self) -> list: + return [DataPart(data=self.data)] + + @api.get("/api/notes") + def notes(self) -> list: + return self.data.get("notes", []) + + return Notes() + + +@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"] == {"notes": [{"id": 1, "text": "hello"}]} + + +def _handwritten(*, leak_host: bool = False, domain_cookie: bool = False, dated_cookie: bool = False, clock: bool = True, + 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, + ready_endpoint: str = "/ready", card_url: 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``; ``card_url`` puts what it says in the card's ``url`` alone.""" + 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, expires=3600 if dated_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, status_code=ready_status)) + + 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_endpoint}} + if clock: + extensions.append({"uri": "urn:agentenv:clock/v1", "params": { + "endpoint": "/agentenv/ext/sync_time", "methods": { + **{op: {"method": "GET", "endpoint": f"/agentenv/ext/{op}"} for op in clock_ops_first}, + "sync_time": {"method": "POST"}}}}) + url = card_url or rpc_path + return stamp(JSONResponse({"name": "hand", **({"url": url} if url else {}), + "capabilities": {"extensions": extensions, + "operations": ["data/reset", "data/add", "data/get"]}})) + + 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"] + state["synced"] = url + if not url: + state["at"] = None + return stamp(JSONResponse({"env_get_time_url": 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"])]) + 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", "detail"), [ + ({"ready": True, "problems": []}, "website/v1 ready answered 503: no problem named"), + ({"ready": False, "problems": ["seeding"]}, "website/v1 ready answered 503: seeding"), +]) +def test_ready_answering_an_http_error_fails_health_whatever_its_body_says(answer: dict, detail: str) -> None: + client = TestClient(_handwritten(ready_answer=answer, ready_status=503)) + health = next(r for r in conformance.check("http://testserver", client=client) if r.name == "health") + assert (health.status, health.detail) == ("failed", 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_a_cookie_with_an_absolute_expires_warns_without_failing_the_run() -> None: + cookies = {r.name: r for r in conformance.check("http://testserver", client=TestClient(_handwritten(dated_cookie=True)))}["cookies"] + assert (cookies.status, cookies.passed) == ("warned", True) and cookies.detail.endswith(": s") + + +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_sites_data_round_trips_and_is_left_as_the_check_found_it_changed_or_not( + notes: WebsiteEnvironment, sdk_site: TestClient) -> None: + for _ in range(2): + before = json.loads(json.dumps(notes.data)) + result = {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["data_plane"] + assert result.status == "passed", result + assert notes.data == before + notes.data["notes"].append({"id": 2, "text": "new"}) + + +def _hosts_asked(client: TestClient) -> list[str]: + hosts: list[str] = [] + client.event_hooks = {"request": [lambda request: hosts.append(request.url.host)], "response": []} + return hosts + + +def test_a_card_naming_another_server_for_its_data_plane_fails_it_with_nothing_sent_there() -> None: + app = _handwritten(card_url="http://elsewhere.example/agentenv") + client = TestClient(app) + hosts = _hosts_asked(client) + result = {r.name: r for r in conformance.check("http://testserver", client=client)}["data_plane"] + assert result.status == "failed" and "outside the server under check" in result.detail + assert set(hosts) == {"testserver"} and app.state.data["items"] == ["a"] + + +def test_a_card_path_that_would_name_another_host_stays_on_the_server() -> None: + client = TestClient(_handwritten(card_url="//elsewhere.example/agentenv", ready_endpoint="@elsewhere.example/ready", + ready_answer={"ready": True, "problems": []})) + hosts = _hosts_asked(client) + conformance.check("http://testserver", client=client) + assert set(hosts) == {"testserver"} + + +def test_the_clock_check_leaves_the_server_back_on_wall_time() -> None: + app = _handwritten() + results = {r.name: r.status for r in conformance.check("http://testserver", client=TestClient(app))} + assert results["clock"] == "passed" and app.state.data["synced"] == "" and app.state.data["at"] is None + + +def test_the_clock_check_leaves_an_sdk_site_unsynced(notes: WebsiteEnvironment, sdk_site: TestClient) -> None: + results = {r.name: r.status for r in conformance.check("http://testserver", client=sdk_site)} + assert results["clock"] == "passed" and not notes.clock.synced + + +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"] + + +def test_the_clock_check_calls_sync_time_whichever_operation_the_card_lists_first() -> None: + results = {r.name: r.status for r in conformance.check( + "http://testserver", client=TestClient(_handwritten(clock_ops_first=("time",))))} + assert results["clock"] == "passed", results + + +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("cookies", "warned", "dated"), + conformance.Result("clock", "failed", "late")]) + assert conformance.main(["http://site"]) == 1 + printed = capsys.readouterr().out + assert "PASS card" in printed and "WARN cookies: dated" in printed and "FAIL clock: late" in printed + assert "2 passed, warned or skipped, 1 failed" 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..0fbbf9165 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_environment.py @@ -0,0 +1,691 @@ +"""WebsiteEnvironment as a site's server: static pages, readiness, the card and its OpenAPI document, a site's own data +plane, sessions, the universe's clock read per request, and a mounted app.""" + +from __future__ import annotations + +import asyncio +import copy +import datetime +import json +import shutil +import subprocess +import threading +import time +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, add_data, get_data, reset_data +from agentenv_protocol.website import ( + SESSION_COOKIE, + SESSION_SECRET_ENV, + SITE_READY_TIMEOUT_S, + UNIVERSE_NOW_HEADER, + WEBSITE_EXTENSION_URI, + WEBSITE_READY_OP, + ApiContext, + Unauthorized, + WebsiteEnvironment, + WebsiteReadiness, + api, + ready_check, + website, +) +from agentenv_protocol.website import clock as clock_module +from agentenv_protocol.website import environment as environment_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): + def __init__(self) -> None: + super().__init__() + self.data: dict = {} + + def acting_user(self) -> str | None: + return self.data.get("current_user") + + @reset_data + def reset(self) -> None: + self.data = {} + + @add_data + def add(self, parts: list) -> None: + for part in parts: + self.data.update(part.data) + + @get_data + def export(self) -> list: + return [DataPart(data=self.data)] + + @api.get("/api/orders", name="list_orders") + def list_orders(self, ctx: ApiContext) -> list: + """List the signed-in user's orders.""" + return [order for order in self.data.get("orders", []) if order["user"] == ctx.user] + + @api.post("/api/orders", name="place_order", roles=["default", "agent"]) + def place_order(self, order: dict, ctx: ApiContext) -> dict: + placed = {**order, "user": ctx.user, "at": self.now().isoformat()} + self.data.setdefault("orders", []).append(placed) + return placed + + @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.data = copy.deepcopy(SEED) + 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_or_reserved_paths(client: TestClient) -> None: + assert client.get("/api/orders").json() == [] + assert client.put("/api/orders").status_code == 405 + 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_ready_checks_run_at_once_each_bounded_and_say_what_they_found(dist: Path, monkeypatch) -> None: + monkeypatch.setattr(environment_module, "CHECK_TIMEOUT_S", 0.3) + + class Slow(site(dist)): + @ready_check + def stuck(self) -> bool: + time.sleep(1) + return True + + @ready_check + async def waiting(self) -> bool: + await asyncio.sleep(1) + return True + + @ready_check + async def broken(self) -> bool: + raise RuntimeError("database locked") + + @ready_check + def seeded(self) -> int: + return 0 + + async def ask() -> tuple[dict, float]: + started = time.monotonic() + return await Slow()._website_ready(), time.monotonic() - started + + answer, took = asyncio.run(ask()) + assert answer["problems"] == ["@ready_check broken: RuntimeError: database locked", "@ready_check seeded returned 0", + "@ready_check stuck ran past 0.3 s", "@ready_check waiting ran past 0.3 s"] + assert answer["ready"] is False and took < 0.9 + + +def test_a_plain_def_check_past_its_bound_runs_once_however_often_ready_is_asked(dist: Path, monkeypatch) -> None: + monkeypatch.setattr(environment_module, "CHECK_TIMEOUT_S", 0.2) + released, calls = threading.Event(), [] + + class Syncing(site(dist)): + @ready_check + def synced(self) -> bool: + calls.append(len(calls)) + return released.wait(5) + + async def ask() -> tuple[list, dict]: + env = Syncing() + stuck = [(await env._website_ready())["problems"] for _ in range(3)] + released.set() + while not env._checking["synced"].done(): + await asyncio.sleep(0.01) + return stuck, await env._website_ready() + + stuck, after = asyncio.run(ask()) + assert stuck == [["@ready_check synced ran past 0.2 s"]] * 3 + assert after["ready"] is True and calls == [0, 1] + + +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") + answer = client.get(ready["endpoint"]).json() + assert (answer["ready"], answer["problems"]) == (False, ["@ready_check database_up returned False"]) + assert answer["timeout_s"] == SITE_READY_TIMEOUT_S and 0 <= answer["waited_s"] < 60 + assert WebsiteReadiness.model_validate(answer).problems == ["@ready_check database_up returned False"] + env.ready = True + assert {k: v for k, v in client.get(ready["endpoint"]).json().items() if k in ("ready", "problems")} == { + "ready": True, "problems": []} + + + +def test_a_site_says_how_long_it_may_take_to_be_ready_and_serves_no_health_path(dist: Path) -> None: + client = TestClient(site(dist, ready_timeout_s=45)().create_app()) + assert client.get("/agentenv/ext/website/ready").json()["timeout_s"] == 45 + card = client.get("/.well-known/agent-env.json").json() + assert "health" not in _extension(card, WEBSITE_EXTENSION_URI)["params"] + assert client.get("/api/health").status_code == 404 + + +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 "health" not in params + 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_a_sites_own_hooks_are_its_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"] == {} + rpc(client, "data/add", {"parts": [{"kind": "data", "data": {"current_user": "carol", "users": [{"id": "carol"}]}}]}) + assert rpc(client, "data/get")["parts"][0]["data"] == {"current_user": "carol", "users": [{"id": "carol"}]} + assert shop.acting_user() == "carol" + + +def test_a_site_without_data_hooks_serves_no_data_plane_and_signs_nobody_in(dist: Path) -> None: + @website(static=str(dist)) + class Bare(WebsiteEnvironment): + pass + + client = TestClient(Bare().create_app()) + assert not client.get("/.well-known/agent-env.json").json()["capabilities"].get("operations") + answer = client.post(RPC, json={"jsonrpc": "2.0", "id": 1, "method": "data/get", "params": {}}).json() + assert "error" in answer and "result" not in answer + assert client.get("/api/session").json() == {"user": None, "role": "default", "signed_in": False} + + +def test_a_sites_data_is_read_through_the_data_plane_alone(client: TestClient) -> None: + client.post("/api/orders", json={"sku": "mug"}) + assert rpc(client, "data/get")["parts"][0]["data"]["orders"][-1]["sku"] == "mug" + assert "home" in client.get("/go").text + params = client.get("/.well-known/agent-env.json").json()["capabilities"]["extensions"] + website_params = next(e for e in params if e["uri"] == "urn:agentenv:website/v1")["params"] + assert "state" not in website_params and "state" not in website_params.get("methods", {}) + + +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.data = copy.deepcopy(SEED) + client = TestClient(env.create_app()) + assert client.get("/api/session").json()["user"] == "alice" + assert client.get("/api/health").json() == {"named": "health"} + 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(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"] + + +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 + # 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") + assert client.get("/api/session").json()["user"] == "alice" + + +def test_the_session_cookie_lasts_a_max_age_not_until_an_absolute_time(client: TestClient) -> None: + attributes = [a.strip().lower() for a in client.get("/api/session").headers["set-cookie"].split(";")[1:]] + assert f"max-age={7 * 24 * 3600}" in attributes and not any(a.startswith("expires") for a in attributes) + + +def test_a_site_restarted_under_its_deploys_secret_keeps_its_sessions(dist: Path, monkeypatch) -> None: + monkeypatch.setenv(SESSION_SECRET_ENV, "the-deploys-secret") + before, after = (site(dist)() for _ in range(2)) + before.data = after.data = copy.deepcopy(SEED) + browser = TestClient(before.create_app()) + browser.post("/api/sign-in", json={"user": "bob"}) + restarted = TestClient(after.create_app(), cookies={SESSION_COOKIE: browser.cookies[SESSION_COOKIE]}) + assert restarted.get("/api/session").json()["user"] == "bob" + + +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.keys() == {"cookies", "local_storage"} and minted["local_storage"] == [] + (cookie,) = minted["cookies"] + assert cookie["name"] == SESSION_COOKIE and cookie["path"] == "/" and cookie["max_age"] == 7 * 24 * 3600 + assert "domain" not in cookie and "expires" not in cookie + 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; None answers 404, as an unarmed clock does.""" + + def __init__(self, virtual: str | None) -> None: + self.virtual = virtual + outer = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + if outer.virtual is None: + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + return + 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_read_per_request(client: TestClient, + shop: WebsiteEnvironment, + gateway_clock: _Clock) -> 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] == "2031-01-01T00:00:00.000Z" + order = client.post("/api/orders", json={"sku": "pen"}).json() + assert order["at"] == "2031-01-01T00:00:00+00:00" + gateway_clock.virtual = "2031-06-01T12:00:00Z" + for _ in range(2): + assert client.get("/").headers[UNIVERSE_NOW_HEADER] == "2031-06-01T12:00:00.000Z" + + +def test_an_unarmed_gateway_clock_reads_as_wall_time(client: TestClient, gateway_clock: _Clock) -> None: + client.post("/agentenv/ext/sync_time", json={"env_get_time_url": gateway_clock.url}) + gateway_clock.virtual = None + now = parse_time(client.get("/").headers[UNIVERSE_NOW_HEADER]) + assert abs((now - datetime.datetime.now(datetime.timezone.utc)).total_seconds()) < 60 + + +def test_sync_time_with_an_empty_url_goes_back_to_wall_time(client: TestClient, shop: WebsiteEnvironment, + gateway_clock: _Clock) -> None: + client.post("/agentenv/ext/sync_time", json={"env_get_time_url": gateway_clock.url, "timezone": "America/Chicago"}) + unsynced = client.post("/agentenv/ext/sync_time", json={"env_get_time_url": ""}).json() + assert unsynced["env_get_time_url"] == "" and not shop.clock.synced and shop.timezone is None + now = parse_time(client.get("/").headers[UNIVERSE_NOW_HEADER]) + assert abs((now - datetime.datetime.now(datetime.timezone.utc)).total_seconds()) < 60 + + +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_fails_the_sites_answers_but_not_agentenvs( + client: TestClient, shop: WebsiteEnvironment, gateway_clock: _Clock) -> 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 not shop.clock.synced + client.post("/agentenv/ext/sync_time", json={"env_get_time_url": gateway_clock.url}) + gateway_clock.close() + for path in ("/", "/api/orders", "/api/missing"): + answer = client.get(path) + assert answer.status_code == 503 and answer.json()["code"] == "clock_unavailable", path + card = client.get("/.well-known/agent-env.json") + assert card.status_code == 200 and UNIVERSE_NOW_HEADER not in card.headers + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + + +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("