diff --git a/packages/agentenv-protocol/docs/website-spec.md b/packages/agentenv-protocol/docs/website-spec.md new file mode 100644 index 000000000..664b90342 --- /dev/null +++ b/packages/agentenv-protocol/docs/website-spec.md @@ -0,0 +1,359 @@ +# The website spec + +A website env used to be described in three places: its `env.toml` (`type = "website"`, `health`, `backend_image`), +the decorators in its code (`@website(domains, static, health, data)` and `@api` routes with names and roles), and the +card it serves (the `urn:agentenv:website/v1` extension: domains, health, routes, session cookie). The website spec +is one declaration of a site. Each way of serving a site either produces a spec or is checked against one, and specs +compose, so a site can be assembled from parts. + +The code is `agentenv_protocol.website.spec`. The JSON Schema of a spec file is +`python -m agentenv_protocol.website.spec schema`. + +## Shape + +A spec is a TOML file. Its top-level keys are fixed and every section is optional: + +| Section | What it says | +| --- | --- | +| `extends` | The path of a spec this one overlays (see [Overlays](#overlays)). | +| `[site]` | `name`, the card's name; `domains`, the host names the site answers for, with `*.` as the leftmost label matching any subdomain; `health`, the path that answers 200 once the site is usable (`/api/health` by default), read only for a server whose card has no `website/v1` `ready`, which is deprecated: a deploy and the conformance check read readiness from the card. | +| `[source]` | What serves the site, exactly one of `image`, `sdk`, `static`, `live` or `replay` (see [Sources](#sources)). | +| `[data]` | `collections` (each with `key`, `owner` and an optional JSON `schema`), or one `schema` of the whole state, or `models = "mcp:"` to bind the site's data to an MCP server's schema; and `seed`, the files it starts with, in order. | +| `[users]` | `personas` (ids, or `{id, name, role}` tables); `acting_user`, who a browser with no session is; `sign_in`, which is `cookie` (the site's signed session cookie, by default), `none` or `live`; and, for a cookie sign-in by a server not built on the SDK, its `session_cookie` and `session_path`. | +| `[roles]` | A role name mapped to the routes it may call. These roles are added to each route's own `roles`. | +| `[routes]` | `ops`, the named operations, each `name = "METHOD /path"` or a table `{method, path, roles, served_by}`; and `openapi`, an OpenAPI document whose operations, named by `operationId` (each name once) with roles from `x-agentenv-roles`, are routes too. Triggers and records call these names `op`. | +| `[clock]` | How the site follows the universe's time: `follow = "header"` (`X-Universe-Now` on every answer), `"date_patch"` (its pages' `Date` is shifted too), or `"none"` (wall time). | +| `[[uses]]` | Components: another spec mounted under a path prefix (see [Composition](#composition)). | + +Paths in a spec (Dockerfiles, folders, seeds, schemas, captures, the OpenAPI document and components) are relative to +the spec file. + +The Notes example's spec, `examples/website/notes/website.toml`: + +```toml +[site] +name = "notes" +domains = ["notes.test"] +health = "/healthz" + +[source] +sdk = "site:Notes" +assets = "web/dist" + +[data] +seed = ["data/seed.json"] + +[data.collections.users] + +[data.collections.notes] +owner = "owner" + +[users] +personas = [{ id = "ann", name = "Ann Lee" }, { id = "ben", name = "Ben Ortiz" }] +acting_user = "ann" +sign_in = "cookie" + +[roles] +admin = ["all_notes"] + +[routes.ops] +list_users = "GET /api/users" +sign_in = "POST /api/sign-in" +list_notes = "GET /api/notes" +create_note = "POST /api/notes" +get_note = "GET /api/notes/{note_id}" +edit_note = "PATCH /api/notes/{note_id}" +toggle_note = "POST /api/notes/{note_id}/toggle" +delete_note = "DELETE /api/notes/{note_id}" +all_notes = "GET /api/admin/notes" + +[clock] +follow = "header" +``` + +## Validation + +`load(path)` reads a spec, resolves `extends` and `uses`, and validates the result. If anything is wrong, it raises +`SpecError`, whose `problems` lists every problem as `: : `. For example, +`.../shop.toml: routes.ops: b and a are both GET /x`. The checks within a section are: unknown keys, exactly one +source, lower-case host names, health and route paths outside the data plane's `/agentenv` and the card's +`/.well-known/agent-env.json`, credentials that are `secret:`/`env:` references rather than values, and an +`acting_user` that is one of the `personas`. + +The checks across sections are: + +- No two routes share a verb and path, and no GET route is served at the health path. +- `[roles]` names only routes that exist, including the namespaced routes of components. +- `sign_in = "live"` needs a `live` or `replay` source. +- A `live` or `replay` site is read as `diff`, and only such a site is. +- An `sdk` site follows the clock, so `follow = "none"` is refused for it. +- A `static` site serves no routes. +- `served_by` is allowed only on a route of a live or replayed site. +- A collection with an `owner` needs a site that signs users in. + +`python -m agentenv_protocol.website.spec validate PATH` runs these checks. `show PATH` prints the resolved spec as +JSON, and `show --card PATH` prints the card params it advertises. + +## Sources + +Every source maps onto the same spec. Each row below says how a source is described, what it produces, and what it is +checked against. + +| Source | `[source]` | Where the spec comes from | Deployable today | +| --- | --- | --- | --- | +| SDK class | `sdk = "module:Class"`, `assets = ""` | `@website(spec="website.toml")` declares the class from the spec, and the class is refused if it doesn't serve what the spec declares. `from_environment(cls)` derives a spec from the decorators of a class declared the old way. | Yes, as a website env's server image | +| Any-stack image | `image = ""` or `image = { dockerfile, context }` | Written by hand. `conformance --spec` checks the running server against it. | Yes, through `backend_image` in env.toml | +| Static | `static = ""` | Written by hand. | Modeled only: no generic static server image ships yet | +| Live real site | `live = { allow_hosts, credentials }` | Written by hand; `from_card` reads one back from a proxy that serves a card. | Modeled only; a website env refuses it | +| Replayed capture | `replay = { capture, allow_hosts }` | Usually an overlay of the live spec. | Modeled only; a website env refuses it | + +For every source, `to_card_extension()` returns the `urn:agentenv:website/v1` params a server of the spec advertises: +`domains`, `health` and `routes` (each `{method, path, name, roles}`); `session_cookie` and `session_path` for a cookie +sign-in; and `clock` when it is declared. A WebsiteEnvironment builds its card from these params, so +the card and the spec cannot drift apart. `from_card(card)` reads a running site's card back into a spec. It recovers +the site, routes, sign-in and clock. A card does not carry a site's source, data, personas or the +`[roles]` table; roles are folded into each route. + +### The SDK + +```python +@website(spec="website.toml") +class Notes(WebsiteEnvironment): + ... +``` + +`@website(spec=...)` takes a path relative to the class's file, or a loaded `WebsiteSpec`. The spec supplies the +domains, health path, static folder (`assets`), seed files and clock patch. If any of those are also passed to +`@website`, it is refused. When the class is declared, it is checked against the spec: + +- Its `@api` routes must match the spec's routes by name, verb, path and roles, whenever the spec declares routes. +- `source.sdk` must name the class. +- The sign-in must be the SDK's cookie. +- `acting_user` must be the seed's `current_user`. + +If the class has no `@environment_card` name, the card is named by `site.name`. A card name that differs from +`site.name` is refused. Classes declared with `@website(domains=..., ...)` work as before; their card goes through +`from_environment(cls).to_card_extension()`. + +### env.toml + +A website env's `env.toml` can point to a spec with `spec = "website.toml"`, or carry one inline as a `[spec]` +table: + +```toml +type = "website" +spec = "website.toml" +backend_image = { dockerfile = "Dockerfile" } +``` + +The env takes its `environment_name` from `site.name` and its health path from `site.health` when the env.toml leaves +them out. If the env.toml sets one to a different value, the env is refused. The env stores the spec's domains +(`WebsiteEnv.domains`). A spec with a `live` or `replay` source is refused, because a server image can't serve it. +`backend_image` still says how the image is built. + +### Conformance + +`python -m agentenv_protocol.website.conformance --spec website.toml` runs the usual checks plus two more: + +- `spec` checks that the card's name, domains, health path, routes, session cookie and session path are the spec's, + and its `clock` too when the spec declares it. +- `spec_routes` checks that every declared route is served. An `OPTIONS` to the route's path, with each path parameter + filled in by a value the converter of the card's route of that name takes (a UUID for `uuid`, else `0`), must find + something there; a GET route without path parameters must answer neither 404 nor 405; and each + route must be an operation of the server's OpenAPI document when its card offers one. When the card offers no + document, a route only the `OPTIONS` can check (a non-GET route, or a GET route with path parameters) counts as + served only when that `OPTIONS` answers below 500 with an `Allow` listing its method. Any other answer, such as a + catch-all `OPTIONS` handler's 200 without `Allow`, a 500, or an `Allow` that doesn't list the method, leaves the + route unverified: the check is then a `warning` (a failure with `--strict`) rather than a pass, and fails only + when a route is shown missing. Only GET routes are called directly, so the check changes no state. + +The `health` check reads readiness from the card's `website/v1` `ready`. For a card without it, it reads the health path, which is deprecated: with `--spec`, the spec's rather than the card's. + +## Overlays + +`extends = ""` merges this spec over the one it names. The merge works like this: + +- Tables merge key by key, recursively. +- Arrays and scalars replace. For example, `domains = [...]` or `personas = [...]` in the overlay replace the base's. +- `[source]` replaces as a whole, so an overlay can turn a live site into a replay without leftover keys. +- `name = false` removes an inherited entry of `[routes.ops]`, `[roles]` or `[data.collections]`. A route removed + this way is removed from the operations of the `openapi` document too. +- A route written as `"METHOD /path"` is expanded to a table before merging, so an overlay can change one key of it, + such as `post_message = { served_by = "..." }`. An operation of the `openapi` document is one too: a table naming it, + such as `add_item = { roles = ["staff"] }`, is written over the document's operation. +- `[[uses]]` entries are added. An entry with the same `as` (or, without one, the same `spec`) replaces the inherited + entry. +- The base's relative paths are rewritten relative to the overlay's folder, so a base in another folder still finds + its files. + +`extends` chains are followed, and a cycle is refused. In code, `spec.overlay({...})` applies the same merge to a +loaded spec. The Notes test uses it to swap in a stand-in static folder. + +Overlays are how one site gets variants: + +- A frozen capture with one synthetic route: `examples/website/specs/slack.toml` extends `slack-live.toml`, replaces + the live source with a replay of a capture, and swaps `post_message` to an SDK class. +- The same site with another persona set: `examples/website/specs/intranet-ben.toml` signs in as `ben`, with personas + `ben` and `cy`, and drops the `editor` role. + +```toml +# slack-live.toml +[site] +name = "slack" +domains = ["slack.com", "*.slack.com"] +health = "/api/api.test" + +[source.live] +allow_hosts = ["*.slack-edge.com", "*.slack-imgs.com"] +credentials = { token = "secret:SLACK_USER_TOKEN" } + +[users] +sign_in = "live" + +[routes.ops] +list_conversations = "GET /api/conversations.list" +history = "GET /api/conversations.history" +post_message = "POST /api/chat.postMessage" +add_reaction = "POST /api/reactions.add" + +[clock] +follow = "none" +``` + +```toml +# slack.toml: the live site frozen as a replay, one route answered by a synthetic implementation +extends = "slack-live.toml" + +[source.replay] +capture = "captures/slack-workspace.har" +allow_hosts = ["*.slack-edge.com"] + +[routes.ops] +post_message = { served_by = "slack_synthetic:SlackChat" } + +[clock] +follow = "date_patch" +``` + +## Composition + +A `[[uses]]` entry mounts another spec as a component: + +```toml +[[uses]] +spec = "components/sso.toml" # relative to this spec +mount = "/sso" # the path prefix its routes are served under +as = "sso" # its namespace; the component's site.name by default +data = { accounts = "users" } # a collection of the component's, mapped onto one of this site's +``` + +When a spec is resolved, each component is resolved first, including its own `extends` and `uses`. It is then merged +into the site: + +- **Routes.** Each of the component's routes is served at `` and named `.`. The roles its own + `[roles]` grants come with it. The site's `[roles]` can name the composed routes, for example + `editor = ["sso.login"]`. +- **Data.** A collection named in `data` merges into the site's collection of that name. They must agree on `key`, + `owner` and `schema` where both set them. Any other collection is added as `.`. A schema file a + component names stays the component's file, its path made relative to the site's spec. A component's seed files + are not merged: the site seeds the shared collections. +- **Users.** The component's personas are added to the site's, merging the fields of a persona they share; fields + that contradict are a conflict. A component must sign in the way the site does, or not at all (`none`). +- **The rest.** The component's `site` (domains, health), `source` and `clock` are not merged. A mounted + component lives under the site's own domains and is served by the site's source. + +These are conflicts, each reported as `uses[]: ...`: + +- A composed route name that the site already declares. +- A composed verb and path that another route already serves. +- A route of the site's own under the component's mount prefix (unless the mount is `/`). +- A `data` mapping of a collection the component doesn't declare. +- A collection or persona that disagrees with the site's. +- A sign-in mismatch. +- A component with no name. +- A cycle of `uses`. + +A resolved spec has no `extends`, `uses` or `routes.openapi` left: the document's operations are in `routes.ops`. +`spec.components` lists what was mounted, each with its name, mount and own resolved spec. `to_dict()` of a resolved +spec reads back as the same spec. + +`examples/website/specs/intranet.toml` uses a fake SSO component, `components/sso.toml`, mounted at `/sso`. The +component's `accounts` collection is mapped onto the intranet's `users`, and its `grants` collection becomes +`sso.grants`: + +```toml +[site] +name = "intranet" +domains = ["intranet.test", "*.intranet.test"] + +[source.image] +dockerfile = "Dockerfile.intranet" + +[data.collections.users] + +[data.collections.pages] +owner = "author" + +[users] +personas = ["ann", "ben"] +acting_user = "ann" + +[roles] +editor = ["publish_page"] + +[routes.ops] +list_pages = "GET /api/pages" +publish_page = "POST /api/pages" + +[[uses]] +spec = "components/sso.toml" +mount = "/sso" +data = { accounts = "users" } +``` + +Resolved, its routes are `list_pages`, `publish_page`, `sso.authorize` (`GET /sso/authorize`), `sso.login`, +`sso.token` and `sso.userinfo`, and its collections are `users`, `pages` and `sso.grants`. + +## The name table + +A multi env routes every host name its sites answer for through one table. `name_table(specs)` builds it: + +- **Kinds.** A site's `domains` are `synthetic` when the env serves the site (an image, sdk, static or replay source) + and `live` when the site is the real one. A live or replay source's `allow_hosts` are `allowlist`: hosts let through + to the internet, not served. +- **Precedence.** When two kinds claim one pattern, the higher kind wins: synthetic, then live, then allowlist. The + loser is kept in `shadowed` beside the entry it lost to. This is how a synthetic overlay takes over a name a live + site or an allowlist also claims. +- **Resolution.** `table.resolve(host)` looks first at the highest kind of the entries matching the host, then at the + most specific pattern of that kind: an exact name, then the longest wildcard. So a synthetic `*.slack.com` beats a + live `status.slack.com`. A synthetic `inbox.example.test` beats a synthetic `*.example.test` of another site. +- **Wildcards.** `*.example.test` matches every subdomain at any depth, but not `example.test` itself. + +These are conflicts, each a `SpecError`: + +- Two sites claim the same pattern at the same kind, synthetic or live, whichever kind wins the pattern. A host that + two different patterns match (`*.example.test` and `inbox.example.test`) is not a conflict, because specificity + decides it. +- A site has no `site.name`. +- Two sites share a name. + +Two sites allowlisting the same host is not a conflict, because an allowlist only lets traffic through. + +## What is wired and what is only modeled + +These parts are wired: + +- The spec model, loader, validation, `extends`, `uses`, the card params, `from_card`, `from_environment`, the name + table, the JSON Schema and the command line. +- `@website(spec=...)` and the SDK card generated through the spec. +- `spec` in a website env's env.toml (name, health and domains). +- `conformance --spec`. + +These parts are only modeled: they are declared and validated, but nothing serves them yet. + +- The `static`, `live` and `replay` sources, and route `served_by`. +- `[data] models` and collection schemas. +- Mounting a component's implementation. An SDK class that uses a component must serve its routes itself. +- The name table's use by the gateway. +- Component references other than file paths. +- The env's stored domains: deploy doesn't yet check them against the card. diff --git a/packages/agentenv-protocol/examples/website/notes/Dockerfile b/packages/agentenv-protocol/examples/website/notes/Dockerfile index 461e886b2..f8896a549 100644 --- a/packages/agentenv-protocol/examples/website/notes/Dockerfile +++ b/packages/agentenv-protocol/examples/website/notes/Dockerfile @@ -14,7 +14,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* COPY packages/agentenv-protocol /tmp/agentenv-protocol RUN pip install --no-cache-dir /tmp/agentenv-protocol "uvicorn>=0.30" && rm -rf /tmp/agentenv-protocol -COPY ${NOTES}/site.py /app/site.py +COPY ${NOTES}/site.py ${NOTES}/website.toml /app/ COPY ${NOTES}/data /app/data COPY --from=web /web/dist /app/web/dist ENV PORT=8000 diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index 4f0c590d3..6bd558790 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -6,19 +6,23 @@ them. The page shows the universe's time. | File | What it is | | --- | --- | -| `site.py` | The server: the `Notes` WebsiteEnvironment, its `@api` routes, and `serve()` on `PORT` (8000). | +| `website.toml` | The site's spec: its name, domains, health path, source, seed, collections, personas, roles, routes, state read and clock (see `docs/website-spec.md`). | +| `site.py` | The server: the `Notes` WebsiteEnvironment, declared from `website.toml`, its `@api` routes, and `serve()` on `PORT` (8000). | | `data/seed.json` | The seed data: two users (`ann`, `ben`), their notes, and `current_user`, the acting user. | | `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike; opened at a path without its final slash, such as `/website/notes`, the page first reloads itself with it. | | `Dockerfile` | The server image: Node builds `web/`, then Python installs this repo's `packages/agentenv-protocol` with uvicorn. It installs `curl`, which the gateway's compose health check runs. | -| `env.toml` | The website env as a bundle entry. | +| `env.toml` | The website env as a bundle entry, pointing at `website.toml`. | ## What it exercises -- **Site declaration.** `@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json")`. - The card advertises website/v1 with the domains, the health path and every route by name. +- **Site declaration.** `@website(spec="website.toml")`. The spec gives the name, domains, static folder, health path + and seed, and the class is refused when its `@api` routes aren't the spec's. The card's website/v1 params are the + spec's `to_card_extension()`: the domains, the health path, every route by name, the session, the state read and + the clock. - **Routes.** `list_users`, `sign_in`, `list_notes` (with an optional `?done=`), `create_note` (201), `get_note`, `edit_note`, `toggle_note` and `delete_note`. Errors are raised as `NotFound`, `BadRequest` and `Unauthorized`. -- **Roles.** `all_notes` (`GET /api/admin/notes`) is declared with `roles=["admin"]`. It answers only a caller that +- **Roles.** `all_notes` (`GET /api/admin/notes`) is declared with `roles=["admin"]`, and the spec's `[roles]` grants + it to `admin`. It answers only a caller that sends `AgentEnv-Role: admin` with `AgentEnv-Role-Key` equal to the container's `WEBSITE_ROLE_KEY`; any other caller gets 403. A deploy with `keyed_roles` gives the site its own role key as `WEBSITE_ROLE_KEY` and its gateway sends it on only beside a role it verified with the same key, so a page cannot claim `admin` through it. Any other deploy sets @@ -71,9 +75,11 @@ python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-h curl -s http://localhost:18080/api/notes ``` -Use `--clock-host` so the container can reach the checker's clock. `--strict` fails on a recommended check's warning -too (the card, `X-Universe-Now`, the clock, forwarded headers, cookies); without it only the contract's required checks -fail the run, and Notes passes every check either way. The clock check leaves the server following a +Use `--clock-host` so the container can reach the checker's clock, and add +`--spec packages/agentenv-protocol/examples/website/notes/website.toml` to check the server against its spec too. +`--strict` fails on a recommended check's warning too (the card, `X-Universe-Now`, the clock, forwarded headers, +cookies); without it only the contract's required checks, and the spec's when given, fail the run, and Notes passes +every check either way. The clock check leaves the server following a clock that is gone once the check ends: later timestamps start from 2031 until the server is synced again, so restart the container before using it. @@ -85,10 +91,10 @@ agent-env env website put --id notes-site \ --server-docker-context . --health-path /healthz ``` -Run it from the repo root. `environment_name` comes from the card (`@environment_card(name="notes")`). The stored env +Run it from the repo root. `environment_name` comes from the card, which the spec names `notes`. The stored env has a server image and no frontend image. Pass `--skip-validation` to register without deploying. -`env.toml` describes the same env for a bundle. Its keys are `type`, `environment_name`, `health` and -`backend_image`. Domains are not one of them: they come from the card. A bundle builds `backend_image` with this +`env.toml` describes the same env for a bundle. Its keys are `type`, `spec` and `backend_image`; the env takes its +`environment_name`, health path and domains from the spec. A bundle builds `backend_image` with this folder as the build context, but this Dockerfile needs the repo root. Until the protocol package is installed from a published release instead, register the site with `put` as above. diff --git a/packages/agentenv-protocol/examples/website/notes/env.toml b/packages/agentenv-protocol/examples/website/notes/env.toml index a1d019251..483d79d77 100644 --- a/packages/agentenv-protocol/examples/website/notes/env.toml +++ b/packages/agentenv-protocol/examples/website/notes/env.toml @@ -1,4 +1,3 @@ type = "website" -environment_name = "notes" -health = "/healthz" +spec = "website.toml" backend_image = { dockerfile = "Dockerfile" } diff --git a/packages/agentenv-protocol/examples/website/notes/site.py b/packages/agentenv-protocol/examples/website/notes/site.py index 5eaafda0e..777c83c40 100644 --- a/packages/agentenv-protocol/examples/website/notes/site.py +++ b/packages/agentenv-protocol/examples/website/notes/site.py @@ -11,7 +11,6 @@ from pydantic import BaseModel -from agentenv_protocol import environment_card from agentenv_protocol.website import ( ApiContext, BadRequest, @@ -43,8 +42,7 @@ class SignIn(BaseModel): user: str -@environment_card(name="notes") -@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json") +@website(spec="website.toml") class Notes(WebsiteEnvironment): @ready_check def seeded(self) -> bool: diff --git a/packages/agentenv-protocol/examples/website/notes/website.toml b/packages/agentenv-protocol/examples/website/notes/website.toml new file mode 100644 index 000000000..e0bb05473 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/website.toml @@ -0,0 +1,38 @@ +[site] +name = "notes" +domains = ["notes.test"] +health = "/healthz" + +[source] +sdk = "site:Notes" +assets = "web/dist" + +[data] +seed = ["data/seed.json"] + +[data.collections.users] + +[data.collections.notes] +owner = "owner" + +[users] +personas = [{ id = "ann", name = "Ann Lee" }, { id = "ben", name = "Ben Ortiz" }] +acting_user = "ann" +sign_in = "cookie" + +[roles] +admin = ["all_notes"] + +[routes.ops] +list_users = "GET /api/users" +sign_in = "POST /api/sign-in" +list_notes = "GET /api/notes" +create_note = "POST /api/notes" +get_note = "GET /api/notes/{note_id}" +edit_note = "PATCH /api/notes/{note_id}" +toggle_note = "POST /api/notes/{note_id}/toggle" +delete_note = "DELETE /api/notes/{note_id}" +all_notes = "GET /api/admin/notes" + +[clock] +follow = "header" diff --git a/packages/agentenv-protocol/examples/website/specs/components/sso.toml b/packages/agentenv-protocol/examples/website/specs/components/sso.toml new file mode 100644 index 000000000..5d5594985 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/specs/components/sso.toml @@ -0,0 +1,19 @@ +[site] +name = "sso" + +[source] +sdk = "fake_sso:FakeSso" + +[data.collections.accounts] + +[data.collections.grants] +owner = "user" + +[users] +sign_in = "cookie" + +[routes.ops] +authorize = "GET /authorize" +login = "POST /login" +token = "POST /token" +userinfo = "GET /userinfo" diff --git a/packages/agentenv-protocol/examples/website/specs/intranet-ben.toml b/packages/agentenv-protocol/examples/website/specs/intranet-ben.toml new file mode 100644 index 000000000..7349d418e --- /dev/null +++ b/packages/agentenv-protocol/examples/website/specs/intranet-ben.toml @@ -0,0 +1,8 @@ +extends = "intranet.toml" + +[users] +personas = ["ben", "cy"] +acting_user = "ben" + +[roles] +editor = false diff --git a/packages/agentenv-protocol/examples/website/specs/intranet.toml b/packages/agentenv-protocol/examples/website/specs/intranet.toml new file mode 100644 index 000000000..b4f8965a9 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/specs/intranet.toml @@ -0,0 +1,27 @@ +[site] +name = "intranet" +domains = ["intranet.test", "*.intranet.test"] + +[source.image] +dockerfile = "Dockerfile.intranet" + +[data.collections.users] + +[data.collections.pages] +owner = "author" + +[users] +personas = ["ann", "ben"] +acting_user = "ann" + +[roles] +editor = ["publish_page"] + +[routes.ops] +list_pages = "GET /api/pages" +publish_page = "POST /api/pages" + +[[uses]] +spec = "components/sso.toml" +mount = "/sso" +data = { accounts = "users" } diff --git a/packages/agentenv-protocol/examples/website/specs/slack-live.toml b/packages/agentenv-protocol/examples/website/specs/slack-live.toml new file mode 100644 index 000000000..35d7eec71 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/specs/slack-live.toml @@ -0,0 +1,20 @@ +[site] +name = "slack" +domains = ["slack.com", "*.slack.com"] +health = "/api/api.test" + +[source.live] +allow_hosts = ["*.slack-edge.com", "*.slack-imgs.com"] +credentials = { token = "secret:SLACK_USER_TOKEN" } + +[users] +sign_in = "live" + +[routes.ops] +list_conversations = "GET /api/conversations.list" +history = "GET /api/conversations.history" +post_message = "POST /api/chat.postMessage" +add_reaction = "POST /api/reactions.add" + +[clock] +follow = "none" diff --git a/packages/agentenv-protocol/examples/website/specs/slack.toml b/packages/agentenv-protocol/examples/website/specs/slack.toml new file mode 100644 index 000000000..77635c391 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/specs/slack.toml @@ -0,0 +1,11 @@ +extends = "slack-live.toml" + +[source.replay] +capture = "captures/slack-workspace.har" +allow_hosts = ["*.slack-edge.com"] + +[routes.ops] +post_message = { served_by = "slack_synthetic:SlackChat" } + +[clock] +follow = "date_patch" diff --git a/packages/agentenv-protocol/pyproject.toml b/packages/agentenv-protocol/pyproject.toml index 17492f651..91e2985b0 100644 --- a/packages/agentenv-protocol/pyproject.toml +++ b/packages/agentenv-protocol/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "pydantic>=2.11", "starlette>=0.37", "httpx>=0.27", + "tomli>=1.1; python_version < '3.11'", ] [project.optional-dependencies] diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py index 1ffa4d454..455f9010c 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py @@ -1,6 +1,7 @@ """The website SDK: a site's server, meeting ``urn:agentenv:website/v1``. Subclass WebsiteEnvironment, declare the site with ``@website(domains=[...], static="dist/")``, mark its API with ``@api.get/post/put/patch/delete(path)``, and -``serve()`` it; it needs only the plain package, and uvicorn to serve. The class is optional: a server in any language +``serve()`` it, or declare it from a website spec with ``@website(spec="website.toml")``; it needs only the plain +package, and uvicorn to serve. The class is optional: a server in any language that meets the contract works, which ``python -m agentenv_protocol.website.conformance `` checks. Whether a site is ready is its ``WebsiteReadiness`` answer to ``ready``, which ``website_readiness`` reads of one site and ``env_readiness`` of every site of an env. What a task left a site with is its data plane's ``data/get``.""" @@ -20,12 +21,14 @@ from .readiness import READY_TIMEOUT_S, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness, env_readiness, website_readiness from .routes import ROLE_KEY_ENV, ROLE_KEY_HEADER, ApiContext, ApiRoute, api, reserved_path from .session import SESSION_COOKIE, SessionSigner +from .spec import NameTable, SpecError, WebsiteSpec, from_card, from_environment, load as load_spec, name_table from .store import Collections __all__ = [ "WebsiteEnvironment", "WebsiteDeclaration", "website", "api", "ApiContext", "ApiRoute", "ready_check", "ApiError", "BadRequest", "Unauthorized", "Forbidden", "NotFound", "Conflict", "Collections", "UniverseClock", "SessionSigner", "reserved_path", + "WebsiteSpec", "SpecError", "NameTable", "load_spec", "from_card", "from_environment", "name_table", "WEBSITE_EXTENSION_URI", "CLOCK_EXTENSION_URI", "UNIVERSE_NOW_HEADER", "DEFAULT_HEALTH_PATH", "SESSION_PATH", "SESSION_COOKIE", "ROLE_KEY_ENV", "ROLE_KEY_HEADER", "WEBSITE_READY_OP", "READY_TIMEOUT_S", "WebsiteReadiness", "website_readiness", "env_readiness", diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py index d5bc89c26..0a515c80c 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -13,6 +13,13 @@ headers wherever an answer shows them (on ``/`` and on a GET route with its trailing slash toggled, which a server may redirect), and cookies tied to no domain, the session path's the one the card names. A failed one is a ``warning``. +With ``--spec``, a website spec, the server is checked against it too, and those checks are required: its card's name, +domains, health path, routes and session against the spec's, and every route the spec declares served (an ``OPTIONS`` +that finds something at its path, and a GET route without path parameters answering neither 404 nor 405), each in the +server's OpenAPI document when its card offers one; for a card without one, a route only the ``OPTIONS`` can check is +unverified, a ``warning``, unless that ``OPTIONS`` answers with an ``Allow`` listing its method. The health check +then reads the spec's health path for a card without ``website/v1`` ``ready``. + The round trip leaves the server's data as it found it, adding the export back when a step after the reset fails too. The clock check leaves the server following a clock that is gone once the check ends, so a server it ran against keeps extrapolating from that clock's time until it's synced again. The checker's clock starts at ``CLOCK_START``, far @@ -25,6 +32,7 @@ import dataclasses import datetime import json +import re import sys import threading import time @@ -40,11 +48,12 @@ from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, iso, parse_time from .environment import DEFAULT_HEALTH_PATH from .readiness import WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness +from .spec import SpecError, WebsiteSpec, load Status = Literal["passed", "failed", "warning", "skipped"] CLOCK_START = datetime.datetime(2031, 5, 6, 7, 8, 9, tzinfo=datetime.timezone.utc) CLOCK_SLACK_S = 120 -REQUIRED = ("any_host", "health", "reserved_paths", "data_plane") +REQUIRED = ("any_host", "health", "reserved_paths", "data_plane", "spec", "spec_routes") @dataclasses.dataclass(frozen=True) @@ -67,21 +76,22 @@ def fails(self, strict: bool = False) -> bool: def check(base_url: str, *, client: httpx.Client | None = None, clock_host: str = "127.0.0.1", - timeout: float = 30) -> list[Result]: + timeout: float = 30, spec: WebsiteSpec | None = None) -> list[Result]: """Each check's result against the server at ``base_url``. ``clock_host`` is the address the server reaches the - checker's clock at. Raises httpx's error when the server can't be reached at all.""" + checker's clock at; with ``spec``, the server is checked against it too. Raises httpx's error when the server + can't be reached at all.""" own = client is None client = client or httpx.Client(timeout=timeout) try: - return _Checker(base_url.rstrip("/"), client, clock_host).run() + return _Checker(base_url.rstrip("/"), client, clock_host, spec).run() finally: if own: client.close() class _Checker: - def __init__(self, base_url: str, client: httpx.Client, clock_host: str) -> None: - self.base_url, self.client, self.clock_host = base_url, client, clock_host + def __init__(self, base_url: str, client: httpx.Client, clock_host: str, spec: WebsiteSpec | None = None) -> None: + self.base_url, self.client, self.clock_host, self.spec = base_url, client, clock_host, spec self.rpc_url = base_url + RPC_PATH self.responses: list[httpx.Response] = [] @@ -102,11 +112,15 @@ def run(self) -> list[Result]: self.rpc_url = _rpc_url(self.base_url, card) site = (find_extension(card, WEBSITE_EXTENSION_URI) or {}).get("params") or {} health = site.get("health") if isinstance(site.get("health"), str) else DEFAULT_HEALTH_PATH - steps = (("card", lambda: self._card(response, site)), ("any_host", self._any_host), + if self.spec is not None: + health = self.spec.health + steps = [("card", lambda: self._card(response, site)), ("any_host", self._any_host), ("health", lambda: self._health(card, health)), ("universe_now", self._universe_now), ("reserved_paths", self._reserved_paths), ("data_plane", lambda: self._data_plane(card)), ("clock", lambda: self._clock(card, health)), ("forwarded", lambda: self._forwarded(site)), - ("cookies", lambda: self._cookies(site))) + ("cookies", lambda: self._cookies(site))] + if self.spec is not None: + steps += [("spec", lambda: self._spec_card(card, site)), ("spec_routes", lambda: self._spec_routes(card, site))] outcomes = {name: _outcome(step) for name, step in steps if name != "universe_now"} outcomes["universe_now"] = _outcome(self._universe_now) results = [] @@ -271,6 +285,135 @@ def _cookies(self, site: dict) -> tuple[Status, str]: return "passed", f"{len(cookies)} cookie(s), none tied to a domain" + def _spec_card(self, card: dict, site: dict) -> tuple[Status, str]: + """The card's name, domains, health path, routes and session cookie and path against the spec's, none of the + latter for a spec that doesn't sign in by cookie, and its state and clock when the spec declares them.""" + spec, declared = self.spec, self.spec.to_card_extension() + problems = [] + if spec.site.name is not None and card.get("name") != spec.site.name: + problems.append(f"the card is named {card.get('name')!r}, the spec's site {spec.site.name!r}") + if set(site.get("domains") or []) != set(declared["domains"]): + problems.append(f"the card's domains are {site.get('domains')}, the spec's {declared['domains']}") + if site.get("health") != declared["health"]: + problems.append(f"the card's health is {site.get('health')!r}, the spec's {declared['health']!r}") + theirs = {r.get("name"): _route_key(r) for r in site.get("routes") or [] if isinstance(r, dict)} + for route in declared["routes"]: + if route["name"] not in theirs: + problems.append(f"the card lacks route {route['name']}") + elif theirs[route["name"]] != _route_key(route): + problems.append(f"the card's {route['name']} is {_shown(theirs[route['name']])}, the spec's " + f"{_shown(_route_key(route))}") + if extra := sorted(set(theirs) - {r["name"] for r in declared["routes"]}): + problems.append(f"the card has routes the spec doesn't declare: {', '.join(map(str, extra))}") + for key in ("session_cookie", "session_path", "clock"): + if key in declared and site.get(key) != declared[key]: + problems.append(f"the card's {key} is {site.get(key)!r}, the spec's {declared[key]!r}") + if spec.users.sign_in != "cookie": + for key in ("session_cookie", "session_path"): + if site.get(key) is not None: + problems.append(f"the card's {key} is {site[key]!r}, a cookie sign-in, and the spec's sign-in " + f"is {spec.users.sign_in}") + if problems: + return "failed", "; ".join(problems) + return "passed", f"name, {len(declared['domains'])} domain(s), health and {len(declared['routes'])} route(s) match" + + def _spec_routes(self, card: dict, site: dict) -> tuple[Status, str]: + """Every route the spec declares served: an OPTIONS finding something at its path, a GET route without path + parameters answering neither 404 nor 405, and an operation of the server's OpenAPI document. A path + parameter is probed with a value its converter on the card's route of that name takes (``_PROBES``), ``0`` + when the card gives it none. When the card offers no document, a route the OPTIONS alone can check is + served only when the OPTIONS answers below 500 with an ``Allow`` listing its method; any other answer, such + as a catch-all's 200 without ``Allow``, a 500, or an ``Allow`` naming only the methods of another route at + that path, leaves it unverified, which, when nothing failed, is a ``warning``.""" + routes = self.spec.route_table() + served = {r.get("name"): r.get("path") for r in site.get("routes") or [] + if isinstance(r, dict) and isinstance(r.get("path"), str)} + if not routes: + return "skipped", "the spec declares no routes" + problems, unverified = [], [] + offered = find_extension_method(card, WEBSITE_EXTENSION_URI, "openapi") is not None + try: + document = self._openapi(card) + except RuntimeError as e: + problems.append(str(e)) + document = None + for name, route in routes.items(): + card_path = served.get(name) + template = card_path if card_path and _CONVERTER.sub(r"{\1}", card_path) == route.path else route.path + path = _PARAM.sub(_probe, template) + options = self.client.options(self.base_url + path) + if options.status_code == 404: + problems.append(f"{name}: nothing is at {route.method} {route.path}") + continue + if route.method == "GET" and path == route.path: + status = self.get(path).status_code + if status in (404, 405) or status >= 500: + problems.append(f"{name}: GET {route.path} answered {status}") + elif not offered: + allowed = _allowed(options) + if allowed is None or options.status_code >= 500 or route.method not in allowed: + shown = " without an Allow" if allowed is None else f" allowing {', '.join(sorted(allowed))}" + unverified.append(f"{name}: OPTIONS {path} answered {options.status_code}{shown}, which doesn't " + f"show {route.method} {route.path} is served") + if document is not None: + operation = ((document.get("paths") or {}).get(route.path) or {}).get(route.method.lower()) + if not isinstance(operation, dict) or operation.get("operationId") != name: + problems.append(f"{name}: the OpenAPI document has no operation {name} at {route.method} {route.path}") + if problems: + return "failed", "; ".join(problems + unverified) + if unverified: + return "warning", "unverified: " + "; ".join(unverified) + return "passed", f"all {len(routes)} route(s) served" + (", each in the OpenAPI document" if document else "") + + def _openapi(self, card: dict) -> dict | None: + """The OpenAPI document the card's ``website/v1`` ``openapi`` serves, None when it offers none; raises + RuntimeError when one it offers can't be read.""" + method = find_extension_method(card, WEBSITE_EXTENSION_URI, "openapi") + if method is None or not method.get("endpoint"): + return None + response = self.client.request(method["method"], self.base_url + method["endpoint"], + **({"json": {}} if method["method"] != "GET" else {})) + try: + document = response.json() if response.status_code == 200 else None + except ValueError: + document = None + if not isinstance(document, dict): + raise RuntimeError(f"the card's openapi, {method['method']} {method['endpoint']}, answered " + f"{response.status_code} and no OpenAPI document") + return document + + +def _allowed(response: httpx.Response) -> set[str] | None: + """The methods ``response``'s ``Allow`` lists, None when it has none.""" + allow = response.headers.get("allow") + return None if allow is None else {m.strip().upper() for m in allow.split(",") if m.strip()} + + +_PARAM = re.compile(r"{[^}]+}") +_PROBES = {"uuid": "00000000-0000-0000-0000-000000000000"} + + +def _probe(param: re.Match) -> str: + """A value the path parameter ``param`` matches: its converter's from ``_PROBES``, else ``0``, which an int, + float, str or path parameter all take.""" + _, _, converter = param.group(0)[1:-1].partition(":") + return _PROBES.get(converter, "0") + + +_CONVERTER = re.compile(r"{(\w+):[^}]+}") + + +def _route_key(route: dict) -> tuple: + """A route as the card and the spec can both say it: its method, its path with any converter dropped + (``{rest:path}`` is ``{rest}``), and its roles.""" + return route.get("method"), _CONVERTER.sub(r"{\1}", route.get("path") or ""), tuple(sorted(route.get("roles") or [])) + + +def _shown(key: tuple) -> str: + method, path, roles = key + return f"{method} {path}" + (f" for {list(roles)}" if roles else "") + + def _rpc_url(base_url: str, card: dict) -> str: """Where the card says its data plane is: its ``url``, a path joined onto ``base_url`` or a full URL, else ``RPC_PATH``.""" @@ -352,9 +495,15 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--clock-host", default="127.0.0.1", help="the address the server reaches this checker at") parser.add_argument("--timeout", type=float, default=30, help="seconds for each request") parser.add_argument("--strict", action="store_true", help="fail on a recommended check's warning too") + parser.add_argument("--spec", help="a website spec file to check the server against too") args = parser.parse_args(argv) try: - results = check(args.url, clock_host=args.clock_host, timeout=args.timeout) + spec = load(args.spec) if args.spec else None + except SpecError as e: + print("\n".join(e.problems), file=sys.stderr) + return 2 + try: + results = check(args.url, clock_host=args.clock_host, timeout=args.timeout, spec=spec) except httpx.TransportError as e: print(f"couldn't reach {args.url}: {type(e).__name__}: {e}", file=sys.stderr) return 2 diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py index 5c3b1eda8..230ab28f4 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py @@ -47,10 +47,10 @@ from .routes import (ApiContext, ApiRoute, endpoint, error_response, forwarded_prefix, openapi, openapi_path, request_role, reserved_path, routes_of) from .session import SessionSigner, expires_at, session_cookie_name +from .spec import (DEFAULT_HEALTH_PATH, SESSION_PATH, SpecError, WebsiteSpec, from_environment, load, + problems_with_environment) from .store import Collections -DEFAULT_HEALTH_PATH = "/api/health" -SESSION_PATH = "/api/session" _WEBSITE_ATTR = "_aee_website" _READY_ATTR = "_aee_website_ready" _DEFAULT_HOOK_ATTR = "_aee_default_store_hook" @@ -60,8 +60,8 @@ class WebsiteDeclaration(NamedTuple): """What ``@website`` declares: the site's domains, its static folder and seed data file (relative to the class's - file), its health path, whether its HTML pages read the universe's time, and the models whose database keeps its - state instead of the default store.""" + file), its health path, whether its HTML pages read the universe's time, the models whose database keeps its state + instead of the default store, and the spec it was declared from.""" domains: tuple[str, ...] = () static: Optional[str] = None @@ -69,16 +69,23 @@ class WebsiteDeclaration(NamedTuple): clock_patch: bool = False data: Optional[str] = None models: Any = None + spec: Optional[WebsiteSpec] = None def website(_cls: type | None = None, *, domains: list[str] | tuple[str, ...] = (), static: str | None = None, health: str = DEFAULT_HEALTH_PATH, clock_patch: bool = False, data: str | None = None, - models: Any = None) -> Any: + models: Any = None, spec: str | Path | WebsiteSpec | None = None) -> Any: """Declare a WebsiteEnvironment's site. ``data`` names a JSON file the default store starts with; ``models`` keeps the site's data in a database instead, an MCP server's (see ``website.database``), and the site's - data plane is that database's.""" + data plane is that database's. With ``spec``, a website spec file (relative to the class's file) or a loaded one, + the site is the spec's, and the class must serve what it declares; the other arguments but ``models`` are then + the spec's to give.""" if _cls is not None: raise TypeError("use @website(...) with parentheses") + if spec is not None and (domains or static is not None or health != DEFAULT_HEALTH_PATH or clock_patch + or data is not None): + raise TypeError("@website(spec=...) takes the site from its spec; set domains, health, assets, seed and " + "clock there") if not health.startswith("/") or health == "/" or reserved_path(health): raise ValueError(f"health is a path of its own under '/', not {health!r}") if models is not None and data is not None: @@ -89,7 +96,20 @@ def website(_cls: type | None = None, *, domains: list[str] | tuple[str, ...] = def deco(cls: type) -> type: if not (isinstance(cls, type) and issubclass(cls, WebsiteEnvironment)): raise TypeError(f"@website declares a WebsiteEnvironment subclass, not {cls!r}") - setattr(cls, _WEBSITE_ATTR, WebsiteDeclaration(tuple(domains), static, health, clock_patch, data, models)) + if spec is None: + setattr(cls, _WEBSITE_ATTR, WebsiteDeclaration(tuple(domains), static, health, clock_patch, data, models)) + else: + declared = spec if isinstance(spec, WebsiteSpec) else load(cls._site_path(str(spec))) + if models is not None and declared.data.seed: + raise ValueError("the spec's data.seed seeds the default store; a site of models= is seeded through " + "data/add") + source = declared.source + assets = str(declared.path(source.assets)) if source is not None and source.assets else None + setattr(cls, _WEBSITE_ATTR, WebsiteDeclaration( + declared.site.domains, assets, declared.health, + declared.clock is not None and declared.clock.follow == "date_patch", None, models, declared)) + if problems := problems_with_environment(declared, cls): + raise SpecError([f"{cls.__name__} doesn't serve its spec: {p}" for p in problems]) _check_health(cls) return cls @@ -122,13 +142,12 @@ def __init_subclass__(cls, **kwargs: Any) -> None: setattr(cls, name, None) def __init__(self) -> None: - declaration = self.declaration() self.store = Collections() - if declaration.data is not None: - self.store.load(json.loads(self._site_path(declaration.data).read_text(encoding="utf-8"))) + for seed in self._seed_files(): + self.store.load(json.loads(seed.read_text(encoding="utf-8"))) self.clock = UniverseClock() self._signer = SessionSigner(cookie_name=session_cookie_name(self.environment_name())) - models = declaration.models + models = self.declaration().models self._tables = (None if models is None or is_hooks(models) else Tables(metadata_of(models), lambda: self.database_url)) self._models = None if models is None else ModelState(models if self._tables is None else self._tables) @@ -144,15 +163,31 @@ def routes(cls) -> list[ApiRoute]: def environment_name(self) -> str: """The site's name, as its card names it: ``ENVIRONMENT_NAME``, else the card's declared name, else the - class's.""" + spec's, else the class's.""" declared = (getattr(self, _CARD_CONFIG_ATTR, None) or {}).get("name") - return os.environ.get("ENVIRONMENT_NAME") or declared or type(self).__name__ + spec = self.declaration().spec + return (os.environ.get("ENVIRONMENT_NAME") or declared or (spec.site.name if spec is not None else None) + or type(self).__name__) @property def session_cookie(self) -> str: """The name of the site's session cookie, ``agentenv_session_``.""" return self._session_signer().cookie_name + @classmethod + def site_spec(cls) -> WebsiteSpec: + """The site's spec: the one ``@website(spec=...)`` names with what the class serves filled in, else the one + its decorators declare. Its card's website/v1 params are this spec's ``to_card_extension()``, with the + session cookie the running site's.""" + return from_environment(cls) + + @classmethod + def _seed_files(cls) -> list[Path]: + declaration = cls.declaration() + if declaration.spec is not None: + return [declaration.spec.path(seed) for seed in declaration.spec.data.seed] + return [cls._site_path(declaration.data)] if declaration.data is not None else [] + @property def database_url(self) -> str | None: """Where the site keeps its task state: its own schema of the env's database.""" @@ -219,13 +254,31 @@ def serve(self) -> None: port=int(os.environ.get("PORT", "8000"))) def extension_params(self, uri: str) -> dict: + """``website/v1``'s params are the spec's card params, each route named by the path the class serves it at, + converters and all (``/files/{rest:path}``), so a gateway matches the requests it answers, nested paths + included; a spec names a path by its template.""" if uri == WEBSITE_EXTENSION_URI: - declaration = self.declaration() - return {"domains": list(declaration.domains), "health": declaration.health, - "routes": [r.advertised() for r in self.routes()], "session_cookie": self.session_cookie, - "session_path": SESSION_PATH} + params = {**self.site_spec().to_card_extension(), "session_cookie": self.session_cookie, + "session_path": SESSION_PATH} + served = {r.name: r.path for r in self.routes()} + params["routes"] = [{**r, "path": served.get(r["name"], r["path"])} for r in params["routes"]] + return params return super().extension_params(uri) + def _build_card(self) -> Any: + """The card, named by the spec's site name when neither ``ENVIRONMENT_NAME`` nor ``@environment_card`` + names it; a card name and a spec name that differ are refused.""" + card = super()._build_card() + spec = self.declaration().spec + named = (getattr(self, _CARD_CONFIG_ATTR, None) or {}).get("name") + if spec is None or spec.site.name is None: + return card + if named is not None and named != spec.site.name: + raise SpecError([f"{type(self).__name__}: @environment_card names it {named!r}, its spec " + f"{spec.site.name!r}"]) + return card if named is not None or os.environ.get("ENVIRONMENT_NAME") else card.model_copy( + update={"name": spec.site.name}) + @extension(CLOCK_EXTENSION_URI, description="Sync this site to the gateway's virtual clock.") async def sync_time(self, env_get_time_url: str, timezone: Optional[str] = None) -> dict: """Follow the gateway's virtual time at ``env_get_time_url`` from now on, in ``timezone`` when the task names diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/spec/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/website/spec/__init__.py new file mode 100644 index 000000000..aa0fe5ce1 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/spec/__init__.py @@ -0,0 +1,1027 @@ +"""The website spec: one declarative description of a site that every way of serving one produces or is checked +against. A spec is a TOML file of fixed sections, each optional: ``[site]`` (name, domains, health path), +``[source]`` (exactly one of ``image``, ``sdk``, ``static``, ``live``, ``replay``), ``[data]``, ``[users]``, +``[roles]``, ``[routes]``, ``[clock]`` and ``[[uses]]``, with ``extends`` naming a spec it overlays. + +``load(path)`` reads one, resolves ``extends`` (a deep merge: tables merge key by key, arrays and scalars replace, +``[source]`` replaces whole, ``false`` removes an inherited route, role or collection, and ``[[uses]]`` entries are +added, one with the same ``as`` replacing the inherited one) and composes ``[[uses]]`` (each component's routes +mounted under its path prefix and named ``.``, its collections namespaced ``.`` unless mapped +into one of the site's, its personas merged), and validates the result, raising ``SpecError`` with every problem. +A resolved spec has no ``extends``, ``uses`` or ``routes.openapi`` left, the document's operations being in +``routes.ops`` (less any an overlay removed); ``components`` says what was mounted. + +``to_card_extension()`` is the ``urn:agentenv:website/v1`` params a server of the spec advertises, ``from_card`` +reads a running site's card back into a spec, ``from_environment`` derives one from a WebsiteEnvironment class's +decorators, and ``name_table`` builds a multi env's one table of host names, synthetic before live before +allowlisted. ``python -m agentenv_protocol.website.spec validate|show|schema`` is its command line.""" +from __future__ import annotations +from ..readiness import WEBSITE_EXTENSION_URI + +import argparse +import copy +import dataclasses +import inspect +import json +import os +import re +import sys +from pathlib import Path +from typing import Any, Iterable, Literal, Optional, Union + +from pydantic import (BaseModel, ConfigDict, Field, PrivateAttr, ValidationError, ValidationInfo, field_validator, + model_validator) + +from ...agent_env_environment import _CARD_CONFIG_ATTR +from ...client import find_extension +from ..routes import openapi_path, reserved_path +from ..session import session_cookie_name + +try: + import tomllib +except ImportError: + try: + import tomli as tomllib + except ImportError: + tomllib = None + +DEFAULT_HEALTH_PATH = "/api/health" +SESSION_PATH = "/api/session" +METHODS = ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") +SOURCE_KINDS = ("image", "sdk", "static", "live", "replay") +NameKind = Literal["synthetic", "live", "allowlist"] +_PRECEDENCE: dict[str, int] = {"synthetic": 0, "live": 1, "allowlist": 2} +_HOST = re.compile(r"(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?") +_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z_][A-Za-z0-9_-]*)*") +_SDK_REF = re.compile(r"[A-Za-z_][\w.]*:[A-Za-z_<][\w.<>]*") +_SECRET_REF = re.compile(r"(secret|env):[A-Za-z_][A-Za-z0-9_./-]*") +_PATH = re.compile(r"/(?:[A-Za-z0-9._~!$&'()*+,;=:@/{}-]|%[0-9A-Fa-f]{2})*") +_PATH_FIELDS: tuple[tuple[str, ...], ...] = ( + ("source", "image", "dockerfile"), ("source", "image", "context"), ("source", "static"), ("source", "assets"), + ("source", "replay", "capture"), ("data", "schema"), ("routes", "openapi")) +_REMOVABLE = {("routes", "ops"), ("roles",), ("data", "collections")} + + +class SpecError(ValueError): + """A spec that can't be read or doesn't hold together; ``problems`` lists each, ``: ``.""" + + def __init__(self, problems: Iterable[str]) -> None: + self.problems = list(problems) + super().__init__("\n".join(self.problems)) + + +class _Section(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True) + + +class Site(_Section): + """Who the site is: its name (the card's), the host names it answers for, ``*.`` as a leftmost label matching + any subdomain, and the path that answers 200 once it's usable.""" + + name: Optional[str] = None + domains: tuple[str, ...] = () + health: Optional[str] = None + + @field_validator("domains") + @classmethod + def _domains(cls, domains: tuple[str, ...]) -> tuple[str, ...]: + bad = [d for d in domains if not _HOST.fullmatch(d)] + if bad: + raise ValueError(f"{bad} aren't host names in lower case, '*.' only as the leftmost label") + return domains + + @field_validator("health") + @classmethod + def _health(cls, health: Optional[str]) -> Optional[str]: + if health is not None and (not _PATH.fullmatch(health) or health == "/" or reserved_path(health)): + raise ValueError(f"health is a path of its own under '/', not {health!r}") + return health + + +class ImageSource(_Section): + """A server image built from a Dockerfile, both relative to the spec.""" + + dockerfile: str + context: str = "." + + +class LiveSource(_Section): + """The real site, reached through the env: the hosts besides its domains it may call, and the credentials it + signs in with, each a ``secret:`` or ``env:`` reference, never a value.""" + + allow_hosts: tuple[str, ...] = () + credentials: dict[str, str] = {} + + @field_validator("allow_hosts") + @classmethod + def _hosts(cls, hosts: tuple[str, ...]) -> tuple[str, ...]: + return Site._domains(hosts) + + @field_validator("credentials") + @classmethod + def _credentials(cls, credentials: dict[str, str]) -> dict[str, str]: + bad = sorted(name for name, ref in credentials.items() if not _SECRET_REF.fullmatch(ref)) + if bad: + raise ValueError(f"credentials {bad} must be secret: or env: references, not values") + return credentials + + +class ReplaySource(_Section): + """A capture of the site served back: the artifact, relative to the spec, and the hosts still let through.""" + + capture: str + allow_hosts: tuple[str, ...] = () + + @field_validator("allow_hosts") + @classmethod + def _hosts(cls, hosts: tuple[str, ...]) -> tuple[str, ...]: + return Site._domains(hosts) + + +class Source(_Section): + """What serves the site, exactly one of: an ``image`` (a reference, or a table building one), an ``sdk`` + WebsiteEnvironment class as ``module:Class`` with its static ``assets`` folder, a ``static`` folder, the + ``live`` real site, or a ``replay`` of a capture.""" + + image: Union[str, ImageSource, None] = None + sdk: Optional[str] = None + static: Optional[str] = None + live: Optional[LiveSource] = None + replay: Optional[ReplaySource] = None + assets: Optional[str] = None + + @model_validator(mode="after") + def _one(self) -> Source: + given = [kind for kind in SOURCE_KINDS if getattr(self, kind) is not None] + if len(given) != 1: + raise ValueError(f"names exactly one of {', '.join(SOURCE_KINDS)}, not {given or 'none'}") + if self.sdk is not None and not _SDK_REF.fullmatch(self.sdk): + raise ValueError(f"sdk is module:Class, not {self.sdk!r}") + if self.assets is not None and self.sdk is None: + raise ValueError("assets is the static folder of an sdk source") + return self + + @property + def kind(self) -> str: + return next(kind for kind in SOURCE_KINDS if getattr(self, kind) is not None) + + +class Collection(_Section): + """One collection of the site's data: the field naming each object (``key``), the field naming the user who + owns one (``owner``), and a JSON Schema of an object, inline or a file relative to the spec.""" + + key: str = "id" + owner: Optional[str] = None + schema_: Union[str, dict, None] = Field(default=None, alias="schema") + + +class Data(_Section): + """The site's data: its collections, or one JSON Schema of the whole state, or ``models = "mcp:"`` + binding it to an MCP server's schema; and the seed files it starts with, in order.""" + + collections: dict[str, Collection] = {} + schema_: Union[str, dict, None] = Field(default=None, alias="schema") + models: Optional[str] = None + seed: tuple[str, ...] = () + + @field_validator("seed", mode="before") + @classmethod + def _seed(cls, seed: Any) -> Any: + return (seed,) if isinstance(seed, str) else seed + + @field_validator("models") + @classmethod + def _models(cls, models: Optional[str]) -> Optional[str]: + if models is not None and not re.fullmatch(r"mcp:[A-Za-z0-9_.-]+", models): + raise ValueError(f"models is mcp:, not {models!r}") + return models + + @model_validator(mode="after") + def _schema_or_models(self) -> Data: + if self.schema_ is not None and self.models is not None: + raise ValueError("data takes a schema or models, not both") + if bad := sorted(name for name in self.collections if not _NAME.fullmatch(name)): + raise ValueError(f"collection names {bad} aren't names") + return self + + +class Persona(_Section): + id: str + name: Optional[str] = None + role: Optional[str] = None + + +class Users(_Section): + """Who uses the site: its personas, the acting user a browser with no session is signed in as, and how a + browser signs in: ``cookie`` (the site's signed session cookie, as ``session_path`` hands it out), ``none``, or + ``live``, the real site's own sign-in.""" + + personas: tuple[Persona, ...] = () + acting_user: Optional[str] = None + sign_in: Literal["cookie", "none", "live"] = "cookie" + session_cookie: Optional[str] = None + session_path: Optional[str] = None + + @field_validator("personas", mode="before") + @classmethod + def _personas(cls, personas: Any) -> Any: + return [{"id": p} if isinstance(p, str) else p for p in personas] if isinstance(personas, list) else personas + + @model_validator(mode="after") + def _consistent(self) -> Users: + ids = [p.id for p in self.personas] + if twice := sorted({i for i in ids if ids.count(i) > 1}): + raise ValueError(f"personas {twice} are declared twice") + if self.acting_user is not None and ids and self.acting_user not in ids: + raise ValueError(f"acting_user {self.acting_user!r} is none of the personas {ids}") + if self.sign_in != "cookie" and (self.session_cookie or self.session_path): + raise ValueError("session_cookie and session_path are a cookie sign-in's") + return self + + +class Route(_Section): + """One named operation: its verb, its OpenAPI-style path, the roles it answers (any when empty), and, for a site + served by a live or replayed source, the ``module:Class`` whose route of the same name answers it instead.""" + + method: str + path: str + roles: tuple[str, ...] = () + served_by: Optional[str] = None + + @model_validator(mode="before") + @classmethod + def _shorthand(cls, value: Any) -> Any: + if isinstance(value, str): + method, _, path = value.strip().partition(" ") + value = {"method": method, "path": path.strip()} + if isinstance(value, dict): + value = dict(value) + if isinstance(value.get("method"), str): + value["method"] = value["method"].upper() + if isinstance(value.get("path"), str): + value["path"] = openapi_path(value["path"]) + return value + + @model_validator(mode="after") + def _route(self) -> Route: + if self.method not in METHODS: + raise ValueError(f"method is one of {', '.join(METHODS)}, not {self.method!r}") + if not _PATH.fullmatch(self.path): + raise ValueError(f"path starts with '/', not {self.path!r}") + if reserved_path(self.path): + raise ValueError(f"path {self.path!r} is the data plane's or the card's") + if self.served_by is not None and not _SDK_REF.fullmatch(self.served_by): + raise ValueError(f"served_by is module:Class, not {self.served_by!r}") + return self + + def advertised(self, name: str) -> dict: + return {"method": self.method, "path": self.path, "name": name, "roles": list(self.roles) or None} + + +class Routes(_Section): + """The site's named operations, ``name = "METHOD /path"`` or a table, and an OpenAPI document whose operations, + by ``operationId``, are routes too; the names are what triggers and records call ``op``.""" + + openapi: Optional[str] = None + ops: dict[str, Route] = {} + + @field_validator("ops") + @classmethod + def _names(cls, ops: dict[str, Route], info: ValidationInfo) -> dict[str, Route]: + if (info.context or {}).get("declared_routes"): + return ops + if bad := sorted(name for name in ops if not _NAME.fullmatch(name)): + raise ValueError(f"route names {bad} aren't names") + return ops + + +class Clock(_Section): + """How the site follows the universe's time: ``header`` (its answers carry ``X-Universe-Now`` and its pages read + it), ``date_patch`` (its pages' ``Date`` is shifted too), or ``none`` (wall time).""" + + follow: Literal["header", "date_patch", "none"] = "header" + + +class Use(_Section): + """A component: another spec, relative to this one, mounted under ``mount``, named ``as`` (its site's name when + left out), with ``data`` mapping a collection of the component's onto one of this site's.""" + + spec: str + mount: str = "/" + as_: Optional[str] = Field(default=None, alias="as") + data: dict[str, str] = {} + + @field_validator("mount") + @classmethod + def _mount(cls, mount: str) -> str: + if not _PATH.fullmatch(mount) or "{" in mount or reserved_path(mount): + raise ValueError(f"mount is a plain path under '/', not {mount!r}") + return mount.rstrip("/") or "/" + + +@dataclasses.dataclass(frozen=True) +class Component: + """A component a resolved spec mounted: its name, its path prefix, and its own resolved spec.""" + + name: str + mount: str + spec: WebsiteSpec + + +class WebsiteSpec(_Section): + """A site as one declaration. ``load`` reads and resolves one; a spec built from a dict is resolved by + ``resolve``. Paths in it are relative to ``base_dir``.""" + + extends: Optional[str] = None + site: Site = Site() + source: Optional[Source] = None + data: Data = Data() + users: Users = Users() + roles: dict[str, tuple[str, ...]] = {} + routes: Routes = Routes() + clock: Optional[Clock] = None + uses: tuple[Use, ...] = () + + _base_dir: Path = PrivateAttr(default_factory=Path.cwd) + _components: tuple[Component, ...] = PrivateAttr(default=()) + _declared: Optional[dict] = PrivateAttr(default=None) + _declared_routes: bool = PrivateAttr(default=False) + + def __eq__(self, other: object) -> bool: + """Two specs are equal when they resolve to the same site in the same folder, however each was declared.""" + if not isinstance(other, WebsiteSpec): + return NotImplemented + return (type(self), self.__dict__, self._base_dir, self._components) == ( + type(other), other.__dict__, other._base_dir, other._components) + + __hash__ = None + + @property + def base_dir(self) -> Path: + return self._base_dir + + @property + def components(self) -> tuple[Component, ...]: + return self._components + + @property + def name(self) -> Optional[str]: + return self.site.name + + @property + def health(self) -> str: + return self.site.health or DEFAULT_HEALTH_PATH + + def path(self, relative: str) -> Path: + """``relative``, a path the spec names, as a path on disk.""" + given = Path(relative) + return given if given.is_absolute() else self._base_dir / given + + def route_table(self) -> dict[str, Route]: + """Every route by name, each with the roles ``[roles]`` grants it added to its own.""" + granted: dict[str, list[str]] = {} + for role, names in self.roles.items(): + for name in names: + granted.setdefault(name, []).append(role) + return {name: route.model_copy(update={"roles": tuple(dict.fromkeys([*route.roles, *granted.get(name, [])]))}) + for name, route in self.routes.ops.items()} + + def to_dict(self) -> dict: + """The spec as a JSON-able dict, empty sections left out, which ``resolve`` reads back as the same spec.""" + return _pruned(self.model_dump(mode="json", by_alias=True, exclude_none=True)) + + def overlay(self, changes: dict, *, base_dir: Path | str | None = None) -> WebsiteSpec: + """This spec with ``changes`` merged over it as an ``extends`` overlay merges, then resolved again; paths in + ``changes`` are relative to ``base_dir``, this spec's own when left out. A spec ``resolve`` or ``load`` made + is merged into as it was declared, its ``[[uses]]`` not yet mounted, so a component of the same ``as`` + replaces the mounted one as it would in a file overlay. A spec read from a class or a card keeps the route + names its server serves, as it was read.""" + here = Path(base_dir) if base_dir is not None else self._base_dir + declared = self._declared if self._declared is not None else self.to_dict() + merged = _merge(declared, _expanded(_rebased(changes, here, self._base_dir))) + return resolve(merged, self._base_dir, declared_routes=self._declared_routes) + + def to_card_extension(self) -> dict: + """The ``urn:agentenv:website/v1`` params a server of this spec advertises: its domains, health path and + routes by name, its session cookie and path for a cookie sign-in, and how its clock is followed, when + declared.""" + params: dict = {"domains": list(self.site.domains), "health": self.health, + "routes": [route.advertised(name) for name, route in self.route_table().items()]} + if self.users.sign_in == "cookie": + params["session_cookie"] = self.users.session_cookie or session_cookie_name(self.site.name) + params["session_path"] = self.users.session_path or SESSION_PATH + if self.clock is not None: + params["clock"] = {"follow": self.clock.follow} + return params + + +def load(path: str | Path) -> WebsiteSpec: + """The spec in the TOML file at ``path``, resolved: its ``extends`` merged, its ``uses`` composed, checked.""" + return _load(Path(path).resolve(), ()) + + +def resolve(raw: dict, base_dir: str | Path, *, origin: str = "spec", declared_routes: bool = False) -> WebsiteSpec: + """The spec ``raw`` declares, paths in it relative to ``base_dir``, resolved as ``load`` resolves a file's. With + ``declared_routes``, its routes are the ones a class's ``@api`` decorators declare, named as they take any name.""" + return _resolve(copy.deepcopy(raw), Path(base_dir).resolve(), origin, (), declared_routes=declared_routes) + + +def _load(path: Path, stack: tuple[Path, ...]) -> WebsiteSpec: + return _resolve(_read(path), path.parent, str(path), stack + (path,)) + + +def _read(path: Path) -> dict: + if tomllib is None: + raise SpecError([f"{path}: reading a TOML spec needs Python 3.11's tomllib, or tomli before it"]) + try: + return tomllib.loads(path.read_text(encoding="utf-8")) + except OSError as e: + raise SpecError([f"{path}: can't be read: {e.strerror or e}"]) from None + except tomllib.TOMLDecodeError as e: + raise SpecError([f"{path}: isn't TOML: {e}"]) from None + + +def _resolve(raw: dict, base_dir: Path, origin: str, stack: tuple[Path, ...], *, + declared_routes: bool = False) -> WebsiteSpec: + """The spec ``raw`` declares, resolved. A spec that uses components has its acting user checked only once their + personas are merged into its own, since the acting user may be one only a component declares.""" + raw = _extended(raw, base_dir, origin, stack) + declared = _expanded(copy.deepcopy(raw)) + removed = _removals(raw) + problems: list[str] = [] + imported = _imported(raw, base_dir, removed, problems) + users = raw.get("users") + spec = _validated({**raw, "users": {k: v for k, v in users.items() if k != "acting_user"}} + if raw.get("uses") and isinstance(users, dict) else raw, origin, declared_routes=declared_routes) + spec._base_dir = base_dir + ops = dict(imported) + for name, route in spec.routes.ops.items(): + if name in ops and (ops[name].method, ops[name].path) != (route.method, route.path): + problems.append(f"routes.ops.{name}: {route.method} {route.path} isn't the openapi document's " + f"{ops[name].method} {ops[name].path}") + ops[name] = route + raw_routes = raw.setdefault("routes", {}) + raw_routes["ops"] = {name: route.model_dump(mode="json", exclude_none=True, exclude_defaults=True) | { + "method": route.method, "path": route.path} for name, route in ops.items()} + raw_routes.pop("openapi", None) + components = [] + for index, use in enumerate(spec.uses): + component = _component(use, index, spec, stack, problems) + if component is not None: + components.append(component) + _mount(raw, component, use, base_dir, f"uses[{index}]", problems) + raw.pop("uses", None) + if problems: + raise SpecError(f"{origin}: {p}" for p in problems) + resolved = _validated(raw, origin, declared_routes=declared_routes) + resolved._base_dir, resolved._components, resolved._declared = base_dir, tuple(components), declared + resolved._declared_routes = declared_routes + problems = _check(resolved) + if problems: + raise SpecError(f"{origin}: {p}" for p in problems) + return resolved + + +def _extended(raw: dict, base_dir: Path, origin: str, stack: tuple[Path, ...]) -> dict: + """``raw`` merged over the spec its ``extends`` names, recursively, that spec's paths made relative to + ``base_dir``.""" + parent = raw.pop("extends", None) + if parent is None: + return raw + if not isinstance(parent, str): + raise SpecError([f"{origin}: extends is the path of a spec, not {parent!r}"]) + path = (base_dir / parent).resolve() + if path in stack: + raise SpecError([f"{origin}: extends {parent} is a cycle: {' -> '.join(str(p) for p in (*stack, path))}"]) + base = _extended(_read(path), path.parent, str(path), stack + (path,)) + return _merge(_expanded(_rebased(base, path.parent, base_dir)), _expanded(raw)) + + +def _expanded(raw: dict) -> dict: + """``raw`` with each ``"METHOD /path"`` route written as its table, so an overlay can change one key of it.""" + ops = (raw.get("routes") or {}).get("ops") if isinstance(raw.get("routes"), dict) else None + if isinstance(ops, dict): + for name, route in ops.items(): + if isinstance(route, str): + method, _, path = route.strip().partition(" ") + ops[name] = {"method": method, "path": path.strip()} + return raw + + +def _removals(raw: dict) -> set[str]: + """The names of the routes ``raw``'s merged overlays removed, each a ``false`` left in ``routes.ops`` so it + removes an operation of the OpenAPI document too, taken out of ``raw``.""" + routes = raw.get("routes") + ops = routes.get("ops") if isinstance(routes, dict) else None + if not isinstance(ops, dict): + return set() + removed = {name for name, route in ops.items() if route is False} + for name in removed: + del ops[name] + return removed + + +def _validated(raw: dict, origin: str, *, declared_routes: bool = False) -> WebsiteSpec: + try: + return WebsiteSpec.model_validate(raw, context={"declared_routes": declared_routes}) + except ValidationError as e: + raise SpecError(f"{origin}: {_where(err['loc'])}: {_why(err)}" for err in e.errors()) from None + + +def _where(loc: tuple) -> str: + where = "" + for part in loc: + if isinstance(part, int): + where += f"[{part}]" + elif part not in ("str", "dict", "ImageSource") and "[" not in part: + where += f".{part}" if where else part + return where or "spec" + + +def _why(err: dict) -> str: + message = err["msg"] + return message.removeprefix("Value error, ") if err["type"] != "extra_forbidden" else "isn't a key the spec takes" + + +def _merge(base: Any, over: Any, at: tuple[str, ...] = ()) -> Any: + """``over`` merged over ``base``: tables key by key, ``[source]`` whole, ``false`` removing an entry of + routes, roles or collections, ``[[uses]]`` added by ``as``, and anything else replaced. A removed route stays a + ``false``, so that resolving the merge removes it from the OpenAPI document's operations as well.""" + if not (isinstance(base, dict) and isinstance(over, dict)) or at == ("source",): + return copy.deepcopy(over) + merged = copy.deepcopy(base) + for key, value in over.items(): + if value is False and at == ("routes", "ops"): + merged[key] = False + elif value is False and at in _REMOVABLE: + merged.pop(key, None) + elif key == "uses" and not at and isinstance(value, list) and isinstance(merged.get("uses", []), list): + kept = {_use_id(u): u for u in merged.get("uses", [])} + kept.update({_use_id(u): copy.deepcopy(u) for u in value}) + merged["uses"] = list(kept.values()) + elif key in merged: + merged[key] = _merge(merged[key], value, (*at, key)) + else: + merged[key] = copy.deepcopy(value) + return merged + + +def _use_id(use: Any) -> str: + return str(use.get("as") or use.get("spec")) if isinstance(use, dict) else repr(use) + + +def _rebased(raw: dict, from_dir: Path, to_dir: Path) -> dict: + """``raw`` with each relative path it names, relative to ``from_dir``, made relative to ``to_dir``; an image + source's context is moved even when left to its default, so it stays the folder its own spec meant.""" + if from_dir == to_dir: + return copy.deepcopy(raw) + raw = copy.deepcopy(raw) + + def moved(value: Any) -> Any: + return _moved(value, from_dir, to_dir) + + image = raw.get("source", {}).get("image") if isinstance(raw.get("source"), dict) else None + if isinstance(image, dict): + image.setdefault("context", ImageSource.model_fields["context"].default) + for keys in _PATH_FIELDS: + holder = raw + for key in keys[:-1]: + holder = holder.get(key) if isinstance(holder, dict) else None + if isinstance(holder, dict) and keys[-1] in holder: + holder[keys[-1]] = moved(holder[keys[-1]]) + data = raw.get("data") if isinstance(raw.get("data"), dict) else {} + if isinstance(data.get("seed"), (str, list)): + data["seed"] = moved(data["seed"]) if isinstance(data["seed"], str) else [moved(s) for s in data["seed"]] + collections = data.get("collections") + for collection in collections.values() if isinstance(collections, dict) else (): + if isinstance(collection, dict) and "schema" in collection: + collection["schema"] = moved(collection["schema"]) + uses = raw.get("uses") + for use in uses if isinstance(uses, list) else (): + if isinstance(use, dict) and "spec" in use: + use["spec"] = moved(use["spec"]) + return raw + + +def _moved(value: Any, from_dir: Path, to_dir: Path) -> Any: + """``value``, a path relative to ``from_dir``, made relative to ``to_dir``; anything else as it is.""" + if not isinstance(value, str) or Path(value).is_absolute(): + return value + return os.path.relpath(from_dir / value, to_dir) + + +def _imported(raw: dict, base_dir: Path, removed: set[str], problems: list[str]) -> dict[str, Route]: + """The routes of ``raw``'s OpenAPI document that no overlay removed, each of ``raw``'s ``routes.ops`` tables + naming one of them written over it, so an overlay can change one key of an imported operation as it can of a + declared one.""" + routes = raw.get("routes") if isinstance(raw.get("routes"), dict) else {} + document = routes.get("openapi") + if not isinstance(document, str): + return {} + imported = {name: route for name, route in _openapi_routes(document, base_dir, problems).items() + if name not in removed} + ops = routes.get("ops") + for name, route in (ops.items() if isinstance(ops, dict) else ()): + if isinstance(route, dict) and name in imported: + ops[name] = imported[name].model_dump(mode="json", exclude_none=True) | route + return imported + + +def _openapi_routes(document: str, base_dir: Path, problems: list[str]) -> dict[str, Route]: + """The routes of the OpenAPI document at ``document``, relative to ``base_dir``, by operationId, roles from + ``x-agentenv-roles``; a document of another shape, an operation without a name and a name two operations share + are problems.""" + try: + parsed = json.loads((base_dir / document).read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + problems.append(f"routes.openapi: {document} isn't a readable JSON document: {e}") + return {} + paths = (parsed.get("paths") or {}) if isinstance(parsed, dict) else None + if not isinstance(paths, dict): + problems.append(f"routes.openapi: {document} isn't an OpenAPI document, an object whose paths " + "are an object") + return {} + routes: dict[str, Route] = {} + for path, operations in paths.items(): + if not isinstance(operations, dict): + problems.append(f"routes.openapi: path {path} isn't an object of operations") + continue + for method, operation in operations.items(): + if method.upper() not in METHODS or not isinstance(operation, dict): + continue + name = operation.get("operationId") + if not name or not isinstance(name, str): + problems.append(f"routes.openapi: {method.upper()} {path} has no operationId to name it by") + continue + if name in routes: + problems.append(f"routes.openapi: {name} names both {routes[name].method} {routes[name].path} and " + f"{method.upper()} {path}") + continue + try: + routes[name] = Route(method=method, path=path, roles=operation.get("x-agentenv-roles") or ()) + except ValidationError as e: + problems.append(f"routes.openapi: {name}: {_why(e.errors()[0])}") + return routes + + +def _component(use: Use, index: int, host: WebsiteSpec, stack: tuple[Path, ...], + problems: list[str]) -> Optional[Component]: + path = host.path(use.spec).resolve() + if path in stack: + problems.append(f"uses[{index}]: {use.spec} is a cycle: {' -> '.join(str(p) for p in (*stack, path))}") + return None + try: + spec = _load(path, stack) + except SpecError as e: + problems.extend(f"uses[{index}]: {p}" for p in e.problems) + return None + name = use.as_ or spec.site.name + if name is None: + problems.append(f"uses[{index}]: {use.spec} has no site.name; name it with as") + return None + return Component(name, use.mount, spec) + + +def _mount(raw: dict, component: Component, use: Use, base_dir: Path, where: str, problems: list[str]) -> None: + """Merge ``component`` into ``raw``, whose paths are relative to ``base_dir``: its routes prefixed and + namespaced, its collections namespaced or mapped, their schema files made relative to ``base_dir``, its personas + added; every clash is a problem.""" + ns, spec = component.name, component.spec + ops = raw["routes"]["ops"] + taken = {(r["method"], r["path"]): name for name, r in ops.items()} + if use.mount != "/": + for name, r in ops.items(): + if r["path"] == use.mount or r["path"].startswith(use.mount + "/"): + problems.append(f"{where}: route {name} ({r['method']} {r['path']}) is under {use.mount}, where " + f"{ns} is mounted") + for name, route in spec.route_table().items(): + full, path = f"{ns}.{name}", _prefixed(use.mount, route.path) + if full in ops: + problems.append(f"{where}: route {full} is declared by the site and by component {ns}") + elif (route.method, path) in taken: + problems.append(f"{where}: {ns}'s route {name} is {route.method} {path}, which {taken[(route.method, path)]} " + "already serves") + else: + ops[full] = {**route.model_dump(mode="json", exclude_none=True, exclude_defaults=True), + "method": route.method, "path": path} + taken[(route.method, path)] = full + data = raw.setdefault("data", {}) + collections = data.setdefault("collections", {}) + if missing := sorted(set(use.data) - set(spec.data.collections)): + problems.append(f"{where}: data maps {missing}, which {ns} doesn't declare") + for name, collection in spec.data.collections.items(): + target = use.data.get(name, f"{ns}.{name}") + theirs = collection.model_dump(mode="json", by_alias=True, exclude_none=True) + if "schema" in theirs: + theirs["schema"] = _moved(theirs["schema"], spec.base_dir, base_dir) + ours = collections.get(target) + if ours is None: + collections[target] = theirs + continue + merged = {**theirs, **ours} + clash = [k for k in ("key", "owner", "schema") if k in ours and k in theirs and ours[k] != theirs[k]] + clash += ["key"] if "key" not in ours and theirs.get("key", "id") != "id" else [] + if clash: + problems.append(f"{where}: {ns}'s collection {name} maps onto {target}, whose {', '.join(clash)} differ") + collections[target] = merged + users = raw.setdefault("users", {}) + personas = {(p["id"] if isinstance(p, dict) else p): ({"id": p} if isinstance(p, str) else p) + for p in users.get("personas", [])} + for persona in spec.users.personas: + theirs, ours = persona.model_dump(mode="json", exclude_none=True), personas.get(persona.id, {}) + if any(key in ours and ours[key] != value for key, value in theirs.items()): + problems.append(f"{where}: persona {persona.id} is declared otherwise by {ns}") + personas[persona.id] = {**theirs, **ours} + if personas: + users["personas"] = list(personas.values()) + sign_in = users.get("sign_in", "cookie") + if spec.users.sign_in not in ("none", sign_in): + problems.append(f"{where}: {ns} signs in by {spec.users.sign_in}, the site by {sign_in}") + + +def _prefixed(mount: str, path: str) -> str: + return path if mount == "/" else mount + path + + +def _check(spec: WebsiteSpec) -> list[str]: + """What doesn't hold together across sections of a resolved spec.""" + problems = [] + table = spec.route_table() + seen: dict[tuple[str, str], str] = {} + for name, route in table.items(): + if (route.method, route.path) in seen: + problems.append(f"routes.ops: {name} and {seen[route.method, route.path]} are both {route.method} {route.path}") + seen.setdefault((route.method, route.path), name) + if ("GET", spec.health) in seen: + problems.append(f"site.health: {spec.health} is served by route {seen['GET', spec.health]}") + for role, names in spec.roles.items(): + if unknown := [n for n in names if n not in table]: + problems.append(f"roles.{role}: names no route {unknown}") + kind = spec.source.kind if spec.source is not None else None + if spec.users.sign_in == "live" and kind not in ("live", "replay"): + problems.append("users.sign_in: live is the sign-in of a live or replayed source") + if kind == "sdk" and spec.clock is not None and spec.clock.follow == "none": + problems.append("clock.follow: an sdk site follows the universe's clock; none is a live site's") + if kind == "static" and table: + problems.append("routes: a static site serves no routes") + if served := [n for n, r in table.items() if r.served_by is not None and kind not in ("live", "replay")]: + problems.append(f"routes.ops: served_by swaps a route of a live or replayed site, not of {kind or 'no'} " + f"source ({served})") + for name, collection in spec.data.collections.items(): + if collection.owner is not None and spec.users.sign_in == "none": + problems.append(f"data.collections.{name}: owner names a user, and the site signs nobody in") + return problems + + +def _pruned(value: Any, at: tuple[str, ...] = ()) -> Any: + """``value`` without its empty tables and arrays, but for a JSON Schema, which is kept as it is.""" + if _is_schema(at): + return value + if isinstance(value, dict): + pruned = {k: _pruned(v, (*at, k)) for k, v in value.items()} + return {k: v for k, v in pruned.items() if _is_schema((*at, k)) or v not in ({}, [], ())} + if isinstance(value, list): + return [_pruned(v, at) for v in value] + return value + + +def _is_schema(at: tuple[str, ...]) -> bool: + return at == ("data", "schema") or (len(at) == 4 and at[:2] == ("data", "collections") and at[3] == "schema") + + +def from_card(card: dict) -> WebsiteSpec: + """The spec a running site's card declares: its name, domains, health path, routes and sign-in, and its clock + when the card says it. A card says nothing of a site's source, data or personas.""" + extension = find_extension(card, WEBSITE_EXTENSION_URI) + if extension is None: + raise SpecError([f"the card declares no {WEBSITE_EXTENSION_URI}"]) + params = extension.get("params") or {} + raw: dict = {"site": {"name": card.get("name"), "domains": [d.lower() if isinstance(d, str) else d + for d in params.get("domains") or []], + "health": params.get("health")}, + "routes": {"ops": {r["name"]: {"method": r["method"], "path": r["path"], "roles": r.get("roles") or []} + for r in params.get("routes") or []}}} + if params.get("session_cookie"): + raw["users"] = {"sign_in": "cookie"} + if params["session_cookie"] != session_cookie_name(card.get("name")): + raw["users"]["session_cookie"] = params["session_cookie"] + if params.get("session_path") not in (None, SESSION_PATH): + raw["users"]["session_path"] = params["session_path"] + else: + raw["users"] = {"sign_in": "none"} + if isinstance(params.get("clock"), dict): + raw["clock"] = params["clock"] + return resolve(_pruned(raw), Path.cwd(), origin=f"card {card.get('name')!r}", declared_routes=True) + + +def from_environment(cls: type) -> WebsiteSpec: + """The spec a WebsiteEnvironment class's decorators declare: ``@environment_card``'s name, ``@website``'s + domains, health path, static folder, seed and clock patch, its ``@api`` routes and a cookie sign-in.""" + declaration = cls.declaration() + if declaration.spec is not None: + base = declaration.spec + raw = base.to_dict() + else: + base, raw = None, {} + card_name = (getattr(cls, _CARD_CONFIG_ATTR, None) or {}).get("name") + routes = cls.routes() + site_file = Path(inspect.getfile(cls)).resolve().parent + raw["site"] = {**raw.get("site", {}), "domains": [d.lower() for d in declaration.domains], + "health": declaration.health} + if card_name or raw["site"].get("name") is None: + raw["site"]["name"] = card_name or cls.__name__ + raw["source"] = {"sdk": f"{cls.__module__}:{cls.__qualname__}", + **({"assets": str(cls._site_path(declaration.static))} if declaration.static else {})} + seeds = [str(path) for path in cls._seed_files()] + if seeds: + raw.setdefault("data", {})["seed"] = seeds + acting = _seed_user(seeds) + if acting is not None and "acting_user" not in raw.get("users", {}): + raw.setdefault("users", {})["acting_user"] = acting + raw.setdefault("users", {})["sign_in"] = "cookie" + raw["routes"] = {"ops": {r.name: {"method": r.method, "path": openapi_path(r.path), "roles": list(r.roles)} + for r in routes}} + raw.pop("roles", None) + raw["clock"] = {"follow": "date_patch" if declaration.clock_patch else "header"} + return resolve(raw, base.base_dir if base is not None else site_file, origin=cls.__qualname__, declared_routes=True) + + +def _seed_user(paths: Iterable[str | Path]) -> Optional[str]: + """The ``current_user`` the store is left with once the seed files at ``paths`` are loaded in order, each that + has one taking its place.""" + user = None + for path in paths: + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + if isinstance(data, dict) and "current_user" in data: + user = data["current_user"] if isinstance(data["current_user"], str) else None + return user + + +def problems_with_environment(spec: WebsiteSpec, cls: type) -> list[str]: + """Where a WebsiteEnvironment class doesn't serve what ``spec`` declares: its source, routes by name, verb, + path and roles, sign-in and clock.""" + problems = [] + if spec.source is not None and spec.source.kind != "sdk": + problems.append(f"source: a WebsiteEnvironment is an sdk source, and the spec names {spec.source.kind}") + elif spec.source is not None and spec.source.sdk.rsplit(":", 1)[1] not in (cls.__qualname__, cls.__name__): + problems.append(f"source.sdk: {spec.source.sdk} names another class than {cls.__name__}") + derived = from_environment(cls) + ours = {name: (r.method, r.path, tuple(sorted(r.roles))) for name, r in spec.route_table().items()} + theirs = {name: (r.method, r.path, tuple(sorted(r.roles))) for name, r in derived.route_table().items()} + if spec.routes.ops: + for name in sorted(set(ours) | set(theirs)): + if name not in theirs: + problems.append(f"routes.ops.{name}: {' '.join(ours[name][:2])} isn't an @api route of {cls.__name__}") + elif name not in ours: + problems.append(f"routes.ops: {cls.__name__} serves {name} ({' '.join(theirs[name][:2])}), " + "which the spec doesn't declare") + elif ours[name] != theirs[name]: + problems.append(f"routes.ops.{name}: the spec says {_shown(ours[name])}, the class serves " + f"{_shown(theirs[name])}") + if spec.users.sign_in != "cookie": + problems.append(f"users.sign_in: a WebsiteEnvironment signs in by cookie, not {spec.users.sign_in}") + cookie = session_cookie_name(spec.site.name) + if spec.users.session_cookie not in (None, cookie) or spec.users.session_path not in (None, SESSION_PATH): + problems.append(f"users: a WebsiteEnvironment's session is {cookie} at {SESSION_PATH}") + acting = _seed_user(cls._seed_files()) + if spec.users.acting_user is not None and acting is not None and spec.users.acting_user != acting: + problems.append(f"users.acting_user: the spec says {spec.users.acting_user}, the seed's current_user {acting}") + return problems + + +def _shown(route: tuple) -> str: + method, path, roles = route + return f"{method} {path}" + (f" for {list(roles)}" if roles else "") + + +@dataclasses.dataclass(frozen=True) +class NameEntry: + """One host name pattern of a multi env's name table: the site it routes to and how that site is reached.""" + + pattern: str + site: str + kind: NameKind + + def matches(self, host: str) -> bool: + host = host.lower().rstrip(".") + if self.pattern.startswith("*."): + return host.endswith(self.pattern[1:]) + return host == self.pattern + + @property + def specificity(self) -> tuple[int, int]: + return (0, len(self.pattern)) if self.pattern.startswith("*.") else (1, len(self.pattern)) + + +@dataclasses.dataclass(frozen=True) +class NameTable: + """A multi env's host names: each pattern's winning entry, and each entry a higher kind shadowed, beside the + entry it lost to.""" + + entries: tuple[NameEntry, ...] + shadowed: tuple[tuple[NameEntry, NameEntry], ...] = () + + def resolve(self, host: str) -> Optional[NameEntry]: + """The entry ``host`` routes to: of those matching it, the highest kind's (synthetic, live, allowlist), + and among those the most specific pattern (an exact name, then the longest wildcard).""" + matching = [e for e in self.entries if e.matches(host)] + if not matching: + return None + return min(matching, key=lambda e: (_PRECEDENCE[e.kind], tuple(-n for n in e.specificity))) + + def as_dict(self) -> dict[str, dict]: + return {e.pattern: {"site": e.site, "kind": e.kind} for e in self.entries} + + +def name_table(specs: Iterable[WebsiteSpec]) -> NameTable: + """The one table of host names of a multi env's sites: each site's domains, ``synthetic`` for one the env + serves (image, sdk, static, replay) and ``live`` for a live source, and its source's ``allow_hosts`` as + ``allowlist``. A pattern two kinds claim goes to the higher, synthetic before live before allowlist; two sites + claiming one pattern at one kind is a conflict, but for an allowlist, which only lets traffic through. Raises + SpecError listing every conflict.""" + claims: dict[str, list[NameEntry]] = {} + problems = [] + names: set[str] = set() + for spec in specs: + if spec.site.name is None: + problems.append(f"a site with domains {list(spec.site.domains)} has no site.name") + continue + if spec.site.name in names: + problems.append(f"two sites are named {spec.site.name}") + names.add(spec.site.name) + source = spec.source + kind: NameKind = "live" if source is not None and source.kind == "live" else "synthetic" + allowed = (source.live.allow_hosts if source is not None and source.live is not None + else source.replay.allow_hosts if source is not None and source.replay is not None else ()) + for pattern in spec.site.domains: + claims.setdefault(pattern, []).append(NameEntry(pattern, spec.site.name, kind)) + for pattern in allowed: + claims.setdefault(pattern, []).append(NameEntry(pattern, spec.site.name, "allowlist")) + entries, shadowed = [], [] + for pattern, claimed in claims.items(): + clashes = [(kind, sorted({e.site for e in claimed if e.kind == kind})) for kind in ("synthetic", "live")] + clashes = [(kind, sites) for kind, sites in clashes if len(sites) > 1] + problems.extend(f"{pattern}: claimed as {kind} by {', '.join(sites)}" for kind, sites in clashes) + if clashes: + continue + best = min(_PRECEDENCE[e.kind] for e in claimed) + top = list(dict.fromkeys(e for e in claimed if _PRECEDENCE[e.kind] == best)) + winner = top[0] + entries.append(winner) + shadowed.extend((e, winner) for e in dict.fromkeys(claimed) if e != winner and e.site != winner.site) + if problems: + raise SpecError(problems) + return NameTable(tuple(entries), tuple(shadowed)) + + +def json_schema() -> dict: + """The JSON Schema of a spec file, with the string shorthands a route and a persona take, the partial route + table an overlay changes an inherited route's keys with, and the ``false`` that removes an inherited route, role + or collection.""" + schema = WebsiteSpec.model_json_schema(by_alias=True) + defs = schema.get("$defs", {}) + route_ref = {"$ref": "#/$defs/Route"} + either_case = "|".join("".join(f"[{c}{c.lower()}]" for c in method) for method in METHODS) + shorthand = {"type": "string", "pattern": rf"^\s*({either_case}) +/", "description": "METHOD /path"} + removed = {"const": False, "description": "removes the inherited entry"} + partial = {**{k: v for k, v in defs["Route"].items() if k != "required"}, + "title": "RouteChange", "description": "keys of an inherited route an overlay changes"} + defs["Routes"]["properties"]["ops"]["additionalProperties"] = {"anyOf": [route_ref, shorthand, removed, partial]} + for holder in (schema["properties"]["roles"], defs["Data"]["properties"]["collections"]): + holder["additionalProperties"] = {"anyOf": [holder["additionalProperties"], removed]} + defs["Users"]["properties"]["personas"]["items"] = {"anyOf": [{"$ref": "#/$defs/Persona"}, {"type": "string"}]} + defs["Data"]["properties"]["seed"] = {"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}], + "default": []} + return {"$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:agentenv:website/v1:spec", + **schema} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python -m agentenv_protocol.website.spec", + description="Validate, show or describe a website/v1 spec.") + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("validate", help="check a spec and everything it extends and uses").add_argument("path") + show = commands.add_parser("show", help="print the resolved spec as JSON") + show.add_argument("path") + show.add_argument("--card", action="store_true", help="print the website/v1 card params it advertises instead") + commands.add_parser("schema", help="print the JSON Schema of a spec file") + args = parser.parse_args(argv) + if args.command == "schema": + print(json.dumps(json_schema(), indent=2)) + return 0 + try: + spec = load(args.path) + except SpecError as e: + print("\n".join(e.problems), file=sys.stderr) + return 1 + if args.command == "show": + print(json.dumps(spec.to_card_extension() if args.card else spec.to_dict(), indent=2)) + return 0 + source = spec.source.kind if spec.source is not None else "no source" + mounted = f", {len(spec.components)} component(s)" if spec.components else "" + print(f"{args.path}: ok: {spec.site.name or 'unnamed site'}, {source}, {len(spec.routes.ops)} route(s), " + f"{len(spec.data.collections)} collection(s){mounted}") + return 0 diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/spec/__main__.py b/packages/agentenv-protocol/src/agentenv_protocol/website/spec/__main__.py new file mode 100644 index 000000000..15170e59a --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/spec/__main__.py @@ -0,0 +1,5 @@ +import sys + +from . import main + +sys.exit(main()) diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index ad92a50df..2b4308a65 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -48,8 +48,8 @@ def rpc(client: TestClient, method: str, params: dict | None = None) -> dict: def notes_class(tmp_path: Path) -> type[WebsiteEnvironment]: """The example's class, serving a stand-in page instead of ``web/dist``, which only a Vite build makes.""" (tmp_path / "index.html").write_text("Notes") - declared = notes_site.Notes.declaration()._replace(static=str(tmp_path), data=str(NOTES_DIR / "data" / "seed.json")) - return website(**declared._asdict())(type("Notes", (notes_site.Notes,), {})) + spec = notes_site.Notes.declaration().spec.overlay({"source": {"sdk": "site:Notes", "assets": str(tmp_path)}}) + return website(spec=spec)(type("Notes", (notes_site.Notes,), {})) @pytest.fixture diff --git a/packages/agentenv-protocol/tests/test_website_spec.py b/packages/agentenv-protocol/tests/test_website_spec.py new file mode 100644 index 000000000..3fa8e7bae --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_spec.py @@ -0,0 +1,955 @@ +"""The website spec: loading and validating one, ``extends`` overlays, ``uses`` composition and its conflicts, a multi +env's name table, the card round trip, a spec derived from a WebsiteEnvironment class and a class declared from one, +and the conformance checker run against a spec.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import httpx +import pytest +from starlette.testclient import TestClient + +from agentenv_protocol import environment_card +from agentenv_protocol.website import WebsiteEnvironment, api, conformance, website +from agentenv_protocol.website import spec as website_spec +from agentenv_protocol.website.spec import SpecError, from_card, from_environment, load, name_table, resolve + +EXAMPLES = Path(__file__).resolve().parents[1] / "examples" / "website" +NOTES_DIR = EXAMPLES / "notes" +SPECS_DIR = EXAMPLES / "specs" + + +def _notes_module(): + spec = importlib.util.spec_from_file_location("notes_spec_example_site", NOTES_DIR / "site.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +notes_site = _notes_module() + + +def write(folder: Path, files: dict[str, str]) -> Path: + for name, text in files.items(): + (folder / name).parent.mkdir(parents=True, exist_ok=True) + (folder / name).write_text(text) + return folder / next(iter(files)) + + +def problems(path_or_raw, base_dir: Path | None = None) -> list[str]: + with pytest.raises(SpecError) as caught: + load(path_or_raw) if base_dir is None else resolve(path_or_raw, base_dir) + return caught.value.problems + + +def card_of(client: TestClient) -> dict: + return client.get("/.well-known/agent-env.json").json() + + +def website_params(card: dict) -> dict: + params = next(e for e in card["capabilities"]["extensions"] if e["uri"] == website_spec.WEBSITE_EXTENSION_URI)["params"] + return {k: v for k, v in params.items() if k != "methods"} + + +@pytest.fixture +def notes_client(tmp_path: Path) -> TestClient: + (tmp_path / "index.html").write_text("") + spec = notes_site.Notes.declaration().spec.overlay({"source": {"sdk": "site:Notes", "assets": str(tmp_path)}}) + return TestClient(website(spec=spec)(type("Notes", (notes_site.Notes,), {}))().create_app()) + + +# Loading + + +def test_the_notes_spec_loads_with_its_routes_roles_personas_and_data() -> None: + spec = load(NOTES_DIR / "website.toml") + assert (spec.site.name, spec.site.domains, spec.health) == ("notes", ("notes.test",), "/healthz") + assert (spec.source.kind, spec.source.sdk, spec.path(spec.source.assets)) == ("sdk", "site:Notes", + NOTES_DIR / "web" / "dist") + assert [p.id for p in spec.users.personas] == ["ann", "ben"] and spec.users.acting_user == "ann" + assert spec.data.collections["notes"].owner == "owner" and spec.data.seed == ("data/seed.json",) + table = spec.route_table() + assert list(table)[:3] == ["list_users", "sign_in", "list_notes"] + assert (table["get_note"].method, table["get_note"].path) == ("GET", "/api/notes/{note_id}") + assert table["all_notes"].roles == ("admin",) and table["list_notes"].roles == () + assert spec.clock.follow == "header" + + +def test_every_section_is_optional_and_a_route_takes_a_table_or_a_shorthand(tmp_path: Path) -> None: + assert load(write(tmp_path, {"empty.toml": ""})).to_card_extension() == { + "domains": [], "health": "/api/health", "routes": [], "session_cookie": "agentenv_session", + "session_path": "/api/session"} + spec = load(write(tmp_path, {"r.toml": '[routes.ops]\na = "get /x/{id:int}"\n' + 'b = { method = "post", path = "/x", roles = ["admin"] }\n'})) + assert [r.advertised(n) for n, r in spec.route_table().items()] == [ + {"method": "GET", "path": "/x/{id}", "name": "a", "roles": None}, + {"method": "POST", "path": "/x", "name": "b", "roles": ["admin"]}] + + +def test_routes_come_from_an_openapi_document_by_operation_id(tmp_path: Path) -> None: + write(tmp_path, {"openapi.json": json.dumps({"paths": { + "/api/items": {"get": {"operationId": "list_items"}, "post": {"operationId": "add_item", + "x-agentenv-roles": ["admin"]}}}})}) + spec = load(write(tmp_path, {"s.toml": '[routes]\nopenapi = "openapi.json"\n[routes.ops]\nping = "GET /ping"\n'})) + assert {n: (r.method, r.path, r.roles) for n, r in spec.route_table().items()} == { + "list_items": ("GET", "/api/items", ()), "add_item": ("POST", "/api/items", ("admin",)), + "ping": ("GET", "/ping", ())} + clash = write(tmp_path, {"c.toml": '[routes]\nopenapi = "openapi.json"\n[routes.ops]\nlist_items = "GET /other"\n'}) + assert any("list_items: GET /other isn't the openapi document's GET /api/items" in p for p in problems(clash)) + + +def test_an_openapi_document_of_another_shape_or_with_a_name_twice_is_refused(tmp_path: Path) -> None: + def found(document) -> list[str]: + write(tmp_path, {"openapi.json": json.dumps(document)}) + return problems(write(tmp_path, {"s.toml": '[routes]\nopenapi = "openapi.json"\n'})) + + shape = "openapi.json isn't an OpenAPI document, an object whose paths are an object" + assert any(shape in p for p in found([])) + assert any(shape in p for p in found({"paths": ["/a"]})) + assert any("path /a isn't an object of operations" in p for p in found({"paths": {"/a": ["get"]}})) + assert any("routes.openapi: items names both GET /a and GET /b" in p for p in found( + {"paths": {"/a": {"get": {"operationId": "items"}}, "/b": {"get": {"operationId": "items"}}}})) + + +def test_false_removes_an_operation_of_the_openapi_document(tmp_path: Path) -> None: + write(tmp_path, {"openapi.json": json.dumps({"paths": {"/api/items": { + "get": {"operationId": "list_items"}, "post": {"operationId": "add_item"}}}}), + "base.toml": '[routes]\nopenapi = "openapi.json"\n'}) + spec = load(tmp_path / "base.toml") + assert set(spec.route_table()) == {"list_items", "add_item"} and spec.routes.openapi is None + assert set(spec.overlay({"routes": {"ops": {"add_item": False}}}).route_table()) == {"list_items"} + child = load(write(tmp_path, {"child.toml": 'extends = "base.toml"\n[routes.ops]\nadd_item = false\n'})) + assert set(child.route_table()) == {"list_items"} + assert set(resolve(child.to_dict(), tmp_path).route_table()) == {"list_items"} + + +@pytest.mark.parametrize("toml, problem", [ + ("[site]\ncolor = 1\n", "site.color: isn't a key the spec takes"), + ("[pages]\n", "pages: isn't a key the spec takes"), + ('[source]\nsdk = "a:B"\nstatic = "dist"\n', "source: names exactly one of image, sdk, static, live, replay, " + "not ['sdk', 'static']"), + ("[source]\n", "source: names exactly one of image, sdk, static, live, replay, not none"), + ('[source]\nstatic = "dist"\nassets = "x"\n', "source: assets is the static folder of an sdk source"), + ('[source]\nsdk = "nocolon"\n', "source: sdk is module:Class, not 'nocolon'"), + ('[site]\ndomains = ["ok.test", "Bad.test", "a.*.test"]\n', "site.domains: ['Bad.test', 'a.*.test'] aren't host"), + ('[site]\nhealth = "/agentenv"\n', "site.health: health is a path of its own under '/', not '/agentenv'"), + ('[source.live]\ncredentials = { token = "xoxp-123" }\n', "source.live.credentials: credentials ['token'] must be"), + ('[users]\npersonas = ["ann"]\nacting_user = "zed"\n', "users: acting_user 'zed' is none of the personas ['ann']"), + ('[users]\npersonas = ["ann", "ann"]\n', "users: personas ['ann'] are declared twice"), + ('[users]\nsign_in = "sso"\n', "users.sign_in: Input should be 'cookie', 'none' or 'live'"), + ('[routes.ops]\na = "FETCH /x"\n', "routes.ops.a: method is one of GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, not 'FETCH'"), + ('[routes.ops]\na = "GET /agentenv/x"\n', "routes.ops.a: path '/agentenv/x' is the data plane's or the card's"), + ('[routes.ops]\na = "GET /x"\nb = "get /x"\n', "routes.ops: b and a are both GET /x"), + ('[site]\nhealth = "/up"\n[routes.ops]\nup = "GET /up"\n', "site.health: /up is served by route up"), + ('[roles]\nadmin = ["nope"]\n', "roles.admin: names no route ['nope']"), + ('[source]\nsdk = "a:B"\n[users]\nsign_in = "live"\n', "users.sign_in: live is the sign-in of a live or replayed"), + ('[source]\nsdk = "a:B"\n[clock]\nfollow = "none"\n', "clock.follow: an sdk site follows the universe's clock"), + ('[source]\nstatic = "dist"\n[routes.ops]\na = "GET /a"\n', "routes: a static site serves no routes"), + ('[source]\nsdk = "a:B"\n[routes.ops]\na = { method = "GET", path = "/a", served_by = "x:Y" }\n', + "routes.ops: served_by swaps a route of a live or replayed site"), + ('[data]\nschema = "s.json"\nmodels = "mcp:mail"\n', "data: data takes a schema or models, not both"), + ('[data]\nmodels = "mail"\n', "data.models: models is mcp:, not 'mail'"), + ('[users]\nsign_in = "none"\n[data.collections.notes]\nowner = "owner"\n', + "data.collections.notes: owner names a user, and the site signs nobody in"), + ('[[uses]]\nspec = "c.toml"\nmount = "/.well-known/agent-env.json"\n', "uses[0].mount: mount is a plain path"), + ("[site\n", "isn't TOML"), +], ids=lambda value: value[:24] if isinstance(value, str) else value) +def test_a_spec_that_does_not_hold_together_is_refused_naming_where(tmp_path: Path, toml: str, problem: str) -> None: + found = problems(write(tmp_path, {"s.toml": toml})) + assert any(problem in p for p in found), found + assert all(p.startswith(str(tmp_path / "s.toml")) for p in found) + + +def test_a_missing_spec_file_is_a_problem(tmp_path: Path) -> None: + assert "can't be read" in problems(tmp_path / "nope.toml")[0] + + +def test_to_dict_reads_back_as_the_same_spec() -> None: + spec = load(NOTES_DIR / "website.toml") + again = resolve(spec.to_dict(), NOTES_DIR) + assert again == spec and again.to_card_extension() == spec.to_card_extension() + + +def test_to_dict_and_an_overlay_keep_a_json_schema_as_it_is(tmp_path: Path) -> None: + closed = {"type": "object", "additionalProperties": False, "properties": {"id": {}}, "required": []} + spec = resolve({"data": {"schema": {"properties": {}}, "collections": {"items": {"schema": closed}, + "any": {"schema": {}}}}}, tmp_path) + data = spec.to_dict()["data"] + assert (data["schema"], data["collections"]["items"]["schema"], data["collections"]["any"]["schema"]) == ( + {"properties": {}}, closed, {}) + assert resolve(spec.to_dict(), tmp_path) == spec + assert spec.overlay({"site": {"name": "x"}}).data.collections["items"].schema_ == closed + + +# extends + + +BASE = """ +[site] +name = "shop" +domains = ["shop.test", "www.shop.test"] +health = "/healthz" + +[source] +sdk = "shop:Shop" +assets = "dist" + +[data] +seed = ["seed.json"] + +[data.collections.orders] +owner = "buyer" + +[data.collections.carts] + +[users] +personas = ["ann", "ben"] +acting_user = "ann" + +[roles] +admin = ["refund"] +staff = ["refund"] + +[routes.ops] +list_orders = "GET /api/orders" +refund = "POST /api/orders/{id}/refund" +""" + + +def test_an_overlay_changes_one_key_of_an_operation_of_the_openapi_document(tmp_path: Path) -> None: + write(tmp_path, {"openapi.json": json.dumps({"paths": {"/api/items": { + "get": {"operationId": "list_items"}, "post": {"operationId": "add_item", "x-agentenv-roles": ["admin"]}}}}), + "base.toml": '[routes]\nopenapi = "openapi.json"\n'}) + child = load(write(tmp_path, {"child.toml": 'extends = "base.toml"\n[source.live]\n[routes.ops]\n' + 'add_item = { roles = ["staff"] }\n' + 'list_items = { served_by = "shop:Shop" }\n'})) + routes = child.route_table() + assert (routes["add_item"].method, routes["add_item"].path, routes["add_item"].roles) == ( + "POST", "/api/items", ("staff",)) + assert (routes["list_items"].method, routes["list_items"].path, routes["list_items"].served_by) == ( + "GET", "/api/items", "shop:Shop") + assert load(tmp_path / "base.toml").overlay({"routes": {"ops": {"add_item": {"roles": ["staff"]}}}}).route_table()[ + "add_item"].roles == ("staff",) + moved = write(tmp_path, {"moved.toml": 'extends = "base.toml"\n[routes.ops]\nadd_item = { path = "/api/other" }\n'}) + assert any("add_item: POST /api/other isn't the openapi document's POST /api/items" in p for p in problems(moved)) + + +def test_extends_merges_tables_replaces_arrays_and_source_and_false_removes(tmp_path: Path) -> None: + write(tmp_path, {"base/shop.toml": BASE}) + child = write(tmp_path, {"variants/ben.toml": """ +extends = "../base/shop.toml" + +[site] +domains = ["shop.example"] + +[source.image] +dockerfile = "Dockerfile" + +[data.collections] +orders = false + +[data.collections.carts] +key = "cart_id" + +[users] +personas = ["ben", "cy"] +acting_user = "ben" + +[roles] +staff = false + +[routes.ops] +refund = { roles = ["manager"] } +list_orders = false +place_order = "POST /api/orders" +"""}) + spec = load(child) + assert (spec.site.name, spec.site.domains, spec.health) == ("shop", ("shop.example",), "/healthz") + assert spec.source.kind == "image" and spec.source.assets is None + assert spec.source.image.dockerfile == "Dockerfile" and spec.base_dir == (tmp_path / "variants").resolve() + assert spec.path(spec.data.seed[0]).resolve() == (tmp_path / "base" / "seed.json").resolve() + assert list(spec.data.collections) == ["carts"] and spec.data.collections["carts"].key == "cart_id" + assert [p.id for p in spec.users.personas] == ["ben", "cy"] and spec.users.acting_user == "ben" + assert list(spec.roles) == ["admin"] + assert {n: (r.method, r.path, r.roles) for n, r in spec.route_table().items()} == { + "refund": ("POST", "/api/orders/{id}/refund", ("manager", "admin")), + "place_order": ("POST", "/api/orders", ())} + assert spec.extends is None + + +def test_an_inherited_image_keeps_its_default_context_in_its_own_folder(tmp_path: Path) -> None: + write(tmp_path, {"base/site.toml": '[site]\nname = "shop"\n[source.image]\ndockerfile = "Dockerfile"\n'}) + implicit = load(write(tmp_path, {"variants/ben.toml": 'extends = "../base/site.toml"\n'})) + write(tmp_path, {"base/explicit.toml": '[site]\nname = "shop"\n[source.image]\ndockerfile = "Dockerfile"\n' + 'context = "."\n'}) + explicit = load(write(tmp_path, {"variants/cy.toml": 'extends = "../base/explicit.toml"\n'})) + assert implicit.path(implicit.source.image.context).resolve() == (tmp_path / "base").resolve() + assert implicit.source.image == explicit.source.image + + +def test_an_extends_cycle_is_refused(tmp_path: Path) -> None: + write(tmp_path, {"a.toml": 'extends = "b.toml"\n', "b.toml": 'extends = "a.toml"\n'}) + assert "is a cycle" in problems(tmp_path / "a.toml")[0] + + +@pytest.mark.parametrize(("files", "problem"), [ + ({"s.toml": 'extends = "base.toml"\nuses = 1\n', "base.toml": '[[uses]]\nspec = "c.toml"\n'}, "uses: "), + ({"s.toml": 'extends = "base/base.toml"\n', "base/base.toml": "[data]\ncollections = [1]\n"}, "data.collections: "), +], ids=["uses", "collections"]) +def test_an_extends_whose_fields_have_the_wrong_type_is_refused_naming_them(tmp_path: Path, files: dict, + problem: str) -> None: + found = problems(write(tmp_path, files)) + assert any(problem in p for p in found), found + + +def test_overlay_merges_over_a_loaded_spec_as_extends_does(tmp_path: Path) -> None: + spec = load(write(tmp_path, {"shop.toml": BASE})) + changed = spec.overlay({"users": {"acting_user": "ben"}, "routes": {"ops": {"refund": {"roles": []}}}}) + assert changed.users.acting_user == "ben" and changed.route_table()["refund"].roles == ("admin", "staff") + assert spec.users.acting_user == "ann" + + +# uses + + +SSO = """ +[site] +name = "sso" +domains = ["sso.test"] + +[source] +sdk = "sso:FakeSso" + +[data.collections.accounts] + +[data.collections.grants] +owner = "user" + +[users] +personas = [{ id = "ann", name = "Ann" }] + +[roles] +admin = ["revoke"] + +[routes.ops] +login = "POST /login" +userinfo = "GET /userinfo" +revoke = "POST /revoke" +""" + +HOST = """ +[site] +name = "intranet" +domains = ["intranet.test"] + +[source.image] +dockerfile = "Dockerfile" + +[data.collections.users] + +[users] +personas = ["ann", "ben"] + +[roles] +editor = ["sso.login"] + +[routes.ops] +pages = "GET /api/pages" +""" + + +def test_uses_mounts_a_component_prefixing_its_routes_and_namespacing_its_data(tmp_path: Path) -> None: + write(tmp_path, {"components/sso.toml": SSO}) + spec = load(write(tmp_path, {"site.toml": HOST + '[[uses]]\nspec = "components/sso.toml"\nmount = "/auth"\n' + 'data = { accounts = "users" }\n'})) + table = spec.route_table() + assert {n: (r.method, r.path, r.roles) for n, r in table.items()} == { + "pages": ("GET", "/api/pages", ()), "sso.login": ("POST", "/auth/login", ("editor",)), + "sso.userinfo": ("GET", "/auth/userinfo", ()), "sso.revoke": ("POST", "/auth/revoke", ("admin",))} + assert list(spec.data.collections) == ["users", "sso.grants"] + assert spec.data.collections["sso.grants"].owner == "user" + assert [p.id for p in spec.users.personas] == ["ann", "ben"] + assert spec.site.domains == ("intranet.test",) and spec.uses == () + (component,) = spec.components + assert (component.name, component.mount, component.spec.site.name) == ("sso", "/auth", "sso") + assert [r["name"] for r in spec.to_card_extension()["routes"]] == ["pages", "sso.login", "sso.userinfo", + "sso.revoke"] + + +def test_uses_names_a_component_by_as_and_mounts_it_at_the_root(tmp_path: Path) -> None: + write(tmp_path, {"sso.toml": SSO}) + spec = load(write(tmp_path, {"site.toml": HOST.replace("sso.login", "auth.login") + + '[[uses]]\nspec = "sso.toml"\nas = "auth"\n'})) + assert spec.route_table()["auth.login"].path == "/login" and "auth.accounts" in spec.data.collections + + +def test_a_mounted_route_keeps_its_trailing_slash(tmp_path: Path) -> None: + write(tmp_path, {"sso.toml": SSO.replace('revoke = "POST /revoke"', 'revoke = "POST /revoke"\nhome = "GET /"\n' + 'items = "GET /items/"')}) + table = load(write(tmp_path, {"site.toml": HOST + '[[uses]]\nspec = "sso.toml"\nmount = "/auth"\n'})).route_table() + assert (table["sso.home"].path, table["sso.items"].path, table["sso.login"].path) == ("/auth/", "/auth/items/", + "/auth/login") + + +def test_a_sites_acting_user_may_be_a_persona_only_its_component_declares(tmp_path: Path) -> None: + write(tmp_path, {"sso.toml": SSO}) + host = HOST.replace('personas = ["ann", "ben"]', 'personas = ["ben"]\nacting_user = "ann"') + spec = load(write(tmp_path, {"site.toml": host + '[[uses]]\nspec = "sso.toml"\nmount = "/auth"\n'})) + assert ([p.id for p in spec.users.personas], spec.users.acting_user) == (["ben", "ann"], "ann") + write(tmp_path, {"site.toml": host.replace('acting_user = "ann"', 'acting_user = "zed"') + + '[[uses]]\nspec = "sso.toml"\nmount = "/auth"\n'}) + assert problems(tmp_path / "site.toml") == [ + f"{tmp_path / 'site.toml'}: users: acting_user 'zed' is none of the personas ['ben', 'ann']"] + + +@pytest.mark.parametrize("host, component, problem", [ + (HOST + '[routes.ops.mine]\nmethod = "POST"\npath = "/auth/login"\n', SSO, + "uses[0]: route mine (POST /auth/login) is under /auth, where sso is mounted"), + (HOST.replace('pages = "GET /api/pages"', 'pages = "GET /userinfo"'), SSO.replace("[site]", "[site]\n"), + "uses[0]: sso's route userinfo is GET /userinfo, which pages already serves"), + (HOST + '[routes.ops."sso.login"]\nmethod = "GET"\npath = "/x"\n', SSO, + "uses[0]: route sso.login is declared by the site and by component sso"), + (HOST + '[data.collections.people]\nkey = "email"\n', SSO, + "uses[0]: sso's collection accounts maps onto people, whose key differ"), + (HOST, SSO.replace('[data.collections.grants]\nowner = "user"', '[data.collections.grants]\nowner = "owner"'), + "uses[0]: sso's collection grants maps onto sso.grants"), + (HOST.replace('personas = ["ann", "ben"]', 'personas = ["ann", "ben"]\nsign_in = "none"'), SSO, + "uses[0]: sso signs in by cookie, the site by none"), + (HOST.replace('personas = ["ann", "ben"]', 'personas = [{ id = "ann", name = "Annie" }]'), SSO, + "uses[0]: persona ann is declared otherwise by sso"), + (HOST.replace('editor = ["sso.login"]', 'editor = ["sso.nope"]'), SSO, "roles.editor: names no route ['sso.nope']"), + (HOST, SSO.replace('name = "sso"\n', ""), "uses[0]: components/sso.toml has no site.name; name it with as"), + (HOST, SSO.replace('login = "POST /login"', 'login = "FETCH /login"'), "routes.ops.login: method is one of"), +], ids=["host-route-under-mount", "same-path", "same-name", "collection-key", "collection-owner", "sign-in", + "persona", "role-of-component-route", "unnamed", "component-invalid"]) +def test_a_composition_that_clashes_is_refused(tmp_path: Path, host: str, component: str, problem: str) -> None: + mount = "/" if "already serves" in problem else "/auth" + mapping = 'data = { accounts = "people" }\n' if "people" in problem else "" + if "grants maps onto sso.grants" in problem: + host += '[data.collections."sso.grants"]\nowner = "user"\n' + write(tmp_path, {"components/sso.toml": component}) + found = problems(write(tmp_path, {"site.toml": host + f'[[uses]]\nspec = "components/sso.toml"\nmount = "{mount}"\n' + + mapping})) + assert any(problem in p for p in found), found + + +def test_a_components_schema_file_stays_its_own_once_mounted(tmp_path: Path) -> None: + write(tmp_path, {"components/sso.toml": SSO.replace("[data.collections.accounts]", + '[data.collections.accounts]\nschema = "accounts.json"')}) + spec = load(write(tmp_path, {"site.toml": HOST + '[[uses]]\nspec = "components/sso.toml"\n'})) + assert spec.path(spec.data.collections["sso.accounts"].schema_).resolve() == tmp_path / "components" / "accounts.json" + mapped = load(write(tmp_path, {"mapped.toml": HOST.replace("[data.collections.users]", + '[data.collections.users]\nschema = "components/accounts.json"') + + '[[uses]]\nspec = "components/sso.toml"\ndata = { accounts = "users" }\n'})) + assert mapped.data.collections["users"].schema_ == "components/accounts.json" + + +def test_a_mapping_of_a_collection_the_component_lacks_and_a_uses_cycle_are_refused(tmp_path: Path) -> None: + write(tmp_path, {"sso.toml": SSO}) + assert any("data maps ['nope'], which sso doesn't declare" in p for p in problems( + write(tmp_path, {"a.toml": HOST + '[[uses]]\nspec = "sso.toml"\ndata = { nope = "users" }\n'}))) + write(tmp_path, {"x.toml": '[site]\nname = "x"\n[[uses]]\nspec = "y.toml"\n', + "y.toml": '[site]\nname = "y"\n[[uses]]\nspec = "x.toml"\n'}) + assert any("is a cycle" in p for p in problems(tmp_path / "x.toml")) + + +def test_an_overlay_adds_a_component_and_replaces_one_of_the_same_name(tmp_path: Path) -> None: + write(tmp_path, {"sso.toml": SSO, "base.toml": HOST + '[[uses]]\nspec = "sso.toml"\nmount = "/a"\nas = "sso"\n'}) + spec = load(write(tmp_path, {"child.toml": 'extends = "base.toml"\n[[uses]]\nspec = "sso.toml"\nmount = "/b"\n' + 'as = "sso"\n[[uses]]\nspec = "sso.toml"\nmount = "/c"\nas = "backup"\n'})) + assert [(c.name, c.mount) for c in spec.components] == [("sso", "/b"), ("backup", "/c")] + assert (spec.route_table()["sso.login"].path, spec.route_table()["backup.login"].path) == ("/b/login", "/c/login") + + +def test_overlay_replaces_a_mounted_component_as_a_file_overlay_does(tmp_path: Path) -> None: + write(tmp_path, {"sso.toml": SSO, "base.toml": HOST + '[[uses]]\nspec = "sso.toml"\nmount = "/a"\nas = "sso"\n'}) + by_file = load(write(tmp_path, {"child.toml": 'extends = "base.toml"\n[[uses]]\nspec = "sso.toml"\nmount = "/b"\n' + 'as = "sso"\n'})) + by_code = load(tmp_path / "base.toml").overlay({"uses": [{"spec": "sso.toml", "mount": "/b", "as": "sso"}]}) + assert [(c.name, c.mount) for c in by_code.components] == [("sso", "/b")] + assert by_code.to_dict() == by_file.to_dict() + + +# The name table + + +def _site(name: str, domains: list[str], source: dict | None = None) -> website_spec.WebsiteSpec: + raw: dict = {"site": {"name": name, "domains": domains}} + if source is not None: + raw["source"] = source + if "live" in source or "replay" in source: + raw["users"] = {"sign_in": "live"} + return resolve(raw, Path.cwd()) + + +def test_the_name_table_puts_synthetic_before_live_before_allowlisted() -> None: + slack = load(SPECS_DIR / "slack.toml") + status = _site("status", ["status.slack.com", "*.slack-edge.com"], {"live": {"allow_hosts": ["cdn.status.io"]}}) + notes = load(NOTES_DIR / "website.toml") + table = name_table([slack, status, notes]) + assert table.as_dict() == { + "slack.com": {"site": "slack", "kind": "synthetic"}, "*.slack.com": {"site": "slack", "kind": "synthetic"}, + "*.slack-edge.com": {"site": "status", "kind": "live"}, "status.slack.com": {"site": "status", "kind": "live"}, + "cdn.status.io": {"site": "status", "kind": "allowlist"}, "notes.test": {"site": "notes", "kind": "synthetic"}} + assert [(lost.site, lost.kind, won.site) for lost, won in table.shadowed] == [("slack", "allowlist", "status")] + assert table.resolve("status.slack.com").site == "slack" + assert table.resolve("files.slack-edge.com").kind == "live" + assert table.resolve("NOTES.test.").site == "notes" and table.resolve("slack.org") is None + + +def test_the_name_table_prefers_the_most_specific_pattern_of_one_kind() -> None: + table = name_table([_site("mail", ["*.example.test"]), _site("inbox", ["inbox.example.test"]), + _site("deep", ["*.b.example.test"])]) + assert [table.resolve(h).site for h in ("x.example.test", "inbox.example.test", "a.b.example.test")] == [ + "mail", "inbox", "deep"] + assert table.resolve("example.test") is None + + +def test_the_name_table_refuses_two_sites_claiming_one_name_at_one_kind() -> None: + with pytest.raises(SpecError) as caught: + name_table([_site("a", ["shop.test", "*.x.test"]), _site("b", ["shop.test"]), _site("c", ["*.x.test"]), + _site("a", []), resolve({"site": {"domains": ["q.test"]}}, Path.cwd()), + _site("l1", ["live.test"], {"live": {}}), _site("l2", ["live.test"], {"live": {}})]) + assert caught.value.problems == [ + "two sites are named a", "a site with domains ['q.test'] has no site.name", + "shop.test: claimed as synthetic by a, b", "*.x.test: claimed as synthetic by a, c", + "live.test: claimed as live by l1, l2"] + + + +def test_the_name_table_refuses_two_live_sites_on_one_name_a_synthetic_site_also_claims() -> None: + with pytest.raises(SpecError) as caught: + name_table([_site("l1", ["shop.test"], {"live": {}}), _site("l2", ["shop.test"], {"live": {}}), + _site("s", ["shop.test"])]) + assert caught.value.problems == ["shop.test: claimed as live by l1, l2"] + + +def test_two_sites_may_allowlist_one_host() -> None: + table = name_table([_site("a", [], {"live": {"allow_hosts": ["cdn.test"]}}), + _site("b", [], {"replay": {"capture": "c.har", "allow_hosts": ["cdn.test"]}})]) + assert table.as_dict() == {"cdn.test": {"site": "a", "kind": "allowlist"}} + + +# The card + + +def test_a_spec_round_trips_through_the_card_it_advertises() -> None: + spec = load(NOTES_DIR / "website.toml") + card = {"name": "notes", "capabilities": {"extensions": [ + {"uri": website_spec.WEBSITE_EXTENSION_URI, "params": spec.to_card_extension()}]}} + back = from_card(card) + assert back.to_card_extension() == spec.to_card_extension() + assert (back.site, back.users.sign_in, back.clock) == (spec.site, "cookie", spec.clock) + assert back.source is None and back.data.collections == {} + + +def test_from_card_reads_a_running_sites_card_and_a_card_without_a_session() -> None: + card = {"name": "docs", "capabilities": {"extensions": [{"uri": website_spec.WEBSITE_EXTENSION_URI, "params": { + "domains": ["docs.test"], "health": "/up", "routes": [{"method": "GET", "path": "/a", "name": "a"}]}}]}} + spec = from_card(card) + assert (spec.site.name, spec.health, spec.users.sign_in, spec.clock) == ("docs", "/up", "none", None) + assert spec.to_card_extension() == card["capabilities"]["extensions"][0]["params"] | { + "routes": [{"method": "GET", "path": "/a", "name": "a", "roles": None}]} + with pytest.raises(SpecError, match="declares no urn:agentenv:website/v1"): + from_card({"name": "x"}) + + +def test_the_notes_sites_card_is_its_spec(notes_client: TestClient) -> None: + from agentenv_protocol.website.routes import openapi_path + + card = card_of(notes_client) + params = website_params(card) + assert card["name"] == "notes" + assert {r["name"]: r["path"] for r in params["routes"]}["get_note"] == "/api/notes/{note_id:int}" + templated = [{**r, "path": openapi_path(r["path"])} for r in params["routes"]] + assert {**params, "routes": templated} == load(NOTES_DIR / "website.toml").to_card_extension() + assert from_card(card).to_card_extension() == {**params, "routes": templated} + + +# WebsiteEnvironment + + +def test_from_environment_derives_the_notes_spec_from_its_class() -> None: + derived = from_environment(notes_site.Notes) + declared = load(NOTES_DIR / "website.toml") + assert derived.to_card_extension() == declared.to_card_extension() + assert (derived.source.kind, derived.source.sdk) == ("sdk", f"{notes_site.__name__}:Notes") + assert derived.users.acting_user == "ann" and derived.path(derived.data.seed[0]) == NOTES_DIR / "data" / "seed.json" + + +def test_from_environment_derives_a_spec_from_decorators_alone(tmp_path: Path) -> None: + (tmp_path / "seed.json").write_text('{"current_user": "zoe", "items": []}') + + @environment_card(name="shop") + @website(domains=["shop.test"], health="/up", data=str(tmp_path / "seed.json"), clock_patch=True) + class Shop(WebsiteEnvironment): + @api.get("/api/items", roles=["admin"]) + def items(self) -> list: + return [] + + spec = from_environment(Shop) + assert (spec.site.name, spec.site.domains, spec.health, spec.users.acting_user) == ("shop", ("shop.test",), "/up", + "zoe") + assert (spec.clock.follow, spec.route_table()["items"].roles) == ("date_patch", ("admin",)) + assert website_params(card_of(TestClient(Shop().create_app()))) == spec.to_card_extension() + + +def test_from_environment_takes_any_url_path_a_route_serves() -> None: + @environment_card(name="chat") + @website(domains=["chat.test"]) + class Chat(WebsiteEnvironment): + @api.get("/api/users/@me") + def me(self) -> dict: + return {"id": "me"} + + @api.get("/api/tags/c++,%2F") + def tags(self) -> list: + return [] + + assert {r.path for r in from_environment(Chat).route_table().values()} == {"/api/users/@me", "/api/tags/c++,%2F"} + assert TestClient(Chat().create_app()).get("/api/users/@me").json() == {"id": "me"} + + +def test_from_environment_keeps_any_name_an_api_route_takes() -> None: + @environment_card(name="shop") + @website(domains=["shop.test"]) + class Shop(WebsiteEnvironment): + @api.get("/api/items", name="items:list") + def items(self) -> list: + return [] + + assert list(from_environment(Shop).route_table()) == ["items:list"] + card = card_of(TestClient(Shop().create_app())) + assert [r["name"] for r in website_params(card)["routes"]] == ["items:list"] + assert list(from_card(card).route_table()) == ["items:list"] + for derived in (from_environment(Shop), from_card(card)): + assert derived.overlay({"site": {"domains": ["shop.example"]}}).site.domains == ("shop.example",) + assert "routes.ops: route names ['items:list'] aren't names" in problems( + {"routes": {"ops": {"items:list": "GET /api/items"}}}, Path.cwd())[0] + + +def test_a_site_whose_domains_are_in_upper_case_starts_and_its_spec_names_them_in_lower_case() -> None: + @environment_card(name="notes") + @website(domains=["Notes.TEST", "*.Notes.Test"]) + class Notes(WebsiteEnvironment): + pass + + card = card_of(TestClient(Notes().create_app())) + assert from_environment(Notes).site.domains == ("notes.test", "*.notes.test") + assert from_card(card).site.domains == ("notes.test", "*.notes.test") + + +def test_from_environment_keeps_a_head_or_options_route_an_api_route_takes() -> None: + @environment_card(name="shop") + @website(domains=["shop.test"]) + class Shop(WebsiteEnvironment): + @api.route("HEAD", "/ping") + def ping(self) -> dict: + return {} + + @api.route("OPTIONS", "/items") + def items_options(self) -> dict: + return {} + + assert {name: (r.method, r.path) for name, r in from_environment(Shop).route_table().items()} == { + "ping": ("HEAD", "/ping"), "items_options": ("OPTIONS", "/items")} + routes = website_params(card_of(TestClient(Shop().create_app())))["routes"] + assert [(r["method"], r["name"]) for r in routes] == [("HEAD", "ping"), ("OPTIONS", "items_options")] + + +def test_a_class_declared_from_a_spec_must_serve_what_it_declares(tmp_path: Path) -> None: + path = write(tmp_path, {"site.toml": '[site]\nname = "wiki"\ndomains = ["wiki.test"]\n[source]\nsdk = "m:Wiki"\n' + '[routes.ops]\npages = "GET /api/pages"\nedit = "PUT /api/pages/{id}"\n' + '[roles]\nadmin = ["edit"]\n'}) + + class Wiki(WebsiteEnvironment): + @api.get("/api/pages") + def pages(self) -> list: + return [] + + @api.put("/api/pages/{id}", name="edit") + def edit_page(self, id: str) -> dict: + return {} + + @api.get("/api/extra") + def extra(self) -> dict: + return {} + + with pytest.raises(SpecError) as caught: + website(spec=path)(Wiki) + assert [p.split(": ", 1)[1] for p in caught.value.problems] == [ + "routes.ops.edit: the spec says PUT /api/pages/{id} for ['admin'], the class serves PUT /api/pages/{id}", + "routes.ops: Wiki serves extra (GET /api/extra), which the spec doesn't declare"] + with pytest.raises(SpecError, match="source.sdk: m:Wiki names another class than Other"): + website(spec=load(path).overlay({"routes": {"ops": {"edit": False}}, "roles": {"admin": False}}))( + type("Other", (WebsiteEnvironment,), {})) + with pytest.raises(TypeError, match="takes the site from its spec"): + website(spec=path, domains=["x.test"]) + + +def test_a_class_declared_from_a_spec_is_named_and_seeded_by_it(tmp_path: Path) -> None: + write(tmp_path, {"a.json": '{"current_user": "ann", "pages": [{"id": 1}]}', "b.json": '{"pages": [{"id": 2}]}', + "dist/index.html": ""}) + path = write(tmp_path, {"site.toml": '[site]\nname = "wiki"\ndomains = ["wiki.test"]\nhealth = "/up"\n' + '[source]\nsdk = "m:Wiki"\nassets = "dist"\n[data]\nseed = ["a.json", "b.json"]\n' + '[clock]\nfollow = "date_patch"\n'}) + + @website(spec=path) + class Wiki(WebsiteEnvironment): + pass + + env = Wiki() + client = TestClient(env.create_app()) + assert card_of(client)["name"] == "wiki" and client.get("/up").status_code == 200 + assert env.store["pages"] == [{"id": 1}, {"id": 2}] and env.store.current_user == "ann" + assert Wiki.declaration().clock_patch and "