From 997449b74d4cc3338cd51d3caf5012a225c13b39 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 13:34:07 -0800 Subject: [PATCH 01/20] feat(website): a Notes site on WebsiteEnvironment, one image that passes the conformance check (FD-4683) This PR's change, rebased as one commit onto #428. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/.gitignore | 2 + .../examples/website/notes/Dockerfile | 26 + .../website/notes/Dockerfile.dockerignore | 6 + .../examples/website/notes/README.md | 89 ++ .../examples/website/notes/data/seed.json | 15 + .../examples/website/notes/env.toml | 4 + .../examples/website/notes/site.py | 131 +++ .../examples/website/notes/web/index.html | 34 + .../website/notes/web/package-lock.json | 1020 +++++++++++++++++ .../examples/website/notes/web/package.json | 14 + .../examples/website/notes/web/src/main.js | 210 ++++ .../examples/website/notes/web/src/style.css | 57 + .../examples/website/notes/web/vite.config.js | 11 + .../tests/test_website_notes_example.py | 237 ++++ 14 files changed, 1856 insertions(+) create mode 100644 packages/agentenv-protocol/examples/website/notes/.gitignore create mode 100644 packages/agentenv-protocol/examples/website/notes/Dockerfile create mode 100644 packages/agentenv-protocol/examples/website/notes/Dockerfile.dockerignore create mode 100644 packages/agentenv-protocol/examples/website/notes/README.md create mode 100644 packages/agentenv-protocol/examples/website/notes/data/seed.json create mode 100644 packages/agentenv-protocol/examples/website/notes/env.toml create mode 100644 packages/agentenv-protocol/examples/website/notes/site.py create mode 100644 packages/agentenv-protocol/examples/website/notes/web/index.html create mode 100644 packages/agentenv-protocol/examples/website/notes/web/package-lock.json create mode 100644 packages/agentenv-protocol/examples/website/notes/web/package.json create mode 100644 packages/agentenv-protocol/examples/website/notes/web/src/main.js create mode 100644 packages/agentenv-protocol/examples/website/notes/web/src/style.css create mode 100644 packages/agentenv-protocol/examples/website/notes/web/vite.config.js create mode 100644 packages/agentenv-protocol/tests/test_website_notes_example.py diff --git a/packages/agentenv-protocol/examples/website/notes/.gitignore b/packages/agentenv-protocol/examples/website/notes/.gitignore new file mode 100644 index 000000000..545a6e274 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/.gitignore @@ -0,0 +1,2 @@ +web/node_modules/ +web/dist/ diff --git a/packages/agentenv-protocol/examples/website/notes/Dockerfile b/packages/agentenv-protocol/examples/website/notes/Dockerfile new file mode 100644 index 000000000..461e886b2 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/Dockerfile @@ -0,0 +1,26 @@ +ARG NOTES=packages/agentenv-protocol/examples/website/notes + +FROM mirror.gcr.io/library/node:22-slim@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da AS web +ARG NOTES +WORKDIR /web +COPY ${NOTES}/web/package.json ${NOTES}/web/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY ${NOTES}/web/ ./ +RUN npm run build + +FROM mirror.gcr.io/library/python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf +ARG NOTES +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}/data /app/data +COPY --from=web /web/dist /app/web/dist +ENV PORT=8000 +EXPOSE 8000 +RUN useradd --create-home --uid 10001 appuser +USER appuser +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/healthz' % os.environ.get('PORT', '8000'), timeout=2)" +CMD ["python", "site.py"] diff --git a/packages/agentenv-protocol/examples/website/notes/Dockerfile.dockerignore b/packages/agentenv-protocol/examples/website/notes/Dockerfile.dockerignore new file mode 100644 index 000000000..10ae9270c --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/Dockerfile.dockerignore @@ -0,0 +1,6 @@ +**/node_modules +**/dist +**/__pycache__ +**/.pytest_cache +**/.venv +.git diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md new file mode 100644 index 000000000..3311112b0 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -0,0 +1,89 @@ +# Notes: a reference website/v1 site + +Notes is a small notes app built on `agentenv_protocol.website.WebsiteEnvironment`. It is the reference site for the +`urn:agentenv:website/v1` server contract. Each user keeps their own notes and can create, edit, mark done and delete +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). | +| `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. | +| `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. | + +## 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. +- **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 + sends `AgentEnv-Role: admin` with `AgentEnv-Role-Key` equal to the container's `WEBSITE_ROLE_KEY`; any other caller + gets 403. The gateway passes on whatever role a browser sends, so without the key a page could claim `admin`; with no + `WEBSITE_ROLE_KEY` set, every caller is `default` and the route is closed. +- **Users.** `ApiContext.user` is the user in the signed session cookie. With no cookie, it is the acting user (the + store's `current_user`), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded + user. A user's notes are invisible to every other user, and another user's note answers 404. +- **State.** The site writes no data hooks, so it uses the default store. `data/add` loads the seed (as a data part, or + as a file part by bytes or `file://`), `data/get` exports it, `data/reset` empties it, and `/go` returns + `{initial_state, current_state, state_diff}`. +- **Time.** Notes are stamped with `self.now()`, which follows the gateway's clock once `clock/v1`'s `sync_time` names + it. The frontend reads `X-Universe-Now` from every API response into `universeNow()` and shows it in the header. + It uses this helper rather than `clock_patch=True`, so the page works the same against a server written without the + SDK. +- **Readiness.** `@ready_check seeded` holds `/healthz` at 503 until the store has users, for example after a + `data/reset` and before the next `data/add`. + +## Run it + +From this folder: + +```bash +cd web && npm ci && npm run build && cd .. +PYTHONPATH=/packages/agentenv-protocol/src python site.py +``` + +Open http://localhost:8000. To work on the frontend, run `npm run dev` in `web/` while `site.py` runs; Vite proxies +`/api` to port 8000. + +## Test it + +```bash +python -m pytest packages/agentenv-protocol/tests/test_website_notes_example.py +``` + +The test imports `site.py`, runs the conformance checker, and checks sign-in, per-user notes, the admin-only route, +clock-synced timestamps, and the data plane's reset and seed load. It serves a stand-in page, so it needs no Vite +build. + +## Build and verify the image + +The build context is the repo root, because the image installs the protocol package from this repo's source: + +```bash +docker build -f packages/agentenv-protocol/examples/website/notes/Dockerfile -t notes-site . +docker run -d --name notes-site -p 18080:8000 notes-site +python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal +curl -s http://localhost:18080/api/notes +``` + +Use `--clock-host` so the container can reach the checker's clock. The clock check leaves the server following a +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. + +## Register it + +```bash +agent-env env website put --id notes-site \ + --server-dockerfile packages/agentenv-protocol/examples/website/notes/Dockerfile \ + --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 +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 +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/data/seed.json b/packages/agentenv-protocol/examples/website/notes/data/seed.json new file mode 100644 index 000000000..0a52ab7be --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/data/seed.json @@ -0,0 +1,15 @@ +{ + "current_user": "ann", + "users": [ + {"id": "ann", "name": "Ann Lee"}, + {"id": "ben", "name": "Ben Ortiz"} + ], + "notes": [ + {"id": 1, "owner": "ann", "title": "Buy milk", "body": "Oat, two cartons.", "done": false, + "created_at": "2026-01-05T09:00:00.000Z", "updated_at": "2026-01-05T09:00:00.000Z"}, + {"id": 2, "owner": "ann", "title": "Book dentist", "body": "", "done": true, + "created_at": "2026-01-04T15:30:00.000Z", "updated_at": "2026-01-06T08:10:00.000Z"}, + {"id": 3, "owner": "ben", "title": "Draft the quarterly report", "body": "Due Friday.", "done": false, + "created_at": "2026-01-03T11:00:00.000Z", "updated_at": "2026-01-03T11:00:00.000Z"} + ] +} diff --git a/packages/agentenv-protocol/examples/website/notes/env.toml b/packages/agentenv-protocol/examples/website/notes/env.toml new file mode 100644 index 000000000..a1d019251 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/env.toml @@ -0,0 +1,4 @@ +type = "website" +environment_name = "notes" +health = "/healthz" +backend_image = { dockerfile = "Dockerfile" } diff --git a/packages/agentenv-protocol/examples/website/notes/site.py b/packages/agentenv-protocol/examples/website/notes/site.py new file mode 100644 index 000000000..5eaafda0e --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/site.py @@ -0,0 +1,131 @@ +"""Notes: the reference site of ``urn:agentenv:website/v1``, a small notes app on WebsiteEnvironment. Each user keeps +notes they create, edit, mark done and delete; a browser with no session is signed in as the acting user, the seed's +``current_user``, and can sign in as another seeded user. The site keeps its state in the default store, which the data +plane loads, exports and resets, and stamps each note with the universe's time. ``admin`` callers, as the gateway's +``AgentEnv-Role`` names them, can list every user's notes. + +Run it with ``python site.py`` after building ``web/`` into ``web/dist``; it serves on ``PORT`` (8000).""" +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel + +from agentenv_protocol import environment_card +from agentenv_protocol.website import ( + ApiContext, + BadRequest, + NotFound, + Unauthorized, + WebsiteEnvironment, + api, + ready_check, + website, +) +from agentenv_protocol.website.clock import iso + +NOTES = "notes" +USERS = "users" + + +class NewNote(BaseModel): + title: str + body: str = "" + + +class NoteEdit(BaseModel): + title: Optional[str] = None + body: Optional[str] = None + done: Optional[bool] = None + + +class SignIn(BaseModel): + user: str + + +@environment_card(name="notes") +@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json") +class Notes(WebsiteEnvironment): + @ready_check + def seeded(self) -> bool: + """The site is usable once its store knows who may sign in.""" + return bool(self.store[USERS]) + + @api.get("/api/users", name="list_users") + def list_users(self) -> list: + """The users a browser can sign in as.""" + return self.store[USERS] + + @api.post("/api/sign-in", name="sign_in") + def sign_in(self, body: SignIn, ctx: ApiContext) -> dict: + """Sign the browser in as one of the site's users.""" + if self.store.get(USERS, body.user) is None: + raise NotFound(f"no user {body.user!r}") + ctx.sign_in(body.user) + return {"user": ctx.user} + + @api.get("/api/notes", name="list_notes") + def list_notes(self, ctx: ApiContext, done: Optional[bool] = None) -> list: + """The signed-in user's notes, newest first; with ``done``, only those done or not.""" + match = {"owner": self._user(ctx)} if done is None else {"owner": self._user(ctx), "done": done} + return sorted(self.store.find(NOTES, **match), key=lambda note: note["created_at"], reverse=True) + + @api.post("/api/notes", name="create_note", status=201) + def create_note(self, note: NewNote, ctx: ApiContext) -> dict: + """Add a note for the signed-in user, not done, created and updated now.""" + now = iso(self.now()) + return self.store.insert(NOTES, {"owner": self._user(ctx), "title": _title(note.title), "body": note.body, + "done": False, "created_at": now, "updated_at": now}) + + @api.get("/api/notes/{note_id:int}", name="get_note") + def get_note(self, note_id: int, ctx: ApiContext) -> dict: + return self._note(note_id, ctx) + + @api.patch("/api/notes/{note_id:int}", name="edit_note") + def edit_note(self, note_id: int, edit: NoteEdit, ctx: ApiContext) -> dict: + """Change a note's title, body or done, and its ``updated_at``.""" + note = self._note(note_id, ctx) + fields = edit.model_dump(exclude_none=True) + if "title" in fields: + fields["title"] = _title(fields["title"]) + note.update(fields, updated_at=iso(self.now())) + return note + + @api.post("/api/notes/{note_id:int}/toggle", name="toggle_note") + def toggle_note(self, note_id: int, ctx: ApiContext) -> dict: + """Mark a note done, or not done when it was.""" + note = self._note(note_id, ctx) + note.update(done=not note["done"], updated_at=iso(self.now())) + return note + + @api.delete("/api/notes/{note_id:int}", name="delete_note") + def delete_note(self, note_id: int, ctx: ApiContext) -> dict: + self.store.delete(NOTES, self._note(note_id, ctx)["id"]) + return {"deleted": note_id} + + @api.get("/api/admin/notes", name="all_notes", roles=["admin"]) + def all_notes(self) -> list: + """Every user's notes, for an ``admin`` caller only.""" + return self.store[NOTES] + + def _user(self, ctx: ApiContext) -> str: + if ctx.user is None: + raise Unauthorized("sign in first") + return ctx.user + + def _note(self, note_id: int, ctx: ApiContext) -> dict: + """The signed-in user's note ``note_id``; another user's is as missing as one that doesn't exist.""" + note = self.store.get(NOTES, note_id) + if note is None or note["owner"] != self._user(ctx): + raise NotFound(f"no note {note_id}") + return note + + +def _title(title: str) -> str: + if not title.strip(): + raise BadRequest("a note needs a title", code="empty_title") + return title.strip() + + +if __name__ == "__main__": + Notes().serve() diff --git a/packages/agentenv-protocol/examples/website/notes/web/index.html b/packages/agentenv-protocol/examples/website/notes/web/index.html new file mode 100644 index 000000000..1e498d3c1 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/index.html @@ -0,0 +1,34 @@ + + + + + + Notes + + +
+

Notes

+
+ + +
+
+
+
+ + + +
+ + +
    +
    + + + diff --git a/packages/agentenv-protocol/examples/website/notes/web/package-lock.json b/packages/agentenv-protocol/examples/website/notes/web/package-lock.json new file mode 100644 index 000000000..938f9c2cd --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/package-lock.json @@ -0,0 +1,1020 @@ +{ + "name": "notes-site-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "notes-site-web", + "version": "0.0.0", + "devDependencies": { + "vite": "5.4.10" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.2.tgz", + "integrity": "sha512-qB7Gzl1QI45zP1zXgqLKzjFmOBGQw2s2MLtdw0eB4xdFGPNE8LhmOnItFgyYJXXNSW8Go9c2ovvXrUUzw5sAMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.2.tgz", + "integrity": "sha512-8+hXNO1J+GsYCGHwyJuBlnhyozivEvIEm/P6NkJeuoVUN+60TDvYYXk38UTm5bKRTLhIpqEQYx4d9CeN6kjh0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.2.tgz", + "integrity": "sha512-/uEZY+tcWDE8DXa/rajU3Bwv6IfvQavvzjmbkn39GgQenT+UYtAxfEMdB7VKr74y8aonncm0eQa1EbiNfg+Cjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.2.tgz", + "integrity": "sha512-3tCLEgakEvb5M3/KyYdhPjoteyWYziMTeznoXfcs/31eeFu+Vgs0VNRlb6Zc+YC07/sLa84OwTDF5zHOMFk92Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.2.tgz", + "integrity": "sha512-z31ODzkdkX7OOJCssvpz++hhtkQKWKw2L0eO2HAxFP7RMSXXedzkrTjesqCUxs4XLckRShjNfxsNxjGY5DQCGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.2.tgz", + "integrity": "sha512-CTvrb0KTAQqcrpcj7rg8FgAI0+jRWnCl+En6exqXDZztCo7vspxlRUX8z9Hp/J/ja2XRnQ1JDbexkBtRyD9jSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.2.tgz", + "integrity": "sha512-SJdblZpBkbOh1e4JwMM5pJJ2PdVmD3WWbMTXvncMSRLA3DdZuwU+P93RaSChOAXN4Mu53tBoFp1ADPzi3dIVAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.2.tgz", + "integrity": "sha512-pUBEd7uI9dAu3TpbPcvi9eU2aJslsjIomormeTaQHmzWNhyqNx3mmq+ekC6blGNZu8F3uTHngTibKp7tbblF0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.2.tgz", + "integrity": "sha512-wW6ARw2Ah0/teOBTgTeSJIpVdPKaxQmEgZ/irkUfr3F9sgimqpApCv/NbQzjm0Nfi5EFilHh1H8kz01uQLaq5Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.2.tgz", + "integrity": "sha512-xcFYMHNL42fRu2MFipvxznr+zd+DRqwL98bhNvDWbMPRcFtMYf34NAz4xVOlnEhzgpNRE5htSemcqAsAxf2eQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.2.tgz", + "integrity": "sha512-U9HrnJ9PFXqA//P/oEGaXBIJraEqzYMS9yuQOdOcPCvHZIC1FFOlnd8ocAaeDDPDpY8xOrhfxMRtr6KqKi3gyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.2.tgz", + "integrity": "sha512-2hZN8CFGfsbDw+DNtImkPRitpLNjVM2XEbi4ZQ5igLxMFD4gI/P6Zo9czGxuvQXtRyZtZyZp8Kr4ERsAipplBw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.2.tgz", + "integrity": "sha512-HZO7UfWEsReAnkdVByyQaFsNXJCtvdfun/LFb2Xd4qnhgF1ZfhikpbgqrTRtNMmB5OgNnSwFLiPobOT8oNT9iw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.2.tgz", + "integrity": "sha512-AdYIihuLkJ2TScy9BpSPiDex3zOMjkNJLsFlk9NeJ0U3WEtOx4W8z0nOkPEhDS2S4gwsRWBa6ZZexJ9e6OxyzQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.2.tgz", + "integrity": "sha512-iZE0NXcAGqLoCWeHRPkBxJe93JT4Q981z1ZqR+y7qAiIksdRlWhw8hmqHFo1B4ZnU66He31NUb52Iyv+vwMEBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.2.tgz", + "integrity": "sha512-vC7EAtJpSqSrrbg8huMfgxtwlR8fUsFCe5rStDk1WA8POky/6EEp52dxENMmqLKjSfdM3TT1WWPcZ01hiYZcdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.2.tgz", + "integrity": "sha512-DdqG6x72MHpQOlkK/cqrru9b2QMJHLkNojCB3bteB5fmkE/7XEAkReJMANp8wgef8eiEC99SvdP/BH7Ho/BpLA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.2.tgz", + "integrity": "sha512-F10mjdnRmGg9K+LGdqQVeUYFr08f3fVBOh7YNE5p1KIpTzhtKKOLqcQEsqZG6yEM2eouXaMo2AZzeDAcseVTrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.2.tgz", + "integrity": "sha512-ChzK9f3iE4N5GInBquiZtzWOqh+CYS5nlLlXGJxeG3d2T9rk8L1Hxg0s+v2Eh6vmAc/1T6IhOVkQU0ORP2eV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.2.tgz", + "integrity": "sha512-95WwzC+KH9hPHtbgwvwwW6MkKME6vMAdAQLS2diCvoi/iUiYdC0Y1RIPYVESx0iWqkN5ZaZq6qD1BflLcyTMEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.2.tgz", + "integrity": "sha512-eKbpwlolQmDKd/dbFjvqB1+2qdYQLrgxi3Mtbijp7/3yBBvO9OoHtQ3ecYIaCdW46ijSjy5/yydyrrxHI416/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.2.tgz", + "integrity": "sha512-is9lnlv6khapl+H9Hjp0gIVsUVtDxQga2nwMHpQbhDXMCMGcNmlel4/q9ihf/3FvvbL4GdGOPOGvWoei2RCggg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.2.tgz", + "integrity": "sha512-yTq+7YjsgbBcMdlk7Wq+VoJilGQtdtRc4PyTvN7vhO958atCO2nQeIjCF/YANSD/Z/fnrCr27fYsGgC6PFTndQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.67.2.tgz", + "integrity": "sha512-nAvZib4Xz1zx8OKhtRuyCsSmCmbfegv1AGbfpG+CowQKxCBW1pLlfYWjcr1+GI3BzRKQbBl7l9640GwWAwOF7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.2.tgz", + "integrity": "sha512-R3CVbEbNa1OXjpldD1tOLarb5rB08xjQiRMpe7PZKtT8eHCZhrV4ClSPfLLbSgRMM1RfLtmAfouBRf14f47lMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.2.tgz", + "integrity": "sha512-DeuVFilvaGtoS9TFIbTIQ0Kt4CBqP3xnjJ8SFqWWbXlFbrpmCdivEW7Rm38trRA8rZzQQ8lhs73nEetWotJpTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.67.2.tgz", + "integrity": "sha512-sFZk5BeRUQzkjmuClrJI41BowIP4yv/40d5gM9vtjxVLEt+8JG9FGlBps6cliTP2KhFiw22oqNGo0G9pVpPZlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.67.2", + "@rollup/rollup-android-arm64": "4.67.2", + "@rollup/rollup-android-x64": "4.67.2", + "@rollup/rollup-darwin-arm64": "4.67.2", + "@rollup/rollup-darwin-x64": "4.67.2", + "@rollup/rollup-freebsd-arm64": "4.67.2", + "@rollup/rollup-freebsd-x64": "4.67.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.67.2", + "@rollup/rollup-linux-arm-musleabihf": "4.67.2", + "@rollup/rollup-linux-arm64-gnu": "4.67.2", + "@rollup/rollup-linux-arm64-musl": "4.67.2", + "@rollup/rollup-linux-loong64-gnu": "4.67.2", + "@rollup/rollup-linux-loong64-musl": "4.67.2", + "@rollup/rollup-linux-ppc64-gnu": "4.67.2", + "@rollup/rollup-linux-ppc64-musl": "4.67.2", + "@rollup/rollup-linux-riscv64-gnu": "4.67.2", + "@rollup/rollup-linux-riscv64-musl": "4.67.2", + "@rollup/rollup-linux-s390x-gnu": "4.67.2", + "@rollup/rollup-linux-x64-gnu": "4.67.2", + "@rollup/rollup-linux-x64-musl": "4.67.2", + "@rollup/rollup-openbsd-x64": "4.67.2", + "@rollup/rollup-openharmony-arm64": "4.67.2", + "@rollup/rollup-win32-arm64-msvc": "4.67.2", + "@rollup/rollup-win32-ia32-msvc": "4.67.2", + "@rollup/rollup-win32-x64-gnu": "4.67.2", + "@rollup/rollup-win32-x64-msvc": "4.67.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/packages/agentenv-protocol/examples/website/notes/web/package.json b/packages/agentenv-protocol/examples/website/notes/web/package.json new file mode 100644 index 000000000..c741095dd --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/package.json @@ -0,0 +1,14 @@ +{ + "name": "notes-site-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "5.4.10" + } +} diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js new file mode 100644 index 000000000..f4318fabf --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -0,0 +1,210 @@ +import "./style.css"; + +let offsetMs = 0; +let filter = "all"; +let renders = 0; +const saving = new Map(); +const lastSent = new Map(); +const landed = new Map(); +const CLOCK_REFRESH_MS = 5000; + +export function universeNow() { + return new Date(Date.now() + offsetMs); +} + +function followUniverse(response) { + const at = Date.parse(response.headers.get("X-Universe-Now") || ""); + if (!Number.isNaN(at)) offsetMs = at - Date.now(); +} + +async function api(path, { method = "GET", body } = {}) { + const response = await fetch(path, { + method, + headers: body === undefined ? {} : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + followUniverse(response); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || `${method} ${path}: ${response.status}`); + return data; +} + +const $ = (id) => document.getElementById(id); + +function queueSave(id, fields) { + lastSent.set(id, fields); + const queued = saving.get(id); + if (queued) { + queued.next = fields; + return; + } + const state = { next: fields }; + saving.set(id, state); + run(async () => { + try { + while (state.next) { + const next = state.next; + state.next = null; + await api(`api/notes/${id}`, { method: "PATCH", body: next }); + } + landed.set(id, renders); + } catch (error) { + lastSent.set(id, {}); + landed.delete(id); + throw error; + } finally { + saving.delete(id); + } + }); +} + +function showError(error) { + $("error").textContent = error ? error.message : ""; + $("error").hidden = !error; +} + +function formatTime(value) { + return new Date(value).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }); +} + +function tick() { + $("universe-now").textContent = `${formatTime(universeNow())} UTC`; + $("universe-now").dateTime = universeNow().toISOString(); +} + +function noteItem(note) { + const item = document.createElement("li"); + item.className = `note card${note.done ? " done" : ""}`; + item.dataset.id = note.id; + + const done = document.createElement("input"); + done.type = "checkbox"; + done.checked = note.done; + done.setAttribute("aria-label", `Mark "${note.title}" ${note.done ? "not done" : "done"}`); + done.addEventListener("change", () => run(() => api(`api/notes/${note.id}/toggle`, { method: "POST" }))); + + const title = document.createElement("input"); + title.className = "title"; + title.defaultValue = note.title; + title.setAttribute("aria-label", "Title"); + + const body = document.createElement("textarea"); + body.className = "body"; + body.rows = 2; + body.defaultValue = note.body; + body.placeholder = "Details"; + body.setAttribute("aria-label", "Details"); + + const save = () => { + const sent = lastSent.get(note.id) ?? { title: note.title, body: note.body }; + if (title.value === sent.title && body.value === sent.body) return; + queueSave(note.id, { title: title.value, body: body.value }); + }; + for (const field of [title, body]) { + field.addEventListener("change", save); + field.addEventListener("blur", save); + } + + const meta = document.createElement("small"); + meta.textContent = `Created ${formatTime(note.created_at)} ยท updated ${formatTime(note.updated_at)}`; + + const remove = document.createElement("button"); + remove.className = "delete"; + remove.textContent = "Delete"; + remove.addEventListener("click", () => run(() => api(`api/notes/${note.id}`, { method: "DELETE" }))); + + const text = document.createElement("div"); + text.className = "text"; + text.append(title, body, meta); + item.append(done, text, remove); + return item; +} + +function editing() { + const kept = []; + for (const field of document.querySelectorAll("#notes .title, #notes .body")) { + const focused = field === document.activeElement; + if (focused || field.value !== field.defaultValue) { + kept.push({ id: field.closest("li").dataset.id, kind: field.className, value: field.value, focused, + start: field.selectionStart, end: field.selectionEnd }); + } + } + return kept; +} + +function restore(kept) { + for (const { id, kind, value, focused, start, end } of kept) { + const field = document.querySelector(`#notes li[data-id="${id}"] .${kind}`); + if (!field) continue; + field.value = value; + if (focused) { + field.focus(); + field.setSelectionRange(start, end); + } + } +} + +async function render() { + const query = filter === "all" ? "" : `?done=${filter === "done"}`; + const request = ++renders; + const notes = await api(`api/notes${query}`); + if (request !== renders) return; + for (const [id, after] of landed) { + if (request > after && !saving.has(id)) { + lastSent.delete(id); + landed.delete(id); + } + } + const kept = editing(); + $("notes").replaceChildren(...notes.map(noteItem)); + restore(kept); + if (!notes.length) { + const empty = document.createElement("li"); + empty.className = "empty"; + empty.textContent = "No notes here yet."; + $("notes").append(empty); + } +} + +async function run(action) { + try { + await action(); + showError(null); + await render(); + } catch (error) { + showError(error); + } +} + +async function start() { + const [session, users] = await Promise.all([api("api/session"), api("api/users")]); + $("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id))); + $("user").value = session.user ?? ""; + $("user").addEventListener("change", (event) => + run(() => api("api/sign-in", { method: "POST", body: { user: event.target.value } }))); + + $("new-note").addEventListener("submit", (event) => { + event.preventDefault(); + const body = { title: $("new-title").value, body: $("new-body").value }; + run(async () => { + await api("api/notes", { method: "POST", body }); + if ($("new-title").value === body.title) $("new-title").value = ""; + if ($("new-body").value === body.body) $("new-body").value = ""; + }); + }); + + for (const button of document.querySelectorAll("[data-filter]")) { + button.addEventListener("click", () => { + filter = button.dataset.filter; + document.querySelectorAll("[data-filter]").forEach((b) => b.classList.toggle("active", b === button)); + run(async () => {}); + }); + } + + tick(); + setInterval(tick, 1000); + setInterval(() => fetch("healthz").then(followUniverse).catch(() => {}), CLOCK_REFRESH_MS); + await render(); +} + +start().catch(showError); diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/style.css b/packages/agentenv-protocol/examples/website/notes/web/src/style.css new file mode 100644 index 000000000..8ecd48b6a --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/src/style.css @@ -0,0 +1,57 @@ +:root { + --bg: #f6f6f3; + --card: #ffffff; + --text: #1d1d1b; + --muted: #6b6b66; + --line: #deded8; + --accent: #2f5bd3; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + color: var(--text); + background: var(--bg); +} + +* { box-sizing: border-box; } +body { margin: 0; } + +.bar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--line); + background: var(--card); +} +.bar h1 { margin: 0; font-size: 1.25rem; } +.who { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; color: var(--muted); font-size: 0.9rem; } +.who time { font-variant-numeric: tabular-nums; } + +main { max-width: 720px; margin: 0 auto; padding: 16px; } + +.card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px; } + +input, textarea, select, button { font: inherit; color: inherit; } +input, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: transparent; } +textarea { resize: vertical; } +button { border: 1px solid var(--line); border-radius: 6px; padding: 6px 12px; background: var(--card); cursor: pointer; } +button[type="submit"], .filters .active { background: var(--accent); border-color: var(--accent); color: #fff; } + +#new-note { display: grid; gap: 8px; } +#new-note button { justify-self: end; } + +.filters { display: flex; gap: 8px; margin: 16px 0; } + +#error { color: #b3261e; margin: 0 0 12px; } + +#notes { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.note { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: start; } +.note .text { display: grid; gap: 6px; min-width: 0; } +.note .title { font-weight: 600; border-color: transparent; } +.note .body { border-color: transparent; color: var(--muted); } +.note .title:focus, .note .body:focus { border-color: var(--line); } +.note small { color: var(--muted); } +.note.done .title { text-decoration: line-through; color: var(--muted); } +.note input[type="checkbox"] { width: auto; margin-top: 10px; } +.delete { color: #b3261e; } +.empty { color: var(--muted); text-align: center; padding: 24px; } diff --git a/packages/agentenv-protocol/examples/website/notes/web/vite.config.js b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js new file mode 100644 index 000000000..d07ecd2c2 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + base: "./", + server: { + proxy: { + "/api": "http://127.0.0.1:8000", + "/healthz": "http://127.0.0.1:8000", + }, + }, +}); diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py new file mode 100644 index 000000000..e02147b10 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -0,0 +1,237 @@ +"""The Notes example site (examples/website/notes) as website/v1's reference site: it passes the conformance checker, +and its own behavior holds: the acting user's auto sign-in and signing in as another user, notes kept per user, the +admin-only route, timestamps from the universe's clock, and the store's seed loaded, exported and reset through the +data plane.""" + +from __future__ import annotations + +import importlib.util +import json +import re +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urljoin, urlsplit + +import pytest +from starlette.responses import PlainTextResponse +from starlette.testclient import TestClient + +from agentenv_protocol.website import (ROLE_KEY_ENV, ROLE_KEY_HEADER, SESSION_COOKIE, UNIVERSE_NOW_HEADER, WebsiteEnvironment, + conformance, website) + +NOTES_DIR = Path(__file__).resolve().parents[1] / "examples" / "website" / "notes" +SEED = json.loads((NOTES_DIR / "data" / "seed.json").read_text(encoding="utf-8")) +RPC = "/agentenv" + + +def _load_site(): + """The example's ``site.py``, imported under a name of its own: ``site`` is the standard library's.""" + spec = importlib.util.spec_from_file_location("notes_example_site", NOTES_DIR / "site.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +notes_site = _load_site() + + +def rpc(client: TestClient, method: str, params: dict | None = None) -> dict: + body = client.post(RPC, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}).json() + assert "error" not in body, body + return body["result"] + + +@pytest.fixture +def notes_class(tmp_path: Path) -> type[WebsiteEnvironment]: + """The example's class, serving a stand-in page instead of ``web/dist``, which only a Vite build makes.""" + (tmp_path / "index.html").write_text("Notes") + declared = notes_site.Notes.declaration()._replace(static=str(tmp_path), data=str(NOTES_DIR / "data" / "seed.json")) + return website(**declared._asdict())(type("Notes", (notes_site.Notes,), {})) + + +@pytest.fixture +def env(notes_class: type[WebsiteEnvironment]) -> WebsiteEnvironment: + return notes_class() + + +@pytest.fixture +def app(env: WebsiteEnvironment): + return env.create_app() + + +@pytest.fixture +def client(app) -> TestClient: + return TestClient(app) + + +class _Clock: + """A gateway-shaped /clock/time whose time a test sets.""" + + def __init__(self, virtual: str) -> None: + self.virtual = virtual + outer = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + body = json.dumps({"virtual_time": outer.virtual}).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args) -> None: + return None + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.url = f"http://127.0.0.1:{self.server.server_port}/clock/time" + + def close(self) -> None: + self.server.shutdown() + self.server.server_close() + + +@pytest.fixture +def gateway_clock(): + clock = _Clock("2040-02-03T04:05:06Z") + yield clock + clock.close() + + +def test_the_example_passes_every_conformance_check(client: TestClient) -> None: + results = conformance.check("http://testserver", client=client) + assert {r.name: r.status for r in results} == dict.fromkeys( + ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"], "passed"), results + + +def test_the_card_declares_the_site_its_health_path_and_its_routes_by_name(client: TestClient) -> None: + card = client.get("/.well-known/agent-env.json").json() + site = next(e for e in card["capabilities"]["extensions"] if e["uri"] == "urn:agentenv:website/v1")["params"] + assert card["name"] == "notes" + assert (site["domains"], site["health"]) == (["notes.test"], "/healthz") + assert [r["name"] for r in site["routes"]] == ["list_users", "sign_in", "list_notes", "create_note", "get_note", + "edit_note", "toggle_note", "delete_note", "all_notes"] + assert next(r for r in site["routes"] if r["name"] == "all_notes")["roles"] == ["admin"] + assert client.get("/healthz").json() == {"status": "ok"} + + +def test_a_browser_is_signed_in_as_the_acting_user_and_can_sign_in_as_another(client: TestClient) -> None: + session = client.get("/api/session") + assert session.json() == {"user": "ann", "role": "default", "signed_in": True} + assert SESSION_COOKIE in session.cookies + assert [n["title"] for n in client.get("/api/notes").json()] == ["Buy milk", "Book dentist"] + assert client.post("/api/sign-in", json={"user": "ben"}).json() == {"user": "ben"} + assert client.get("/api/session").json()["user"] == "ben" + assert [n["title"] for n in client.get("/api/notes").json()] == ["Draft the quarterly report"] + refused = client.post("/api/sign-in", json={"user": "mallory"}) + assert refused.status_code == 404 and refused.json()["code"] == "not_found" + + +def test_notes_are_created_edited_toggled_and_deleted_by_their_owner_only(app, client: TestClient) -> None: + created = client.post("/api/notes", json={"title": " Call Sam ", "body": "about the trip"}) + assert created.status_code == 201 + note = created.json() + assert (note["owner"], note["title"], note["done"]) == ("ann", "Call Sam", False) + assert note["created_at"] == note["updated_at"] + + ben = TestClient(app) + ben.post("/api/sign-in", json={"user": "ben"}) + assert "Call Sam" not in [n["title"] for n in ben.get("/api/notes").json()] + for response in (ben.get(f"/api/notes/{note['id']}"), ben.patch(f"/api/notes/{note['id']}", json={"done": True}), + ben.post(f"/api/notes/{note['id']}/toggle"), ben.delete(f"/api/notes/{note['id']}")): + assert response.status_code == 404, response.text + + edited = client.patch(f"/api/notes/{note['id']}", json={"body": "about the trip, before Friday"}).json() + assert (edited["title"], edited["body"]) == ("Call Sam", "about the trip, before Friday") + assert client.post(f"/api/notes/{note['id']}/toggle").json()["done"] is True + assert [n["title"] for n in client.get("/api/notes", params={"done": "true"}).json()] == ["Call Sam", "Book dentist"] + assert client.patch(f"/api/notes/{note['id']}", json={"title": " "}).json()["code"] == "empty_title" + assert client.post("/api/notes", json={"body": "no title"}).status_code == 400 + assert client.delete(f"/api/notes/{note['id']}").json() == {"deleted": note["id"]} + assert client.get(f"/api/notes/{note['id']}").status_code == 404 + + +def test_every_users_notes_are_listed_for_an_admin_caller_only(client: TestClient, + monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ROLE_KEY_ENV, "k3y") + for headers in ({}, {"AgentEnv-Role": "agent", ROLE_KEY_HEADER: "k3y"}, {"AgentEnv-Role": "admin"}, + {"AgentEnv-Role": "admin", ROLE_KEY_HEADER: "guess"}): + refused = client.get("/api/admin/notes", headers=headers) + assert refused.status_code == 403, headers + every = client.get("/api/admin/notes", headers={"AgentEnv-Role": "admin", ROLE_KEY_HEADER: "k3y"}).json() + assert {n["owner"] for n in every} == {"ann", "ben"} + + +def test_timestamps_follow_the_universes_clock_once_synced(client: TestClient, gateway_clock: _Clock) -> None: + before = client.post("/api/notes", json={"title": "before the sync"}).json() + assert not before["created_at"].startswith("2040") + client.post("/agentenv/ext/sync_time", json={"env_get_time_url": gateway_clock.url}) + after = client.post("/api/notes", json={"title": "after the sync"}) + assert after.json()["created_at"].startswith("2040-02-03T04:05") + assert after.headers[UNIVERSE_NOW_HEADER].startswith("2040-02-03T04:05") + toggled = client.post(f"/api/notes/{before['id']}/toggle").json() + assert toggled["created_at"] == before["created_at"] and toggled["updated_at"].startswith("2040-02-03T04:05") + + +def test_the_seed_loads_exports_and_resets_through_the_data_plane(client: TestClient) -> None: + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + rpc(client, "data/reset") + assert rpc(client, "data/get")["parts"][0]["data"] == {} + assert client.get("/api/notes").status_code == 401 + assert client.get("/healthz").status_code == 503 + + seed_file = {"kind": "file", "file": {"uri": (NOTES_DIR / "data" / "seed.json").as_uri(), + "mimeType": "application/json"}} + rpc(client, "data/add", {"parts": [seed_file]}) + assert client.get("/healthz").status_code == 200 + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + assert client.get("/api/session").json()["user"] == "ann" + + client.post("/api/notes/1/toggle") + state = client.get("/go").json() + assert state["initial_state"] == SEED + assert state["state_diff"]["notes"]["changed"][0]["after"]["done"] is True + + +GATEWAY_PREFIX = "/website/notes" + + +def _behind_gateway_prefix(app): + """The gateway's ``/website//`` route: a request under the prefix reaches the site with the prefix taken off, + and any other one answers the gateway's own 404.""" + + async def gateway(scope, receive, send): + if scope["type"] == "http" and scope["path"].startswith(GATEWAY_PREFIX + "/"): + path = scope["path"][len(GATEWAY_PREFIX):] + return await app({**scope, "path": path, "raw_path": path.encode()}, receive, send) + if scope["type"] != "http": + return await app(scope, receive, send) + await PlainTextResponse("no gateway route", status_code=404)(scope, receive, send) + + return gateway + + +@pytest.mark.skipif(not (NOTES_DIR / "web" / "dist" / "index.html").is_file(), reason="web/dist needs a Vite build") +def test_the_built_page_loads_its_assets_and_api_under_the_gateways_prefix() -> None: + page_url = f"http://gateway.test{GATEWAY_PREFIX}/" + browser = TestClient(_behind_gateway_prefix(notes_site.Notes().create_app()), base_url="http://gateway.test") + page = browser.get(page_url) + assert page.status_code == 200 and page.headers["content-type"].startswith("text/html") + + assets = [urljoin(page_url, ref) for ref in re.findall(r'(?:src|href)="([^"]+)"', page.text)] + assert assets and all(urlsplit(url).path.startswith(GATEWAY_PREFIX + "/") for url in assets), assets + scripts = [] + for url in assets: + fetched = browser.get(url) + assert fetched.status_code == 200, url + if url.endswith(".js"): + scripts.append(fetched.text) + + called = {path for script in scripts for path in re.findall(r'["`](/?api/[\w-]+)', script)} + assert {"api/session", "api/users", "api/notes", "api/sign-in"} <= called + assert not [path for path in called if path.startswith("/")], called + assert browser.get(urljoin(page_url, "api/session")).json()["user"] == "ann" + assert [u["id"] for u in browser.get(urljoin(page_url, "api/users")).json()] == ["ann", "ben"] From 7278968206601d6669de6a777dc715040ae53ccf Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 14:22:37 -0800 Subject: [PATCH 02/20] fix(website): the Notes page saves queued edits before switching users, and adds a note once per submit (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/web/src/main.js | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index f4318fabf..d061a6cea 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -40,7 +40,7 @@ function queueSave(id, fields) { } const state = { next: fields }; saving.set(id, state); - run(async () => { + state.done = run(async () => { try { while (state.next) { const next = state.next; @@ -180,14 +180,31 @@ async function start() { const [session, users] = await Promise.all([api("api/session"), api("api/users")]); $("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id))); $("user").value = session.user ?? ""; - $("user").addEventListener("change", (event) => - run(() => api("api/sign-in", { method: "POST", body: { user: event.target.value } }))); + $("user").addEventListener("change", (event) => { + const user = event.target.value; + $("user").disabled = true; + run(async () => { + try { + while (saving.size) await Promise.all([...saving.values()].map((state) => state.done)); + await api("api/sign-in", { method: "POST", body: { user } }); + } finally { + $("user").disabled = false; + } + }); + }); + const add = $("new-note").querySelector("button[type=submit]"); $("new-note").addEventListener("submit", (event) => { event.preventDefault(); + if (add.disabled) return; const body = { title: $("new-title").value, body: $("new-body").value }; + add.disabled = true; run(async () => { - await api("api/notes", { method: "POST", body }); + try { + await api("api/notes", { method: "POST", body }); + } finally { + add.disabled = false; + } if ($("new-title").value === body.title) $("new-title").value = ""; if ($("new-body").value === body.body) $("new-body").value = ""; }); From 13b3046db1662e3943d94800e966e6b3b76eb8be Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 15:01:02 -0800 Subject: [PATCH 03/20] fix(website): the Notes page keeps the signed-in user when a save or sign-in fails, and reloads itself at its slash-ending address (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/README.md | 2 +- .../examples/website/notes/web/index.html | 3 +++ .../examples/website/notes/web/src/main.js | 13 +++++++++++-- .../tests/test_website_notes_example.py | 1 + 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index 3311112b0..67bac1ae3 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -8,7 +8,7 @@ them. The page shows the universe's time. | --- | --- | | `site.py` | The server: the `Notes` WebsiteEnvironment, its `@api` routes, and `serve()` on `PORT` (8000). | | `data/seed.json` | The seed data: two users (`ann`, `ben`), their notes, and `current_user`, the acting user. | -| `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike. | +| `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike; opened at a path without its final slash, such as `/website/notes`, the page first reloads itself with it. | | `Dockerfile` | The server image: Node builds `web/`, then Python installs this repo's `packages/agentenv-protocol` with uvicorn. It installs `curl`, which the gateway's compose health check runs. | | `env.toml` | The website env as a bundle entry. | diff --git a/packages/agentenv-protocol/examples/website/notes/web/index.html b/packages/agentenv-protocol/examples/website/notes/web/index.html index 1e498d3c1..468ef6385 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/index.html +++ b/packages/agentenv-protocol/examples/website/notes/web/index.html @@ -4,6 +4,9 @@ Notes +
    diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index d061a6cea..fafffe38f 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -49,6 +49,7 @@ function queueSave(id, fields) { } landed.set(id, renders); } catch (error) { + state.error = error; lastSent.set(id, {}); landed.delete(id); throw error; @@ -179,15 +180,23 @@ async function run(action) { async function start() { const [session, users] = await Promise.all([api("api/session"), api("api/users")]); $("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id))); - $("user").value = session.user ?? ""; + let signedInAs = session.user ?? ""; + $("user").value = signedInAs; $("user").addEventListener("change", (event) => { const user = event.target.value; $("user").disabled = true; run(async () => { try { - while (saving.size) await Promise.all([...saving.values()].map((state) => state.done)); + while (saving.size) { + const pending = [...saving.values()]; + await Promise.all(pending.map((state) => state.done)); + const failed = pending.find((state) => state.error); + if (failed) throw failed.error; + } await api("api/sign-in", { method: "POST", body: { user } }); + signedInAs = user; } finally { + $("user").value = signedInAs; $("user").disabled = false; } }); diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index e02147b10..c7ae6212d 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -220,6 +220,7 @@ def test_the_built_page_loads_its_assets_and_api_under_the_gateways_prefix() -> browser = TestClient(_behind_gateway_prefix(notes_site.Notes().create_app()), base_url="http://gateway.test") page = browser.get(page_url) assert page.status_code == 200 and page.headers["content-type"].startswith("text/html") + assert page.text.index('location.pathname.endsWith("/")') < page.text.index(" diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index fafffe38f..61ead9d31 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -6,6 +6,7 @@ let renders = 0; const saving = new Map(); const lastSent = new Map(); const landed = new Map(); +const drafts = new Map(); const CLOCK_REFRESH_MS = 5000; export function universeNow() { @@ -31,6 +32,21 @@ async function api(path, { method = "GET", body } = {}) { const $ = (id) => document.getElementById(id); +function setDraft(id, kind, value, saved) { + const draft = { ...drafts.get(id) }; + if (value === saved) delete draft[kind]; + else draft[kind] = value; + if (Object.keys(draft).length) drafts.set(id, draft); + else drafts.delete(id); +} + +function settleDraft(id, sent) { + const draft = drafts.get(id); + if (!draft) return; + for (const kind of Object.keys(sent)) if (draft[kind] === sent[kind]) delete draft[kind]; + if (!Object.keys(draft).length) drafts.delete(id); +} + function queueSave(id, fields) { lastSent.set(id, fields); const queued = saving.get(id); @@ -46,6 +62,7 @@ function queueSave(id, fields) { const next = state.next; state.next = null; await api(`api/notes/${id}`, { method: "PATCH", body: next }); + settleDraft(id, next); } landed.set(id, renders); } catch (error) { @@ -84,15 +101,18 @@ function noteItem(note) { done.setAttribute("aria-label", `Mark "${note.title}" ${note.done ? "not done" : "done"}`); done.addEventListener("change", () => run(() => api(`api/notes/${note.id}/toggle`, { method: "POST" }))); + const draft = drafts.get(note.id) ?? {}; const title = document.createElement("input"); title.className = "title"; title.defaultValue = note.title; + title.value = draft.title ?? note.title; title.setAttribute("aria-label", "Title"); const body = document.createElement("textarea"); body.className = "body"; body.rows = 2; body.defaultValue = note.body; + body.value = draft.body ?? note.body; body.placeholder = "Details"; body.setAttribute("aria-label", "Details"); @@ -101,7 +121,8 @@ function noteItem(note) { if (title.value === sent.title && body.value === sent.body) return; queueSave(note.id, { title: title.value, body: body.value }); }; - for (const field of [title, body]) { + for (const [kind, field] of [["title", title], ["body", body]]) { + field.addEventListener("input", () => setDraft(note.id, kind, field.value, note[kind])); field.addEventListener("change", save); field.addEventListener("blur", save); } @@ -112,7 +133,10 @@ function noteItem(note) { const remove = document.createElement("button"); remove.className = "delete"; remove.textContent = "Delete"; - remove.addEventListener("click", () => run(() => api(`api/notes/${note.id}`, { method: "DELETE" }))); + remove.addEventListener("click", () => run(async () => { + await api(`api/notes/${note.id}`, { method: "DELETE" }); + drafts.delete(note.id); + })); const text = document.createElement("div"); text.className = "text"; @@ -121,28 +145,19 @@ function noteItem(note) { return item; } -function editing() { - const kept = []; - for (const field of document.querySelectorAll("#notes .title, #notes .body")) { - const focused = field === document.activeElement; - if (focused || field.value !== field.defaultValue) { - kept.push({ id: field.closest("li").dataset.id, kind: field.className, value: field.value, focused, - start: field.selectionStart, end: field.selectionEnd }); - } - } - return kept; +function focused() { + const field = document.activeElement; + if (!field?.matches?.("#notes .title, #notes .body")) return null; + return { id: field.closest("li").dataset.id, kind: field.className, start: field.selectionStart, + end: field.selectionEnd }; } -function restore(kept) { - for (const { id, kind, value, focused, start, end } of kept) { - const field = document.querySelector(`#notes li[data-id="${id}"] .${kind}`); - if (!field) continue; - field.value = value; - if (focused) { - field.focus(); - field.setSelectionRange(start, end); - } - } +function refocus(kept) { + if (!kept) return; + const field = document.querySelector(`#notes li[data-id="${kept.id}"] .${kept.kind}`); + if (!field) return; + field.focus(); + field.setSelectionRange(kept.start, kept.end); } async function render() { @@ -156,9 +171,9 @@ async function render() { landed.delete(id); } } - const kept = editing(); + const kept = focused(); $("notes").replaceChildren(...notes.map(noteItem)); - restore(kept); + refocus(kept); if (!notes.length) { const empty = document.createElement("li"); empty.className = "empty"; @@ -195,6 +210,7 @@ async function start() { } await api("api/sign-in", { method: "POST", body: { user } }); signedInAs = user; + drafts.clear(); } finally { $("user").value = signedInAs; $("user").disabled = false; From 701762477170a489a5187f0ee9e3d70d739ad360 Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 14:55:48 -0800 Subject: [PATCH 06/20] refactor(website): the Notes example keeps its own data and is ready through website/v1's ready alone (FD-4683) The SDK on main keeps no store and serves no health path, so Notes holds its users and notes itself, starting from data/seed.json, serves them through @reset_data, @add_data and @get_data, and names its acting user with acting_user(). Its readiness is website/v1's ready (@ready_check seeded); env.toml and the image's health check no longer name /healthz, which the compose health check now reads as the card. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/Dockerfile | 2 +- .../examples/website/notes/README.md | 22 ++--- .../examples/website/notes/env.toml | 1 - .../examples/website/notes/site.py | 86 +++++++++++++++---- .../examples/website/notes/web/src/main.js | 2 +- .../examples/website/notes/web/vite.config.js | 1 - .../tests/test_website_notes_example.py | 24 ++++-- 7 files changed, 99 insertions(+), 39 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/Dockerfile b/packages/agentenv-protocol/examples/website/notes/Dockerfile index 461e886b2..c042318da 100644 --- a/packages/agentenv-protocol/examples/website/notes/Dockerfile +++ b/packages/agentenv-protocol/examples/website/notes/Dockerfile @@ -22,5 +22,5 @@ EXPOSE 8000 RUN useradd --create-home --uid 10001 appuser USER appuser HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ - CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/healthz' % os.environ.get('PORT', '8000'), timeout=2)" + CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/.well-known/agent-env.json' % os.environ.get('PORT', '8000'), timeout=2)" CMD ["python", "site.py"] diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index 67d54de43..a89badd6e 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -9,13 +9,13 @@ them. The page shows the universe's time. | `site.py` | The server: the `Notes` WebsiteEnvironment, its `@api` routes, and `serve()` on `PORT` (8000). | | `data/seed.json` | The seed data: two users (`ann`, `ben`), their notes, and `current_user`, the acting user. | | `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike; 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. | +| `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 against the card. | | `env.toml` | The website env as a bundle entry. | ## 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(domains=["notes.test"], static="web/dist")`. The card advertises website/v1 with + the domains and every route by name. - **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 @@ -23,17 +23,17 @@ them. The page shows the universe's time. gets 403. The gateway passes on whatever role a browser sends, so without the key a page could claim `admin`; with no `WEBSITE_ROLE_KEY` set, every caller is `default` and the route is closed. - **Users.** `ApiContext.user` is the user in the signed session cookie. With no cookie, it is the acting user (the - store's `current_user`), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded + data's `current_user`, which `acting_user()` returns), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded user. A user's notes are invisible to every other user, and another user's note answers 404. -- **State.** The site writes no data hooks, so it uses the default store. `data/add` loads the seed (as a data part, or - as a file part by bytes or `file://`), `data/get` exports it, and `data/reset` empties it. `data/get` is how - agentenv reads what a task left the site with. +- **State.** The site keeps its users and notes itself, starting from `data/seed.json`, and serves them through its + data hooks: `data/add` adds a part's records by id (a data part, or a file part by bytes or `file://`), `data/get` + exports them, and `data/reset` empties them. `data/get` is how agentenv reads what a task left the site with. - **Time.** Notes are stamped with `self.now()`, which follows the gateway's clock once `clock/v1`'s `sync_time` names it. The frontend reads `X-Universe-Now` from every API response into `universeNow()` and shows it in the header. It uses this helper rather than `clock_patch=True`, so the page works the same against a server written without the SDK. -- **Readiness.** `@ready_check seeded` holds `/healthz` at 503 until the store has users, for example after a - `data/reset` and before the next `data/add`. +- **Readiness.** website/v1's `ready`, through the card, says not ready (`@ready_check seeded`) until the site has + users, for example after a `data/reset` and before the next `data/add`. The site serves no health path. ## Run it @@ -77,13 +77,13 @@ the container before using it. ```bash agent-env env website put --id notes-site \ --server-dockerfile packages/agentenv-protocol/examples/website/notes/Dockerfile \ - --server-docker-context . --health-path /healthz + --server-docker-context . ``` Run it from the repo root. `environment_name` comes from the card (`@environment_card(name="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 +`env.toml` describes the same env for a bundle. Its keys are `type`, `environment_name` and `backend_image`. Domains are not one of them: they come from the card. 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..fd7b5cc5c 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" 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..3cc3b610e 100644 --- a/packages/agentenv-protocol/examples/website/notes/site.py +++ b/packages/agentenv-protocol/examples/website/notes/site.py @@ -1,17 +1,23 @@ """Notes: the reference site of ``urn:agentenv:website/v1``, a small notes app on WebsiteEnvironment. Each user keeps notes they create, edit, mark done and delete; a browser with no session is signed in as the acting user, the seed's -``current_user``, and can sign in as another seeded user. The site keeps its state in the default store, which the data -plane loads, exports and resets, and stamps each note with the universe's time. ``admin`` callers, as the gateway's +``current_user``, and can sign in as another seeded user. The site keeps its users and notes itself, starting from +``data/seed.json``, and serves them through the data plane (``data/get``, ``data/reset``, ``data/add``), as any +AgentEnvEnvironment does; it stamps each note with the universe's time. ``admin`` callers, as the gateway's ``AgentEnv-Role`` names them, can list every user's notes. Run it with ``python site.py`` after building ``web/`` into ``web/dist``; it serves on ``PORT`` (8000).""" from __future__ import annotations +import base64 +import copy +import json +from pathlib import Path from typing import Optional +from urllib.parse import unquote, urlparse from pydantic import BaseModel -from agentenv_protocol import environment_card +from agentenv_protocol import DataPart, FilePart, TextPart, add_data, environment_card, get_data, reset_data from agentenv_protocol.website import ( ApiContext, BadRequest, @@ -26,6 +32,7 @@ NOTES = "notes" USERS = "users" +SEED = Path(__file__).resolve().parent / "data" / "seed.json" class NewNote(BaseModel): @@ -44,22 +51,49 @@ class SignIn(BaseModel): @environment_card(name="notes") -@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json") +@website(domains=["notes.test"], static="web/dist") class Notes(WebsiteEnvironment): + def __init__(self) -> None: + super().__init__() + self.data: dict = json.loads(SEED.read_text(encoding="utf-8")) + + def acting_user(self) -> Optional[str]: + return self.data.get("current_user") + @ready_check def seeded(self) -> bool: - """The site is usable once its store knows who may sign in.""" - return bool(self.store[USERS]) + """The site is usable once it knows who may sign in.""" + return bool(self.data.get(USERS)) + + @reset_data + def reset(self) -> None: + self.data = {} + + @add_data + def add(self, parts: list) -> None: + """Each part's JSON, its collections' records added by id and its other fields set, as the seed is.""" + for part in parts: + for key, value in _part_data(part).items(): + if isinstance(value, list): + kept = {record["id"]: record for record in self.data.get(key, [])} + kept.update({record["id"]: copy.deepcopy(record) for record in value}) + self.data[key] = list(kept.values()) + else: + self.data[key] = copy.deepcopy(value) + + @get_data + def export(self) -> list: + return [DataPart(data=copy.deepcopy(self.data))] @api.get("/api/users", name="list_users") def list_users(self) -> list: """The users a browser can sign in as.""" - return self.store[USERS] + return self.data.get(USERS, []) @api.post("/api/sign-in", name="sign_in") def sign_in(self, body: SignIn, ctx: ApiContext) -> dict: """Sign the browser in as one of the site's users.""" - if self.store.get(USERS, body.user) is None: + if not any(user["id"] == body.user for user in self.data.get(USERS, [])): raise NotFound(f"no user {body.user!r}") ctx.sign_in(body.user) return {"user": ctx.user} @@ -67,15 +101,18 @@ def sign_in(self, body: SignIn, ctx: ApiContext) -> dict: @api.get("/api/notes", name="list_notes") def list_notes(self, ctx: ApiContext, done: Optional[bool] = None) -> list: """The signed-in user's notes, newest first; with ``done``, only those done or not.""" - match = {"owner": self._user(ctx)} if done is None else {"owner": self._user(ctx), "done": done} - return sorted(self.store.find(NOTES, **match), key=lambda note: note["created_at"], reverse=True) + user = self._user(ctx) + mine = [n for n in self.data.get(NOTES, []) if n["owner"] == user and (done is None or n["done"] == done)] + return sorted(mine, key=lambda note: note["created_at"], reverse=True) @api.post("/api/notes", name="create_note", status=201) def create_note(self, note: NewNote, ctx: ApiContext) -> dict: """Add a note for the signed-in user, not done, created and updated now.""" - now = iso(self.now()) - return self.store.insert(NOTES, {"owner": self._user(ctx), "title": _title(note.title), "body": note.body, - "done": False, "created_at": now, "updated_at": now}) + now, notes = iso(self.now()), self.data.setdefault(NOTES, []) + created = {"id": max((n["id"] for n in notes), default=0) + 1, "owner": self._user(ctx), + "title": _title(note.title), "body": note.body, "done": False, "created_at": now, "updated_at": now} + notes.append(created) + return created @api.get("/api/notes/{note_id:int}", name="get_note") def get_note(self, note_id: int, ctx: ApiContext) -> dict: @@ -100,13 +137,14 @@ def toggle_note(self, note_id: int, ctx: ApiContext) -> dict: @api.delete("/api/notes/{note_id:int}", name="delete_note") def delete_note(self, note_id: int, ctx: ApiContext) -> dict: - self.store.delete(NOTES, self._note(note_id, ctx)["id"]) + note = self._note(note_id, ctx) + self.data[NOTES] = [n for n in self.data[NOTES] if n is not note] return {"deleted": note_id} @api.get("/api/admin/notes", name="all_notes", roles=["admin"]) def all_notes(self) -> list: """Every user's notes, for an ``admin`` caller only.""" - return self.store[NOTES] + return self.data.get(NOTES, []) def _user(self, ctx: ApiContext) -> str: if ctx.user is None: @@ -115,12 +153,28 @@ def _user(self, ctx: ApiContext) -> str: def _note(self, note_id: int, ctx: ApiContext) -> dict: """The signed-in user's note ``note_id``; another user's is as missing as one that doesn't exist.""" - note = self.store.get(NOTES, note_id) + note = next((n for n in self.data.get(NOTES, []) if n["id"] == note_id), None) if note is None or note["owner"] != self._user(ctx): raise NotFound(f"no note {note_id}") return note +def _part_data(part) -> dict: + """A part's JSON: a data part's data, a text part's text, or a file part's bytes or ``file://`` file.""" + if isinstance(part, DataPart): + return part.data + if isinstance(part, TextPart): + return json.loads(part.text) + if isinstance(part, FilePart): + if getattr(part.file, "bytes", None) is not None: + return json.loads(base64.b64decode(part.file.bytes)) + uri = urlparse(part.file.uri) + if uri.scheme == "file": + return json.loads(Path(unquote(uri.path)).read_text(encoding="utf-8")) + raise ValueError(f"Notes reads a file part by its bytes or a file:// URI, not {part.file.uri!r}") + raise ValueError(f"Notes can't read a {type(part).__name__}") + + def _title(title: str) -> str: if not title.strip(): raise BadRequest("a note needs a title", code="empty_title") diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index 61ead9d31..a30303385 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -245,7 +245,7 @@ async function start() { tick(); setInterval(tick, 1000); - setInterval(() => fetch("healthz").then(followUniverse).catch(() => {}), CLOCK_REFRESH_MS); + setInterval(() => fetch("api/users").then(followUniverse).catch(() => {}), CLOCK_REFRESH_MS); await render(); } diff --git a/packages/agentenv-protocol/examples/website/notes/web/vite.config.js b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js index d07ecd2c2..2fecfa631 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/vite.config.js +++ b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js @@ -5,7 +5,6 @@ export default defineConfig({ server: { proxy: { "/api": "http://127.0.0.1:8000", - "/healthz": "http://127.0.0.1:8000", }, }, }); diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index 4d7110657..f4045b1de 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -1,7 +1,7 @@ """The Notes example site (examples/website/notes) as website/v1's reference site: it passes the conformance checker, and its own behavior holds: the acting user's auto sign-in and signing in as another user, notes kept per user, the -admin-only route, timestamps from the universe's clock, and the store's seed loaded, exported and reset through the -data plane.""" +admin-only route, timestamps from the universe's clock, and its seed loaded, exported and reset through the data +plane, readiness following it.""" from __future__ import annotations @@ -48,7 +48,7 @@ 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")) + declared = notes_site.Notes.declaration()._replace(static=str(tmp_path)) return website(**declared._asdict())(type("Notes", (notes_site.Notes,), {})) @@ -101,21 +101,29 @@ def gateway_clock(): clock.close() +def ready(client: TestClient) -> dict: + """website/v1's ready, where the card says it is.""" + card = client.get("/.well-known/agent-env.json").json() + site = next(e for e in card["capabilities"]["extensions"] if e["uri"] == "urn:agentenv:website/v1") + return client.get(site["params"]["methods"]["ready"]["endpoint"]).json() + + def test_the_example_passes_every_conformance_check(client: TestClient) -> None: results = conformance.check("http://testserver", client=client) assert {r.name: r.status for r in results} == dict.fromkeys( ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"], "passed"), results -def test_the_card_declares_the_site_its_health_path_and_its_routes_by_name(client: TestClient) -> None: +def test_the_card_declares_the_site_and_its_routes_by_name_and_it_is_ready_through_website_v1( + client: TestClient) -> None: card = client.get("/.well-known/agent-env.json").json() site = next(e for e in card["capabilities"]["extensions"] if e["uri"] == "urn:agentenv:website/v1")["params"] assert card["name"] == "notes" - assert (site["domains"], site["health"]) == (["notes.test"], "/healthz") + assert site["domains"] == ["notes.test"] and "health" not in site assert [r["name"] for r in site["routes"]] == ["list_users", "sign_in", "list_notes", "create_note", "get_note", "edit_note", "toggle_note", "delete_note", "all_notes"] assert next(r for r in site["routes"] if r["name"] == "all_notes")["roles"] == ["admin"] - assert client.get("/healthz").json() == {"status": "ok"} + assert ready(client)["ready"] is True def test_a_browser_is_signed_in_as_the_acting_user_and_can_sign_in_as_another(client: TestClient) -> None: @@ -181,12 +189,12 @@ def test_the_seed_loads_exports_and_resets_through_the_data_plane(client: TestCl rpc(client, "data/reset") assert rpc(client, "data/get")["parts"][0]["data"] == {} assert client.get("/api/notes").status_code == 401 - assert client.get("/healthz").status_code == 503 + assert ready(client)["ready"] is False seed_file = {"kind": "file", "file": {"uri": (NOTES_DIR / "data" / "seed.json").as_uri(), "mimeType": "application/json"}} rpc(client, "data/add", {"parts": [seed_file]}) - assert client.get("/healthz").status_code == 200 + assert ready(client)["ready"] is True assert rpc(client, "data/get")["parts"][0]["data"] == SEED assert client.get("/api/session").json()["user"] == "ann" From 48ac23c275ff411a225e52273429fcca13c11acb Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 15:06:17 -0800 Subject: [PATCH 07/20] fix(website): the Notes example changes its data under one lock, so concurrent writes keep every note (FD-4683) Plain def handlers run in worker threads, so two creates could choose one id, and a create could append to a list a delete was replacing. Every read and write of the site's data is under one lock, ids chosen and notes inserted together, and the handlers answer copies. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/site.py | 89 ++++++++++++------- .../tests/test_website_notes_example.py | 33 +++++++ 2 files changed, 89 insertions(+), 33 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/site.py b/packages/agentenv-protocol/examples/website/notes/site.py index 3cc3b610e..6b98504f6 100644 --- a/packages/agentenv-protocol/examples/website/notes/site.py +++ b/packages/agentenv-protocol/examples/website/notes/site.py @@ -1,7 +1,7 @@ """Notes: the reference site of ``urn:agentenv:website/v1``, a small notes app on WebsiteEnvironment. Each user keeps notes they create, edit, mark done and delete; a browser with no session is signed in as the acting user, the seed's -``current_user``, and can sign in as another seeded user. The site keeps its users and notes itself, starting from -``data/seed.json``, and serves them through the data plane (``data/get``, ``data/reset``, ``data/add``), as any +``current_user``, and can sign in as another seeded user. The site keeps its users and notes itself, behind one lock +since plain ``def`` handlers run in worker threads, starting from ``data/seed.json``, and serves them through the data plane (``data/get``, ``data/reset``, ``data/add``), as any AgentEnvEnvironment does; it stamps each note with the universe's time. ``admin`` callers, as the gateway's ``AgentEnv-Role`` names them, can list every user's notes. @@ -11,6 +11,7 @@ import base64 import copy import json +import threading from pathlib import Path from typing import Optional from urllib.parse import unquote, urlparse @@ -55,45 +56,55 @@ class SignIn(BaseModel): class Notes(WebsiteEnvironment): def __init__(self) -> None: super().__init__() + self._lock = threading.Lock() self.data: dict = json.loads(SEED.read_text(encoding="utf-8")) def acting_user(self) -> Optional[str]: - return self.data.get("current_user") + with self._lock: + return self.data.get("current_user") @ready_check def seeded(self) -> bool: """The site is usable once it knows who may sign in.""" - return bool(self.data.get(USERS)) + with self._lock: + return bool(self.data.get(USERS)) @reset_data def reset(self) -> None: - self.data = {} + with self._lock: + self.data = {} @add_data def add(self, parts: list) -> None: """Each part's JSON, its collections' records added by id and its other fields set, as the seed is.""" - for part in parts: - for key, value in _part_data(part).items(): - if isinstance(value, list): - kept = {record["id"]: record for record in self.data.get(key, [])} - kept.update({record["id"]: copy.deepcopy(record) for record in value}) - self.data[key] = list(kept.values()) - else: - self.data[key] = copy.deepcopy(value) + loaded = [_part_data(part) for part in parts] + with self._lock: + for data in loaded: + for key, value in data.items(): + if isinstance(value, list): + kept = {record["id"]: record for record in self.data.get(key, [])} + kept.update({record["id"]: copy.deepcopy(record) for record in value}) + self.data[key] = list(kept.values()) + else: + self.data[key] = copy.deepcopy(value) @get_data def export(self) -> list: - return [DataPart(data=copy.deepcopy(self.data))] + with self._lock: + return [DataPart(data=copy.deepcopy(self.data))] @api.get("/api/users", name="list_users") def list_users(self) -> list: """The users a browser can sign in as.""" - return self.data.get(USERS, []) + with self._lock: + return copy.deepcopy(self.data.get(USERS, [])) @api.post("/api/sign-in", name="sign_in") def sign_in(self, body: SignIn, ctx: ApiContext) -> dict: """Sign the browser in as one of the site's users.""" - if not any(user["id"] == body.user for user in self.data.get(USERS, [])): + with self._lock: + known = any(user["id"] == body.user for user in self.data.get(USERS, [])) + if not known: raise NotFound(f"no user {body.user!r}") ctx.sign_in(body.user) return {"user": ctx.user} @@ -102,49 +113,60 @@ def sign_in(self, body: SignIn, ctx: ApiContext) -> dict: def list_notes(self, ctx: ApiContext, done: Optional[bool] = None) -> list: """The signed-in user's notes, newest first; with ``done``, only those done or not.""" user = self._user(ctx) - mine = [n for n in self.data.get(NOTES, []) if n["owner"] == user and (done is None or n["done"] == done)] + with self._lock: + mine = [copy.deepcopy(n) for n in self.data.get(NOTES, []) + if n["owner"] == user and (done is None or n["done"] == done)] return sorted(mine, key=lambda note: note["created_at"], reverse=True) @api.post("/api/notes", name="create_note", status=201) def create_note(self, note: NewNote, ctx: ApiContext) -> dict: """Add a note for the signed-in user, not done, created and updated now.""" - now, notes = iso(self.now()), self.data.setdefault(NOTES, []) - created = {"id": max((n["id"] for n in notes), default=0) + 1, "owner": self._user(ctx), - "title": _title(note.title), "body": note.body, "done": False, "created_at": now, "updated_at": now} - notes.append(created) - return created + owner, title, now = self._user(ctx), _title(note.title), iso(self.now()) + with self._lock: + notes = self.data.setdefault(NOTES, []) + created = {"id": max((n["id"] for n in notes), default=0) + 1, "owner": owner, "title": title, + "body": note.body, "done": False, "created_at": now, "updated_at": now} + notes.append(created) + return copy.deepcopy(created) @api.get("/api/notes/{note_id:int}", name="get_note") def get_note(self, note_id: int, ctx: ApiContext) -> dict: - return self._note(note_id, ctx) + with self._lock: + return copy.deepcopy(self._note(note_id, ctx)) @api.patch("/api/notes/{note_id:int}", name="edit_note") def edit_note(self, note_id: int, edit: NoteEdit, ctx: ApiContext) -> dict: """Change a note's title, body or done, and its ``updated_at``.""" - note = self._note(note_id, ctx) fields = edit.model_dump(exclude_none=True) if "title" in fields: fields["title"] = _title(fields["title"]) - note.update(fields, updated_at=iso(self.now())) - return note + now = iso(self.now()) + with self._lock: + note = self._note(note_id, ctx) + note.update(fields, updated_at=now) + return copy.deepcopy(note) @api.post("/api/notes/{note_id:int}/toggle", name="toggle_note") def toggle_note(self, note_id: int, ctx: ApiContext) -> dict: """Mark a note done, or not done when it was.""" - note = self._note(note_id, ctx) - note.update(done=not note["done"], updated_at=iso(self.now())) - return note + now = iso(self.now()) + with self._lock: + note = self._note(note_id, ctx) + note.update(done=not note["done"], updated_at=now) + return copy.deepcopy(note) @api.delete("/api/notes/{note_id:int}", name="delete_note") def delete_note(self, note_id: int, ctx: ApiContext) -> dict: - note = self._note(note_id, ctx) - self.data[NOTES] = [n for n in self.data[NOTES] if n is not note] + with self._lock: + note = self._note(note_id, ctx) + self.data[NOTES] = [n for n in self.data[NOTES] if n is not note] return {"deleted": note_id} @api.get("/api/admin/notes", name="all_notes", roles=["admin"]) def all_notes(self) -> list: """Every user's notes, for an ``admin`` caller only.""" - return self.data.get(NOTES, []) + with self._lock: + return copy.deepcopy(self.data.get(NOTES, [])) def _user(self, ctx: ApiContext) -> str: if ctx.user is None: @@ -152,7 +174,8 @@ def _user(self, ctx: ApiContext) -> str: return ctx.user def _note(self, note_id: int, ctx: ApiContext) -> dict: - """The signed-in user's note ``note_id``; another user's is as missing as one that doesn't exist.""" + """The signed-in user's note ``note_id``, read under the lock; another user's is as missing as one that + doesn't exist.""" note = next((n for n in self.data.get(NOTES, []) if n["id"] == note_id), None) if note is None or note["owner"] != self._user(ctx): raise NotFound(f"no note {note_id}") diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index f4045b1de..53127567d 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -10,6 +10,7 @@ import re import sys import threading +from concurrent.futures import ThreadPoolExecutor from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urljoin, urlsplit @@ -162,6 +163,38 @@ def test_notes_are_created_edited_toggled_and_deleted_by_their_owner_only(app, c assert client.get(f"/api/notes/{note['id']}").status_code == 404 +def test_concurrent_creates_each_get_a_note_of_their_own(env: WebsiteEnvironment) -> None: + barrier = threading.Barrier(8) + real_max = max + + def slow_max(*args, **kwargs): + """Hold every create's id choice open together, as worker threads racing would.""" + found = real_max(*args, **kwargs) + try: + barrier.wait(timeout=0.2) + except threading.BrokenBarrierError: + pass + return found + + notes_site.max = slow_max + try: + with ThreadPoolExecutor(8) as pool: + made = list(pool.map(lambda i: env.create_note(notes_site.NewNote(title=f"n{i}"), _Signed("ann")), + range(8))) + finally: + del notes_site.max + ids = [note["id"] for note in made] + assert len(set(ids)) == 8, ids + assert len([n for n in env.data["notes"] if n["owner"] == "ann" and n["title"].startswith("n")]) == 8 + + +class _Signed: + """An ``ApiContext`` stand-in signed in as ``user``.""" + + def __init__(self, user: str) -> None: + self.user = user + + def test_every_users_notes_are_listed_for_an_admin_caller_only(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(ROLE_KEY_ENV, "k3y") From 4bab4b8f8de61cb09bb595a625cb64fee8ae5b0c Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 13:42:16 -0800 Subject: [PATCH 08/20] feat(website): a website env may be an MCP server too, its tools declared on its card (FD-4683) - A WebsiteEnvironment with @tool or @resource methods serves them at /mcp (streamable HTTP, the mcp package) beside its pages, @api routes and data plane, in the site's process and under its clock; its card declares that endpoint as its mcp interface and lists the tools. A site without them serves no /mcp and declares none (serves_mcp()). - The gateway reads each site's card when it starts and serves the tools of one whose card declares an mcp interface as it does an MCP env's, with the caller's role in ToolContext. A site's interfaces are among the paths its card advertises, so its browsing route never reaches its /mcp. - Conformance checks a declared mcp endpoint: it answers initialize and lists the card's tools; a site without one skips the check. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/agentenv-protocol/README.md | 25 +++ .../agentenv_protocol/website/conformance.py | 57 +++++- .../agentenv_protocol/website/environment.py | 23 ++- .../tests/test_website_conformance.py | 6 +- .../tests/test_website_mcp.py | 170 ++++++++++++++++++ src/agent_env/env/gateway/entrypoint.py | 51 +++++- src/agent_env/env/gateway/gateway.py | 6 +- tst/unit/env/gateway/website_mcp_test.py | 123 +++++++++++++ 8 files changed, 449 insertions(+), 12 deletions(-) create mode 100644 packages/agentenv-protocol/tests/test_website_mcp.py create mode 100644 tst/unit/env/gateway/website_mcp_test.py diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index 560821c68..c166812c4 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -462,6 +462,31 @@ print(env.ready, env.problems) # False ['shop: seeded'] asked, or answers no `WebsiteReadiness`, is a problem itself. A card or child without `ready`, an MCP server's or an older site's, isn't counted, so an env with no site serving it is ready with no problems. +## Websites with tools + +A website env may also be an MCP server, so an agent can act on a site through tools as well as its pages. Give a +`WebsiteEnvironment` `@tool` (or `@resource`) methods, as an MCP env has; they need the `mcp` package. The site then +serves them at `/mcp` (streamable HTTP) beside its pages, `@api` routes and data plane, and its card declares that +endpoint as its `mcp` interface (`additionalInterfaces`) and lists the tools under `capabilities.tools`. A site without +tools serves no `/mcp` and declares no interface; `serves_mcp()` says which a site is. + +```python +@environment_card(name="shop") +@website(domains=["shop.example"], static="dist") +class Shop(WebsiteEnvironment): + @tool() + def place_order(self, item: str, ctx: ToolContext) -> str: + """Place an order for item, as the caller.""" + ... +``` + +A tool runs in the site's process, so it changes what its pages show and what `data/get` returns, and it reads the +universe's time (`self.now()`) as a page's request does. The gateway reads each site's card when it starts, and serves +the tools of a site whose card declares an `mcp` interface as it does an MCP env's: listed to agents with the rest, +called with the caller's role in `ToolContext`. The site's `/mcp` is one of the paths its card advertises, which its +browsing route (`/website//`) never reaches. `python -m agentenv_protocol.website.conformance` checks a declared +endpoint (`mcp`): it answers `initialize` and lists the tools the card does. + ## Computer drivers A computer driver is what runs inside a computer an agent uses through screenshots and input. Write diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py index 9f6c7debc..bbf5f91fc 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -7,7 +7,9 @@ returned, data/get again), ``clock/v1``'s ``sync_time`` against a clock the checker serves, and the forwarded headers wherever an answer shows them, on ``/`` and on a GET route with its trailing slash toggled, which a server may redirect, and the cookies those answers set: -none tied to a domain, and a warning for one whose lifetime is an absolute ``Expires`` with no ``Max-Age``. +none tied to a domain, and a warning for one whose lifetime is an absolute ``Expires`` with no ``Max-Age``. A site whose +card declares an ``mcp`` interface, being an MCP server too, must answer ``initialize`` there and ``tools/list`` with +the tools its card lists (streamable HTTP, ``MCP_PROTOCOL_VERSION``); a site without one skips that check. Every request goes to the server under check: a card's path or URL naming another scheme, host or port fails the check that would have used it, with nothing sent there. The round trip leaves the server's data as it found it, adding the @@ -33,13 +35,14 @@ from pydantic import ValidationError from ..client import find_extension, find_extension_method -from ..types import METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH +from ..types import MCP_TRANSPORT, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, iso, parse_time from .readiness import DEFAULT_HEALTH_PATH, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness Status = Literal["passed", "failed", "skipped", "warned"] CLOCK_START = datetime.datetime(2031, 5, 6, 7, 8, 9, tzinfo=datetime.timezone.utc) CLOCK_SLACK_S = 120 +MCP_PROTOCOL_VERSION = "2025-06-18" @dataclasses.dataclass(frozen=True) @@ -96,6 +99,7 @@ def run(self) -> list[Result]: health = site.get("health") if isinstance(site.get("health"), str) else DEFAULT_HEALTH_PATH steps = (("any_host", self._any_host), ("health", lambda: self._health(card, health)), ("universe_now", self._universe_now), ("data_plane", lambda: self._data_plane(card)), + ("mcp", lambda: self._mcp(card)), ("clock", lambda: self._clock(card)), ("forwarded", lambda: self._forwarded(site)), ("cookies", lambda: self._cookies(site))) outcomes = {name: _outcome(step) for name, step in steps if name != "universe_now"} @@ -198,6 +202,40 @@ def _data_plane(self, card: dict) -> tuple[Status, str]: return "failed", "data/get after data/reset and data/add of its own export returned other data" return "passed", f"{len(exported)} part(s) round-tripped through reset and add" + def _mcp(self, card: dict) -> tuple[Status, str]: + """The MCP endpoint the card declares answers ``initialize`` and lists the card's tools; its answers needn't + carry ``X-Universe-Now``, which the MCP transport doesn't promise.""" + interfaces = [i for i in card.get("additionalInterfaces") or [] if isinstance(i, dict) + and i.get("transport") == MCP_TRANSPORT] + if not interfaces: + return "skipped", "the card declares no mcp interface" + path = interfaces[0].get("url") + if not isinstance(path, str) or not path: + return "failed", f"the card's mcp interface has no url: {path!r}" + url = self._url(path) + headers = {"Accept": "application/json, text/event-stream"} + first = self.client.post(url, headers=headers, json={ + "jsonrpc": "2.0", "id": "initialize", "method": "initialize", + "params": {"protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {}, + "clientInfo": {"name": "agentenv-website-conformance", "version": "1"}}}) + server = _mcp_result(first, "initialize").get("serverInfo") or {} + if session := first.headers.get("mcp-session-id"): + headers["Mcp-Session-Id"] = session + try: + self.client.post(url, headers=headers, json={"jsonrpc": "2.0", "method": "notifications/initialized"}) + listed = _mcp_result(self.client.post(url, headers=headers, json={ + "jsonrpc": "2.0", "id": "tools/list", "method": "tools/list"}), "tools/list") + finally: + if session: + with contextlib.suppress(httpx.HTTPError): + self.client.delete(url, headers=headers) + served = sorted(t.get("name") for t in listed.get("tools") or [] if isinstance(t, dict)) + advertised = sorted(t.get("name") for t in (card.get("capabilities") or {}).get("tools") or [] + if isinstance(t, dict)) + if served != advertised: + return "failed", f"tools/list at {path} named {served}, the card {advertised}" + return "passed", f"{server.get('name') or 'the server'} at {path} lists the card's {len(served)} tool(s)" + def _clock(self, card: dict) -> tuple[Status, str]: if find_extension(card, CLOCK_EXTENSION_URI) is None: return "failed", f"the card declares no {CLOCK_EXTENSION_URI}" @@ -275,6 +313,21 @@ def _outcome(step: Callable[[], tuple[Status, str]]) -> tuple[Status, str]: return "failed", f"{type(e).__name__}: {e}" +def _mcp_result(response: httpx.Response, method: str) -> dict: + """The JSON-RPC result of an MCP request, its answer JSON or the last event of an event stream.""" + text = response.text + if response.headers.get("content-type", "").startswith("text/event-stream"): + events = [line[len("data:"):] for line in text.splitlines() if line.startswith("data:")] + text = events[-1] if events else "" + try: + body = json.loads(text) + except ValueError: + raise RuntimeError(f"{method} answered {response.status_code} with no JSON-RPC answer: {text[:200]!r}") from None + if not isinstance(body, dict) or "result" not in body: + raise RuntimeError(f"{method} answered {response.status_code}: {str(body)[:200]}") + return body["result"] or {} + + def _field_error(error: dict) -> str: """One of a ``ValidationError``'s errors as a person reads it: where, then what.""" where = ".".join(map(str, error.get("loc") or ())) or "the answer" diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py index 9c4db1ad3..76b955c25 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py @@ -8,6 +8,10 @@ (``@reset_data``, ``@add_data``, ``@get_data``) as any AgentEnvEnvironment does: ``data/get`` is how agentenv reads what a task left a site with. +A site may also be an MCP server, as an MCP env is: its ``@tool`` and ``@resource`` methods are served at ``/mcp`` +(streamable HTTP, of the ``mcp`` package), which its card declares as its ``mcp`` interface and lists the tools of, so +an agent can act on the site through tools beside its pages. A site with none serves no ``/mcp``. + One route is built in, replaced by a site's own ``@api`` route at its very path and answering before one whose path parameters would also match it: ``/api/session``, which a frontend calls to learn who is signed in, signing the acting user in when nobody is (GET), or to sign out (DELETE).""" @@ -32,9 +36,12 @@ from ..agent_env_environment import ( AgentEnvEnvironment, + _RESOURCE_ATTR, + _TOOL_ATTR, AgentEnvStarletteApplication, _marked_members, _maybe_await, + create_fastmcp_app, extension, ) from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, ClockUnavailable, UniverseClock, clock_patch_script, iso @@ -159,8 +166,15 @@ def create_app(self) -> Starlette: api_routes = self.routes() taken = {openapi_path(r.path) for r in api_routes} root = self._site_path(declaration.static) if declaration.static is not None else None - asgi = Starlette(middleware=[Middleware(_Forwarded), Middleware(_Universe, env=self)]) - AgentEnvStarletteApplication(self._build_card(), self).add_routes_to_app(asgi) + middleware = [Middleware(_Forwarded), Middleware(_Universe, env=self)] + if self.serves_mcp(): + self.mcp = create_fastmcp_app(self, card=self._build_card()) + served = self.mcp.streamable_http_app() + asgi = Starlette(middleware=middleware, lifespan=served.router.lifespan_context) + asgi.router.routes.extend(served.routes) + else: + asgi = Starlette(middleware=middleware) + AgentEnvStarletteApplication(self._build_card(), self).add_routes_to_app(asgi) if SESSION_PATH not in taken: asgi.router.routes.append(Route(SESSION_PATH, self._session, methods=["GET", "DELETE"])) asgi.router.routes.extend(Route(r.path, _unreserved(endpoint(getattr(self, r.fn.__name__), r, self.context)), @@ -170,6 +184,11 @@ def create_app(self) -> Starlette: self._mounted = True return asgi + def serves_mcp(self) -> bool: + """Whether the site is an MCP server too: it has ``@tool`` or ``@resource`` methods, or forwards tools.""" + return any(True for _ in _marked_members(self, _TOOL_ATTR)) or any( + True for _ in _marked_members(self, _RESOURCE_ATTR)) or bool(self.forwarded_tools()) + def mount(self, app: Any) -> Any: raise TypeError(f"{type(self).__name__} builds its own app with create_app(); set app = ... to serve more") diff --git a/packages/agentenv-protocol/tests/test_website_conformance.py b/packages/agentenv-protocol/tests/test_website_conformance.py index c475846f0..950e1f6bb 100644 --- a/packages/agentenv-protocol/tests/test_website_conformance.py +++ b/packages/agentenv-protocol/tests/test_website_conformance.py @@ -19,7 +19,7 @@ from agentenv_protocol.website import conformance from agentenv_protocol.website.clock import UNIVERSE_NOW_HEADER, iso -CHECKS = ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"] +CHECKS = ["card", "any_host", "health", "universe_now", "data_plane", "mcp", "clock", "forwarded", "cookies"] def statuses(results: list[conformance.Result]) -> dict[str, str]: @@ -67,7 +67,7 @@ def sdk_site(notes: WebsiteEnvironment) -> TestClient: def test_an_sdk_site_passes_every_check(sdk_site: TestClient) -> None: results = conformance.check("http://testserver", client=sdk_site) assert [r.name for r in results] == CHECKS - assert all(r.status == "passed" for r in results), results + assert all(r.status == ("skipped" if r.name == "mcp" else "passed") for r in results), results assert sdk_site.post("/agentenv", json={"jsonrpc": "2.0", "id": 1, "method": "data/get"}).json()["result"][ "parts"][0]["data"] == {"notes": [{"id": 1, "text": "hello"}]} @@ -168,7 +168,7 @@ async def sync_time(request: Request) -> Response: def test_a_handwritten_server_meeting_the_contract_passes() -> None: results = conformance.check("http://testserver", client=TestClient(_handwritten())) - assert statuses(results) == dict.fromkeys(CHECKS, "passed"), results + assert statuses(results) == {**dict.fromkeys(CHECKS, "passed"), "mcp": "skipped"}, results @pytest.mark.parametrize(("answer", "status", "detail"), [ diff --git a/packages/agentenv-protocol/tests/test_website_mcp.py b/packages/agentenv-protocol/tests/test_website_mcp.py new file mode 100644 index 000000000..c4326bc06 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_mcp.py @@ -0,0 +1,170 @@ +"""A website env that is an MCP server too: its @tool methods served at /mcp, declared on its card, beside its pages, +API and data plane; a site without tools serving no /mcp; and conformance checking the endpoint a card declares.""" +from __future__ import annotations + +import json +from pathlib import Path + +import httpx +import pytest +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route +from starlette.testclient import TestClient + +from agentenv_protocol import DataPart, ToolContext, add_data, environment_card, get_data, reset_data, tool +from agentenv_protocol.website import UNIVERSE_NOW_HEADER, WebsiteEnvironment, api, website +from agentenv_protocol.website import conformance + +MCP_HEADERS = {"accept": "application/json, text/event-stream", "content-type": "application/json"} + + +def _shop(*, tools: bool = True, static: Path | None = None) -> WebsiteEnvironment: + if static is not None: + (static / "index.html").write_text("shop") + declared = website(domains=["shop.test"], static=None if static is None else str(static)) + + class Shop(WebsiteEnvironment): + def __init__(self) -> None: + super().__init__() + self.orders: list[dict] = [] + + @api.get("/api/orders", name="list_orders") + def list_orders(self) -> list: + return self.orders + + @reset_data + def reset(self) -> None: + self.orders = [] + + @add_data + def add(self, parts: list) -> None: + for part in parts: + self.orders += part.data.get("orders", []) + + @get_data + def export(self) -> list: + return [DataPart(data={"orders": self.orders})] + + if not tools: + return environment_card(name="shop")(declared(Shop))() + + class ToolShop(Shop): + @tool() + def place_order(self, item: str, ctx: ToolContext) -> str: + """Place an order for item, as the caller.""" + self.orders.append({"item": item, "by": ctx.role, "at": self.now().isoformat()}) + return f"order {len(self.orders)} placed" + + return environment_card(name="shop")(declared(ToolShop))() + + +def _answer(response: httpx.Response) -> dict: + text = response.text + if response.headers.get("content-type", "").startswith("text/event-stream"): + text = [line[len("data:"):] for line in text.splitlines() if line.startswith("data:")][-1] + return json.loads(text) + + +def _mcp(client: TestClient) -> dict: + """Headers of an initialized MCP session with the site.""" + first = client.post("/mcp", headers=MCP_HEADERS, json={ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}}) + assert first.status_code == 200, first.text + headers = {**MCP_HEADERS, "mcp-session-id": first.headers["mcp-session-id"]} + assert client.post("/mcp", headers=headers, json={"jsonrpc": "2.0", "method": "notifications/initialized"}).is_success + return headers + + +def test_a_site_with_tools_declares_its_mcp_endpoint_and_tools_on_its_card() -> None: + with TestClient(_shop().create_app()) as client: + card = client.get("/.well-known/agent-env.json").json() + assert card["additionalInterfaces"] == [{"url": "/mcp", "transport": "mcp"}] + assert [t["name"] for t in card["capabilities"]["tools"]] == ["place_order"] + assert "ctx" not in card["capabilities"]["tools"][0]["inputSchema"]["properties"] + assert {e["uri"] for e in card["capabilities"]["extensions"]} >= {"urn:agentenv:website/v1", "urn:agentenv:clock/v1"} + + +def test_a_tool_call_acts_on_the_site_its_pages_and_data_plane_read() -> None: + site = _shop() + with TestClient(site.create_app()) as client: + headers = _mcp(client) + listed = _answer(client.post("/mcp", headers=headers, json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"})) + assert [t["name"] for t in listed["result"]["tools"]] == ["place_order"] + called = client.post("/mcp", headers={**headers, "AgentEnv-Role": "world"}, json={ + "jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": "place_order", "arguments": {"item": "milk"}}}) + assert _answer(called)["result"]["content"][0]["text"] == "order 1 placed" + assert UNIVERSE_NOW_HEADER in called.headers + assert [o["item"] for o in client.get("/api/orders").json()] == ["milk"] + data = client.post("/agentenv", json={"jsonrpc": "2.0", "id": 1, "method": "data/get"}).json() + assert data["result"]["parts"][0]["data"]["orders"][0]["item"] == "milk" + assert site.orders[0]["by"] == "world" + + +def test_a_site_without_tools_serves_no_mcp_endpoint() -> None: + site = _shop(tools=False) + assert not site.serves_mcp() + with TestClient(site.create_app()) as client: + card = client.get("/.well-known/agent-env.json").json() + answer = client.post("/mcp", headers=MCP_HEADERS, json={"jsonrpc": "2.0", "id": 1, "method": "initialize"}) + assert card["additionalInterfaces"] == [] + assert "tools" not in (card["capabilities"] or {}) or not card["capabilities"]["tools"] + assert answer.status_code == 404 + assert site.mcp is None + + +def test_a_site_with_tools_passes_conformance_with_its_mcp_endpoint_checked(tmp_path: Path) -> None: + with TestClient(_shop(static=tmp_path).create_app()) as client: + results = {r.name: r for r in conformance.check("http://testserver", client=client)} + assert results["mcp"].status == "passed", results["mcp"] + assert "1 tool(s)" in results["mcp"].detail + assert all(r.passed for r in results.values()), results + + +def test_a_site_without_tools_skips_the_mcp_check(tmp_path: Path) -> None: + with TestClient(_shop(tools=False, static=tmp_path).create_app()) as client: + results = {r.name: r for r in conformance.check("http://testserver", client=client)} + assert results["mcp"].status == "skipped" + + +def _declaring(mcp: Route | None) -> Starlette: + """A server whose card declares an MCP interface listing the tool ``a``, answering at /mcp as ``mcp`` does.""" + card = {"name": "x", "additionalInterfaces": [{"url": "/mcp", "transport": "mcp"}], + "capabilities": {"tools": [{"name": "a", "inputSchema": {"type": "object"}}]}} + routes = [Route("/.well-known/agent-env.json", lambda request: JSONResponse(card))] + return Starlette(routes=routes + ([mcp] if mcp is not None else [])) + + +def _listing(*names: str) -> Route: + async def answer(request: Request) -> Response: + body = await request.json() + if body.get("method") == "initialize": + return JSONResponse({"jsonrpc": "2.0", "id": body["id"], "result": {"serverInfo": {"name": "x"}}}) + if body.get("method") == "tools/list": + return JSONResponse({"jsonrpc": "2.0", "id": body["id"], "result": {"tools": [{"name": n} for n in names]}}) + return Response(status_code=202) + + return Route("/mcp", answer, methods=["POST"]) + + +@pytest.mark.parametrize(("mcp", "status", "detail"), [ + (_listing("a"), "passed", "lists the card's 1 tool(s)"), + (_listing("a", "b"), "failed", "named ['a', 'b'], the card ['a']"), + (None, "failed", "initialize answered 404"), +]) +def test_the_mcp_check_reads_the_endpoint_the_card_declares(mcp: Route | None, status: str, detail: str) -> None: + client = TestClient(_declaring(mcp)) + card = client.get("/.well-known/agent-env.json").json() + found = conformance._outcome(lambda: conformance._Checker("http://testserver", client, "127.0.0.1")._mcp(card)) + assert found[0] == status and detail in found[1], found + + +def test_an_mcp_interface_on_another_server_fails_the_check_with_nothing_sent_there() -> None: + sent = [] + client = httpx.Client(transport=httpx.MockTransport(lambda request: sent.append(request) or httpx.Response(500))) + card = {"additionalInterfaces": [{"url": "http://elsewhere.test/mcp", "transport": "mcp"}]} + found = conformance._outcome(lambda: conformance._Checker("http://site.test", client, "127.0.0.1")._mcp(card)) + assert found[0] == "failed" and sent == [] diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 65cf7e3c3..1ded44d2b 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -7,7 +7,7 @@ import httpx from . import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode, InternalMCPServer -from .constants import DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV +from .constants import DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, MCP_TRANSPORT, WELL_KNOWN_PATH from .gateway import Gateway logger = logging.getLogger(__name__) @@ -103,6 +103,48 @@ async def wait_for_internal_servers(servers: list[InternalMCPServer]) -> None: logger.info(f"All {len(servers)} internal MCP server(s) ready in {time.monotonic() - t0:.1f}s") +async def website_mcp_servers(sites: dict[str, str], taken: frozenset[str] = frozenset(), + timeout_s: int = _INTERNAL_SERVER_TIMEOUT_S) -> list[InternalMCPServer]: + """The sites, by name at the address their server answers, that are MCP servers too: each as an internal server at + the MCP endpoint its card declares, so the gateway serves its tools as it does an MCP env's. A site whose card + declares no MCP interface, or that answers no card, is left out, as is one named as an internal server already + (``taken``). A site whose server can't be reached yet is asked again until ``timeout_s``.""" + found: list[InternalMCPServer] = [] + deadline = time.monotonic() + timeout_s + async with httpx.AsyncClient() as client: + for name, base_url in sites.items(): + if name in taken: + continue + base = base_url.rstrip("/") + card = await _site_card(client, name, base, deadline) + path = next((i.get("url") for i in (card or {}).get("additionalInterfaces") or [] + if isinstance(i, dict) and i.get("transport") == MCP_TRANSPORT and i.get("url")), None) + if isinstance(path, str) and path.startswith("/"): + logger.info(f"Site {name} is an MCP server at {path}") + found.append(InternalMCPServer(name=name, mcp_url=f"{base}{path}")) + return found + + +async def _site_card(client: httpx.AsyncClient, name: str, base: str, deadline: float) -> dict | None: + """The card the site at ``base`` serves, None when it serves none; asked again while its server can't be reached + or answers 5xx, until ``deadline``.""" + while True: + try: + answer = await client.get(base + WELL_KNOWN_PATH, timeout=5) + except httpx.HTTPError: + answer = None + if answer is not None and answer.status_code < 500: + try: + card = answer.json() if answer.status_code == 200 else None + except ValueError: + return None + return card if isinstance(card, dict) else None + if time.monotonic() >= deadline: + logger.warning(f"site {name}'s card at {base} didn't answer in time; its MCP tools, if any, aren't served") + return None + await asyncio.sleep(_INTERNAL_SERVER_POLL_S) + + def parse_name_url_map(env_str: str) -> dict[str, str]: # Expected format: 'slack=http://slack-website-backend:8000,email=http://email-website-backend:8000' urls: dict[str, str] = {} @@ -137,10 +179,13 @@ def parse_name_url_map(env_str: str) -> dict[str, str]: spec=os.environ.get("INTERNAL_MCP_SERVERS", ""), port=int(os.environ.get("INTERNAL_MCP_PORT", str(AGENT_ENV_GATEWAY_MCP_PORT))), ) - asyncio.run(wait_for_internal_servers(internal_mcp_servers)) - website_urls = parse_name_url_map(os.environ.get("WEBSITE_URLS", "")) rest_proxy_urls = parse_name_url_map(os.environ.get("REST_PROXY_URLS", "")) + internal_mcp_servers += asyncio.run(website_mcp_servers( + {name: rest_proxy_urls.get(name, url) for name, url in website_urls.items()}, + taken=frozenset(server.name for server in internal_mcp_servers))) + asyncio.run(wait_for_internal_servers(internal_mcp_servers)) + gateway_mode = GatewayMode(os.environ.get("GATEWAY_MODE", GatewayMode.PERFORMANCE.value)) service_db_url = os.environ.get("SERVICE_DB_URL") gateway = Gateway( diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 79f6d1911..cec722b0a 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -1669,9 +1669,11 @@ def reserved_site_path(path: str, reserved: frozenset[str] = frozenset()) -> boo def site_reserved_paths(card: dict) -> frozenset[str]: - """The paths a site's card advertises for agentenv, which its route never reaches: its ``url``, and every - extension's endpoint and each of its methods'; each one a path of its own, not the site's root.""" + """The paths a site's card advertises for agentenv, which its route never reaches: its ``url``, every interface's, + such as the MCP endpoint of a site that is an MCP server too, and every extension's endpoint and each of its + methods'; each one a path of its own, not the site's root.""" paths = [card.get("url")] + paths += [i.get("url") for i in card.get("additionalInterfaces") or [] if isinstance(i, dict)] for ext in (card.get("capabilities") or {}).get("extensions") or []: params = ext.get("params") if isinstance(ext, dict) else None if not isinstance(params, dict): diff --git a/tst/unit/env/gateway/website_mcp_test.py b/tst/unit/env/gateway/website_mcp_test.py new file mode 100644 index 000000000..f99928124 --- /dev/null +++ b/tst/unit/env/gateway/website_mcp_test.py @@ -0,0 +1,123 @@ +"""A website env that is an MCP server too: the gateway finds its MCP endpoint on its card and serves its tools as it +does an MCP env's, while the site's route keeps that endpoint, like the rest of what its card advertises, out of an +agent's browsing reach.""" +from __future__ import annotations + +import httpx +import pytest +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client +from pytest_socket import enable_socket + +from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER, InternalMCPServer +from agent_env.env.gateway import entrypoint as entrypoint_module +from agent_env.env.gateway.entrypoint import website_mcp_servers +from agent_env.env.gateway.gateway import Gateway, site_reserved_paths +from agentenv_protocol import DataPart, ToolContext, environment_card, get_data, tool +from agentenv_protocol.website import WebsiteEnvironment, website +from tst.unit.env.gateway.process_scope_socket_test import _serve + +CARD = "/.well-known/agent-env.json" + + +def test_a_sites_mcp_interface_is_one_of_its_reserved_paths(): + card = {"url": "/agentenv", "additionalInterfaces": [{"url": "/mcp", "transport": "mcp"}, {"transport": "x"}]} + assert site_reserved_paths(card) == frozenset({"/agentenv", "/mcp"}) + + +@pytest.mark.asyncio +async def test_the_sites_whose_cards_declare_an_mcp_interface_are_internal_servers(monkeypatch): + calls: dict[str, int] = {} + cards = { + "shop": {"additionalInterfaces": [{"url": "/tools/mcp", "transport": "mcp"}]}, + "mail": {"additionalInterfaces": []}, + "other": {"additionalInterfaces": [{"url": "/rpc", "transport": "grpc"}]}, + } + + def site(request: httpx.Request) -> httpx.Response: + host = request.url.host + calls[host] = calls.get(host, 0) + 1 + assert request.url.path == CARD + if host == "late" and calls[host] == 1: + raise httpx.ConnectError("not up yet") + if host == "late": + return httpx.Response(200, json={"additionalInterfaces": [{"url": "/mcp", "transport": "mcp"}]}) + if host == "nocard": + return httpx.Response(404) + if host == "html": + return httpx.Response(200, text="") + return httpx.Response(200, json=cards[host]) + + real = httpx.AsyncClient + monkeypatch.setattr(entrypoint_module.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(site), **kw)) + monkeypatch.setattr(entrypoint_module, "_INTERNAL_SERVER_POLL_S", 0) + sites = {name: f"http://{name}:8000/" for name in ("shop", "mail", "other", "late", "nocard", "html", "slack")} + + found = await website_mcp_servers(sites, taken=frozenset({"slack"})) + + assert found == [InternalMCPServer("shop", "http://shop:8000/tools/mcp"), InternalMCPServer("late", "http://late:8000/mcp")] + assert "slack" not in calls and calls["late"] == 2 + + +@pytest.mark.asyncio +async def test_a_site_whose_server_never_answers_is_left_out_once_the_time_is_up(monkeypatch): + def down(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("down") + + real = httpx.AsyncClient + monkeypatch.setattr(entrypoint_module.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(down), **kw)) + monkeypatch.setattr(entrypoint_module, "_INTERNAL_SERVER_POLL_S", 0) + assert await website_mcp_servers({"shop": "http://shop:8000"}, timeout_s=0) == [] + + +@environment_card(name="shop") +@website(domains=["shop.test"]) +class _Shop(WebsiteEnvironment): + def __init__(self) -> None: + super().__init__() + self.orders: list[dict] = [] + + @tool() + def place_order(self, item: str, ctx: ToolContext) -> str: + """Place an order for item, as the caller.""" + self.orders.append({"item": item, "by": ctx.role}) + return f"order {len(self.orders)} placed" + + @get_data + def export(self) -> list: + return [DataPart(data={"orders": self.orders})] + + +@pytest.mark.asyncio +async def test_the_gateway_serves_a_sites_tools_and_its_route_refuses_the_sites_mcp_endpoint(): + enable_socket() + site = _Shop() + site_server, site_url = await _serve(site.create_app()) + try: + servers = await website_mcp_servers({"shop": site_url}) + assert servers == [InternalMCPServer("shop", f"{site_url}/mcp")] + gw = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=servers, + website_urls={"shop": site_url}, rest_proxy_urls={"shop": site_url}) + gw_server, gw_url = await _serve(gw._asgi_app()) + try: + async with ( + httpx.AsyncClient(headers={AGENT_ENV_ROLE_HEADER: "alice@example.com"}, timeout=30) as http, + streamable_http_client(f"{gw_url}/mcp", http_client=http) as (read, write, _), + ClientSession(read, write) as session, + ): + await session.initialize() + assert "place_order" in [t.name for t in (await session.list_tools()).tools] + result = await session.call_tool("place_order", {"item": "milk"}) + assert not result.isError and result.content[0].text == "order 1 placed" + async with httpx.AsyncClient(timeout=30) as http: + browsing = await http.post(f"{gw_url}/website/shop/mcp", json={"jsonrpc": "2.0", "id": 1, + "method": "tools/list"}) + data = await http.post(f"{gw_url}/svc/shop/agentenv", json={"jsonrpc": "2.0", "id": 1, + "method": "data/get"}) + finally: + await gw_server.shutdown() + finally: + await site_server.shutdown() + assert site.orders == [{"item": "milk", "by": "alice@example.com"}] + assert browsing.status_code == 404 + assert data.json()["result"]["parts"][0]["data"]["orders"][0]["item"] == "milk" From dac69e28d75117c872937b31a442ff898a13e7f3 Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 14:34:30 -0800 Subject: [PATCH 09/20] fix(website): a site's MCP calls through /svc carry the gateway's role, sites are found at once, and conformance reads every page - A call to a site's MCP endpoint through /svc// gets the gateway's role in its JSON-RPC _meta, as an MCP server's does, at the endpoint its card declares (site_mcp_endpoints), so a caller can't claim another role there. - The gateway asks every site for its card at once, so a site that never comes up leaves another its own retries. - Conformance reads every page of tools/list, fails a listed tool the card doesn't name, and warns on a card's tool it doesn't list, as a hidden-by-default one is listed to no caller. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agentenv_protocol/website/conformance.py | 40 +++++++++++++------ .../tests/test_website_mcp.py | 32 +++++++++++++-- src/agent_env/env/gateway/entrypoint.py | 23 +++++------ src/agent_env/env/gateway/gateway.py | 21 ++++++++-- src/agent_env/env/gateway/rest_proxy.py | 20 ++++++---- tst/unit/env/gateway/website_mcp_test.py | 40 +++++++++++++++++-- 6 files changed, 134 insertions(+), 42 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py index bbf5f91fc..2d777d84e 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -8,8 +8,10 @@ ``sync_time`` against a clock the checker serves, and the forwarded headers wherever an answer shows them, on ``/`` and on a GET route with its trailing slash toggled, which a server may redirect, and the cookies those answers set: none tied to a domain, and a warning for one whose lifetime is an absolute ``Expires`` with no ``Max-Age``. A site whose -card declares an ``mcp`` interface, being an MCP server too, must answer ``initialize`` there and ``tools/list`` with -the tools its card lists (streamable HTTP, ``MCP_PROTOCOL_VERSION``); a site without one skips that check. +card declares an ``mcp`` interface, being an MCP server too, must answer ``initialize`` there and list, every page of +``tools/list`` read, no tool its card doesn't (streamable HTTP, ``MCP_PROTOCOL_VERSION``); a card's tool it doesn't +list is a warning, since a hidden-by-default tool is listed to no caller until one enables it. A site without one +skips that check. Every request goes to the server under check: a card's path or URL naming another scheme, host or port fails the check that would have used it, with nothing sent there. The round trip leaves the server's data as it found it, adding the @@ -43,6 +45,7 @@ CLOCK_START = datetime.datetime(2031, 5, 6, 7, 8, 9, tzinfo=datetime.timezone.utc) CLOCK_SLACK_S = 120 MCP_PROTOCOL_VERSION = "2025-06-18" +MCP_LIST_PAGES = 100 @dataclasses.dataclass(frozen=True) @@ -203,8 +206,9 @@ def _data_plane(self, card: dict) -> tuple[Status, str]: return "passed", f"{len(exported)} part(s) round-tripped through reset and add" def _mcp(self, card: dict) -> tuple[Status, str]: - """The MCP endpoint the card declares answers ``initialize`` and lists the card's tools; its answers needn't - carry ``X-Universe-Now``, which the MCP transport doesn't promise.""" + """The MCP endpoint the card declares answers ``initialize`` and lists no tool the card doesn't, reading up to + ``MCP_LIST_PAGES`` pages of ``tools/list``; its answers needn't carry ``X-Universe-Now``, which the MCP + transport doesn't promise.""" interfaces = [i for i in card.get("additionalInterfaces") or [] if isinstance(i, dict) and i.get("transport") == MCP_TRANSPORT] if not interfaces: @@ -223,18 +227,30 @@ def _mcp(self, card: dict) -> tuple[Status, str]: headers["Mcp-Session-Id"] = session try: self.client.post(url, headers=headers, json={"jsonrpc": "2.0", "method": "notifications/initialized"}) - listed = _mcp_result(self.client.post(url, headers=headers, json={ - "jsonrpc": "2.0", "id": "tools/list", "method": "tools/list"}), "tools/list") + served: list = [] + cursor = None + for _page in range(MCP_LIST_PAGES): + listed = _mcp_result(self.client.post(url, headers=headers, json={ + "jsonrpc": "2.0", "id": "tools/list", "method": "tools/list", + **({"params": {"cursor": cursor}} if cursor else {})}), "tools/list") + served += [t.get("name") for t in listed.get("tools") or [] if isinstance(t, dict)] + cursor = listed.get("nextCursor") + if not cursor: + break + else: + return "failed", f"tools/list at {path} still had a next page after {MCP_LIST_PAGES}" finally: if session: with contextlib.suppress(httpx.HTTPError): self.client.delete(url, headers=headers) - served = sorted(t.get("name") for t in listed.get("tools") or [] if isinstance(t, dict)) - advertised = sorted(t.get("name") for t in (card.get("capabilities") or {}).get("tools") or [] - if isinstance(t, dict)) - if served != advertised: - return "failed", f"tools/list at {path} named {served}, the card {advertised}" - return "passed", f"{server.get('name') or 'the server'} at {path} lists the card's {len(served)} tool(s)" + advertised = {t.get("name") for t in (card.get("capabilities") or {}).get("tools") or [] if isinstance(t, dict)} + if unadvertised := sorted(set(served) - advertised): + return "failed", f"tools/list at {path} named {unadvertised}, which the card doesn't list" + name = server.get("name") or "the server" + if unlisted := sorted(advertised - set(served)): + return "warned", (f"{name} at {path} lists {len(served)} of the card's tools, not {unlisted}: hidden by " + "default, or missing") + return "passed", f"{name} at {path} lists the card's {len(served)} tool(s)" def _clock(self, card: dict) -> tuple[Status, str]: if find_extension(card, CLOCK_EXTENSION_URI) is None: diff --git a/packages/agentenv-protocol/tests/test_website_mcp.py b/packages/agentenv-protocol/tests/test_website_mcp.py index c4326bc06..5a82cdc3a 100644 --- a/packages/agentenv-protocol/tests/test_website_mcp.py +++ b/packages/agentenv-protocol/tests/test_website_mcp.py @@ -138,21 +138,27 @@ def _declaring(mcp: Route | None) -> Starlette: return Starlette(routes=routes + ([mcp] if mcp is not None else [])) -def _listing(*names: str) -> Route: +def _listing(*pages: tuple[str, ...]) -> Route: + """An MCP endpoint whose tools/list answers ``pages`` in turn, each pointing at the next by its cursor.""" async def answer(request: Request) -> Response: body = await request.json() if body.get("method") == "initialize": return JSONResponse({"jsonrpc": "2.0", "id": body["id"], "result": {"serverInfo": {"name": "x"}}}) if body.get("method") == "tools/list": - return JSONResponse({"jsonrpc": "2.0", "id": body["id"], "result": {"tools": [{"name": n} for n in names]}}) + at = int((body.get("params") or {}).get("cursor") or 0) + result = {"tools": [{"name": n} for n in pages[at]], **({"nextCursor": str(at + 1)} if at + 1 < len(pages) + else {})} + return JSONResponse({"jsonrpc": "2.0", "id": body["id"], "result": result}) return Response(status_code=202) return Route("/mcp", answer, methods=["POST"]) @pytest.mark.parametrize(("mcp", "status", "detail"), [ - (_listing("a"), "passed", "lists the card's 1 tool(s)"), - (_listing("a", "b"), "failed", "named ['a', 'b'], the card ['a']"), + (_listing(("a",)), "passed", "lists the card's 1 tool(s)"), + (_listing((), ("a",)), "passed", "lists the card's 1 tool(s)"), + (_listing(("a", "b")), "failed", "named ['b'], which the card doesn't list"), + (_listing(()), "warned", "lists 0 of the card's tools, not ['a']: hidden by default, or missing"), (None, "failed", "initialize answered 404"), ]) def test_the_mcp_check_reads_the_endpoint_the_card_declares(mcp: Route | None, status: str, detail: str) -> None: @@ -162,6 +168,24 @@ def test_the_mcp_check_reads_the_endpoint_the_card_declares(mcp: Route | None, s assert found[0] == status and detail in found[1], found +def test_a_hidden_by_default_tool_on_the_card_warns_without_failing_the_run(tmp_path: Path) -> None: + site = _shop(static=tmp_path) + + class Gated(type(site)): + tool_access = True + + @tool(hidden_by_default=True) + def refund(self, item: str) -> str: + """Refund an order.""" + return "refunded" + + gated = environment_card(name="shop")(Gated)() + with TestClient(gated.create_app()) as client: + results = {r.name: r for r in conformance.check("http://testserver", client=client)} + assert results["mcp"].status == "warned" and "['refund']" in results["mcp"].detail + assert all(r.passed for r in results.values()), results + + def test_an_mcp_interface_on_another_server_fails_the_check_with_nothing_sent_there() -> None: sent = [] client = httpx.Client(transport=httpx.MockTransport(lambda request: sent.append(request) or httpx.Response(500))) diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 1ded44d2b..7561d9082 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -108,20 +108,19 @@ async def website_mcp_servers(sites: dict[str, str], taken: frozenset[str] = fro """The sites, by name at the address their server answers, that are MCP servers too: each as an internal server at the MCP endpoint its card declares, so the gateway serves its tools as it does an MCP env's. A site whose card declares no MCP interface, or that answers no card, is left out, as is one named as an internal server already - (``taken``). A site whose server can't be reached yet is asked again until ``timeout_s``.""" - found: list[InternalMCPServer] = [] + (``taken``). Every site is asked at once, and one whose server can't be reached yet is asked again until + ``timeout_s``.""" + asked = {name: url.rstrip("/") for name, url in sites.items() if name not in taken} deadline = time.monotonic() + timeout_s async with httpx.AsyncClient() as client: - for name, base_url in sites.items(): - if name in taken: - continue - base = base_url.rstrip("/") - card = await _site_card(client, name, base, deadline) - path = next((i.get("url") for i in (card or {}).get("additionalInterfaces") or [] - if isinstance(i, dict) and i.get("transport") == MCP_TRANSPORT and i.get("url")), None) - if isinstance(path, str) and path.startswith("/"): - logger.info(f"Site {name} is an MCP server at {path}") - found.append(InternalMCPServer(name=name, mcp_url=f"{base}{path}")) + cards = await asyncio.gather(*(_site_card(client, name, base, deadline) for name, base in asked.items())) + found: list[InternalMCPServer] = [] + for (name, base), card in zip(asked.items(), cards): + path = next((i.get("url") for i in (card or {}).get("additionalInterfaces") or [] + if isinstance(i, dict) and i.get("transport") == MCP_TRANSPORT and i.get("url")), None) + if isinstance(path, str) and path.startswith("/"): + logger.info(f"Site {name} is an MCP server at {path}") + found.append(InternalMCPServer(name=name, mcp_url=f"{base}{path}")) return found diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index cec722b0a..d92984657 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -153,6 +153,7 @@ def __init__( self.internal_mcp_servers = internal_mcp_servers self.website_urls = website_urls or {} self.rest_proxy_urls = rest_proxy_urls or {} + self._site_mcp_endpoints = site_mcp_endpoints(internal_mcp_servers, self.website_urls, self.rest_proxy_urls) self._site_reserved: dict[str, frozenset[str]] = {} self._withheld = frozenset(name.strip().lower().encode() for name in provider_headers if name.strip()) self._browsers = BrowserRoutes(self.rest_proxy_urls, self.REST_PROXY_TIMEOUT, self._log_event, @@ -651,12 +652,14 @@ async def _proxy_rest_request(self, request: Request, url_map: dict[str, str] | if base_url is None: return self._json_response({"error": f"Unknown service: {service_name}"}, 404) role = self._request_role(request) - mcp_server = url_map is self.rest_proxy_urls and service_name.startswith("mcp-") + site_mcp = self._site_mcp_endpoints.get(service_name) if url_map is self.rest_proxy_urls else None + mcp_server = url_map is self.rest_proxy_urls and (service_name.startswith("mcp-") or site_mcp is not None) + mcp_endpoint = site_mcp or "mcp" if url_map is self.rest_proxy_urls and (cdp := await self._browsers.cdp_answer(request, base_url, role)) is not None: return cdp if request.method in _SAFE_METHODS: return await forward(request, base_url, role=role, timeout=self.REST_PROXY_TIMEOUT, mcp_server=mcp_server, - withheld=self._withheld) + withheld=self._withheld, mcp_endpoint=mcp_endpoint) changelog_before = self._query_changelog_id() attribution = self._attribution(role) # at the start: a fire may be gone by the time the response ends @@ -681,7 +684,7 @@ async def log_write(status: int) -> None: }) return await forward(request, base_url, role=role, timeout=self.REST_PROXY_TIMEOUT, mcp_server=mcp_server, - on_done=log_write, withheld=self._withheld) + on_done=log_write, withheld=self._withheld, mcp_endpoint=mcp_endpoint) async def _proxy_website_request(self, request: Request) -> Response: """Proxy a website request; with a website or state trigger registered, feed it to the engine and hold barriers. @@ -1668,6 +1671,18 @@ def reserved_site_path(path: str, reserved: frozenset[str] = frozenset()) -> boo for base in (RPC_PATH, *reserved)) +def site_mcp_endpoints(servers: list[InternalMCPServer], website_urls: dict[str, str], + rest_proxy_urls: dict[str, str]) -> dict[str, str]: + """The MCP endpoint, as a path under its /svc// base, of each site that is an internal MCP server too, by + name: where a call through /svc// gets the gateway's role in its JSON-RPC ``_meta``, as an MCP server's does.""" + endpoints = {} + for server in servers: + base = rest_proxy_urls.get(server.name, "").rstrip("/") + if server.name in website_urls and base and server.mcp_url.startswith(base + "/"): + endpoints[server.name] = server.mcp_url[len(base):] + return endpoints + + def site_reserved_paths(card: dict) -> frozenset[str]: """The paths a site's card advertises for agentenv, which its route never reaches: its ``url``, every interface's, such as the MCP endpoint of a site that is an MCP server too, and every extension's endpoint and each of its diff --git a/src/agent_env/env/gateway/rest_proxy.py b/src/agent_env/env/gateway/rest_proxy.py index 6dbe137f9..4278512b4 100644 --- a/src/agent_env/env/gateway/rest_proxy.py +++ b/src/agent_env/env/gateway/rest_proxy.py @@ -86,11 +86,11 @@ async def forward(request: Request, base_url: str, *, role: str, timeout: httpx.Timeout, mcp_server: bool, on_done: Callable[[int], Awaitable[None]] | None = None, - withheld: frozenset[bytes] = frozenset()) -> Response: + withheld: frozenset[bytes] = frozenset(), mcp_endpoint: str = "mcp") -> Response: """Forward `request` to the service at `base_url` as `role`, without the headers named in `withheld`, and stream the response through. `mcp_server` says - the service is a backing MCP server, whose own endpoint takes the role in its JSON-RPC `_meta` as well as in the - header. A 400 names a request-target the proxy cannot route; a 502 names an upstream that never answered. + the service is a backing MCP server, whose own endpoint, `mcp_endpoint` under it, takes the role in its JSON-RPC + `_meta` as well as in the header. A 400 names a request-target the proxy cannot route; a 502 names an upstream that never answered. `on_done` is awaited with the status once the response has ended, after its last byte when streamed.""" try: target_url = upstream_url(base_url, request) @@ -98,7 +98,7 @@ async def forward(request: Request, base_url: str, *, role: str, timeout: httpx. return await _done(_json_response({"error": str(e)}, 400), on_done) content = await request.body() # /svc and /website request bodies are small (JSON-RPC, forms) headers = upstream_headers(request, role, withheld=withheld) - if mcp_server and request.method == "POST" and is_child_mcp_path(request.path_params.get("path", "")): + if mcp_server and request.method == "POST" and is_child_mcp_path(request.path_params.get("path", ""), mcp_endpoint): # The gateway is the trust boundary for identity: a child's proxied MCP endpoint stays reachable, but the # role in its JSON-RPC `_meta` is the gateway's to write, like the header above. content = stamp_jsonrpc_role(content, role) @@ -187,15 +187,19 @@ def route_prefix(request: HTTPConnection) -> str: return raw_path.decode("latin-1").rstrip("/") -def is_child_mcp_path(decoded_rest: str) -> bool: - """Whether a /svc/{service}/{path} remainder reaches the backing server's MCP endpoint (`mcp` or anything under - it), judged after dot-segment normalisation, since httpx collapses `x/../mcp` to `/mcp` on the way upstream. +def is_child_mcp_path(decoded_rest: str, endpoint: str = "mcp") -> bool: + """Whether a /svc/{service}/{path} remainder reaches the backing server's MCP endpoint (`endpoint`, `mcp` unless + its card declares another, or anything under it), judged after dot-segment normalisation, since httpx collapses + `x/../mcp` to `/mcp` on the way upstream. >>> [is_child_mcp_path(p) for p in ("mcp", "mcp/", "mcp/x", "x/../mcp", "./mcp", "/mcp", "mcpx", "agentenv", "")] [True, True, True, True, True, True, False, False, False] + >>> [is_child_mcp_path(p, "/tools/mcp/") for p in ("tools/mcp", "tools/x/../mcp", "mcp")] + [True, True, False] """ normalized = posixpath.normpath("/" + decoded_rest.lstrip("/")) - return normalized == "/mcp" or normalized.startswith("/mcp/") + at = posixpath.normpath("/" + endpoint.strip("/")) + return normalized == at or normalized.startswith(at + "/") def stamp_jsonrpc_role(body: bytes, role: str) -> bytes: diff --git a/tst/unit/env/gateway/website_mcp_test.py b/tst/unit/env/gateway/website_mcp_test.py index f99928124..6aa9f17e0 100644 --- a/tst/unit/env/gateway/website_mcp_test.py +++ b/tst/unit/env/gateway/website_mcp_test.py @@ -9,10 +9,10 @@ from mcp.client.streamable_http import streamable_http_client from pytest_socket import enable_socket -from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER, InternalMCPServer +from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER, AGENT_ENV_ROLE_META_KEY, InternalMCPServer from agent_env.env.gateway import entrypoint as entrypoint_module from agent_env.env.gateway.entrypoint import website_mcp_servers -from agent_env.env.gateway.gateway import Gateway, site_reserved_paths +from agent_env.env.gateway.gateway import Gateway, site_mcp_endpoints, site_reserved_paths from agentenv_protocol import DataPart, ToolContext, environment_card, get_data, tool from agentenv_protocol.website import WebsiteEnvironment, website from tst.unit.env.gateway.process_scope_socket_test import _serve @@ -25,6 +25,14 @@ def test_a_sites_mcp_interface_is_one_of_its_reserved_paths(): assert site_reserved_paths(card) == frozenset({"/agentenv", "/mcp"}) +def test_a_sites_mcp_endpoint_is_its_internal_servers_path_under_its_svc_base(): + servers = [InternalMCPServer("shop", "http://shop-backend:8000/tools/mcp"), + InternalMCPServer("slack", "http://slack:18765/mcp"), + InternalMCPServer("odd", "http://elsewhere:9/mcp")] + sites = {"shop": "http://shop-backend:8000", "odd": "http://odd-backend:8000"} + assert site_mcp_endpoints(servers, sites, {**sites, "mcp-slack": "http://slack:18765"}) == {"shop": "/tools/mcp"} + + @pytest.mark.asyncio async def test_the_sites_whose_cards_declare_an_mcp_interface_are_internal_servers(monkeypatch): calls: dict[str, int] = {} @@ -70,6 +78,24 @@ def down(request: httpx.Request) -> httpx.Response: assert await website_mcp_servers({"shop": "http://shop:8000"}, timeout_s=0) == [] +@pytest.mark.asyncio +async def test_sites_are_asked_at_once_so_one_never_up_leaves_another_its_retries(monkeypatch): + calls: dict[str, int] = {} + + def site(request: httpx.Request) -> httpx.Response: + host = request.url.host + calls[host] = calls.get(host, 0) + 1 + if host == "down" or calls[host] < 3: + raise httpx.ConnectError("not up yet") + return httpx.Response(200, json={"additionalInterfaces": [{"url": "/mcp", "transport": "mcp"}]}) + + real = httpx.AsyncClient + monkeypatch.setattr(entrypoint_module.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(site), **kw)) + monkeypatch.setattr(entrypoint_module, "_INTERNAL_SERVER_POLL_S", 0.01) + found = await website_mcp_servers({"down": "http://down", "late": "http://late"}, timeout_s=1) + assert found == [InternalMCPServer("late", "http://late/mcp")] + + @environment_card(name="shop") @website(domains=["shop.test"]) class _Shop(WebsiteEnvironment): @@ -109,6 +135,14 @@ async def test_the_gateway_serves_a_sites_tools_and_its_route_refuses_the_sites_ assert "place_order" in [t.name for t in (await session.list_tools()).tools] result = await session.call_tool("place_order", {"item": "milk"}) assert not result.isError and result.content[0].text == "order 1 placed" + forged = {AGENT_ENV_ROLE_META_KEY: "mallory@example.com"} + async with ( + httpx.AsyncClient(headers={AGENT_ENV_ROLE_HEADER: "bob@example.com"}, timeout=30) as http, + streamable_http_client(f"{gw_url}/svc/shop/mcp", http_client=http) as (read, write, _), + ClientSession(read, write) as session, + ): + await session.initialize() + assert not (await session.call_tool("place_order", {"item": "eggs"}, meta=forged)).isError async with httpx.AsyncClient(timeout=30) as http: browsing = await http.post(f"{gw_url}/website/shop/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) @@ -118,6 +152,6 @@ async def test_the_gateway_serves_a_sites_tools_and_its_route_refuses_the_sites_ await gw_server.shutdown() finally: await site_server.shutdown() - assert site.orders == [{"item": "milk", "by": "alice@example.com"}] + assert site.orders == [{"item": "milk", "by": "alice@example.com"}, {"item": "eggs", "by": "bob@example.com"}] assert browsing.status_code == 404 assert data.json()["result"]["parts"][0]["data"]["orders"][0]["item"] == "milk" From 7a909f17a6b952a302770e18ead7ae1d508d20bf Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 14:58:34 -0800 Subject: [PATCH 10/20] test(website): the Notes example skips the mcp check, serving no tools Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agentenv-protocol/tests/test_website_notes_example.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index 53127567d..25a5d109a 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -111,8 +111,9 @@ def ready(client: TestClient) -> dict: def test_the_example_passes_every_conformance_check(client: TestClient) -> None: results = conformance.check("http://testserver", client=client) - assert {r.name: r.status for r in results} == dict.fromkeys( - ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"], "passed"), results + assert {r.name: r.status for r in results} == {**dict.fromkeys( + ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"], "passed"), + "mcp": "skipped"}, results def test_the_card_declares_the_site_and_its_routes_by_name_and_it_is_ready_through_website_v1( From 199d1f275ba386de8a538e7731460aad40766330 Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 15:16:31 -0800 Subject: [PATCH 11/20] fix(website): a site's MCP endpoint may be a full URL, is reserved however its card writes it, and conformance reads whole events - The gateway takes a card's MCP endpoint written as a full URL when it names the site's own server, joining a path under the site's address as before, and leaves out, saying so, one on another server. - A site's reserved paths come from each URL its card advertises, a full one or one with a query as well as a bare path, so its browsing route never reaches /mcp however the card writes it. - Conformance reads an event stream's events whole, their data lines joined, and takes the reply to its own request, as ToolSession does, so a reply split over lines or beside notifications is read right. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agentenv_protocol/website/conformance.py | 53 ++++++++++++++----- .../tests/test_website_mcp.py | 24 +++++++++ src/agent_env/env/gateway/entrypoint.py | 29 ++++++++-- src/agent_env/env/gateway/gateway.py | 27 +++++++--- tst/unit/env/gateway/website_mcp_test.py | 15 ++++-- .../gateway/website_reserved_paths_test.py | 4 +- 6 files changed, 120 insertions(+), 32 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py index 2d777d84e..9d912f2cd 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -222,7 +222,7 @@ def _mcp(self, card: dict) -> tuple[Status, str]: "jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": "agentenv-website-conformance", "version": "1"}}}) - server = _mcp_result(first, "initialize").get("serverInfo") or {} + server = _mcp_result(first, "initialize", "initialize").get("serverInfo") or {} if session := first.headers.get("mcp-session-id"): headers["Mcp-Session-Id"] = session try: @@ -232,7 +232,7 @@ def _mcp(self, card: dict) -> tuple[Status, str]: for _page in range(MCP_LIST_PAGES): listed = _mcp_result(self.client.post(url, headers=headers, json={ "jsonrpc": "2.0", "id": "tools/list", "method": "tools/list", - **({"params": {"cursor": cursor}} if cursor else {})}), "tools/list") + **({"params": {"cursor": cursor}} if cursor else {})}), "tools/list", "tools/list") served += [t.get("name") for t in listed.get("tools") or [] if isinstance(t, dict)] cursor = listed.get("nextCursor") if not cursor: @@ -329,21 +329,48 @@ def _outcome(step: Callable[[], tuple[Status, str]]) -> tuple[Status, str]: return "failed", f"{type(e).__name__}: {e}" -def _mcp_result(response: httpx.Response, method: str) -> dict: - """The JSON-RPC result of an MCP request, its answer JSON or the last event of an event stream.""" - text = response.text +def _mcp_result(response: httpx.Response, method: str, request_id: str) -> dict: + """The result of the JSON-RPC reply to request ``request_id``: the answer's JSON, or the reply among an event + stream's messages, each event's data lines joined, as ``ToolSession`` reads them.""" if response.headers.get("content-type", "").startswith("text/event-stream"): - events = [line[len("data:"):] for line in text.splitlines() if line.startswith("data:")] - text = events[-1] if events else "" - try: - body = json.loads(text) - except ValueError: - raise RuntimeError(f"{method} answered {response.status_code} with no JSON-RPC answer: {text[:200]!r}") from None - if not isinstance(body, dict) or "result" not in body: - raise RuntimeError(f"{method} answered {response.status_code}: {str(body)[:200]}") + messages = [] + for data in _sse_data(response.text): + try: + messages.append(json.loads(data)) + except ValueError: + raise RuntimeError(f"{method} answered an event that isn't JSON: {data[:200]!r}") from None + else: + try: + messages = [json.loads(response.text)] + except ValueError: + raise RuntimeError(f"{method} answered {response.status_code} with no JSON-RPC answer: " + f"{response.text[:200]!r}") from None + body = next((m for m in messages if isinstance(m, dict) and m.get("id") == request_id + and ("result" in m or "error" in m)), None) + if body is None or "result" not in body: + raise RuntimeError(f"{method} answered {response.status_code}: {str(body or messages)[:200]}") return body["result"] or {} +def _sse_data(text: str) -> Iterator[str]: + """The data of each ``message`` event in a server-sent event stream, its data lines joined; an event the stream + ends inside is dropped, as are events with no data.""" + event, data = "message", [] + for line in text.splitlines(): + if not line: + payload = "\n".join(data) + if payload.strip() and event == "message": + yield payload + event, data = "message", [] + continue + field, _, value = line.partition(":") + value = value[1:] if value.startswith(" ") else value + if field == "data": + data.append(value) + elif field == "event": + event = value + + def _field_error(error: dict) -> str: """One of a ``ValidationError``'s errors as a person reads it: where, then what.""" where = ".".join(map(str, error.get("loc") or ())) or "the answer" diff --git a/packages/agentenv-protocol/tests/test_website_mcp.py b/packages/agentenv-protocol/tests/test_website_mcp.py index 5a82cdc3a..b57610731 100644 --- a/packages/agentenv-protocol/tests/test_website_mcp.py +++ b/packages/agentenv-protocol/tests/test_website_mcp.py @@ -186,6 +186,30 @@ def refund(self, item: str) -> str: assert all(r.passed for r in results.values()), results +def _streaming(*events: str) -> Route: + """An MCP endpoint answering each request with an event stream of ``events``, ``{id}`` the request's id.""" + async def answer(request: Request) -> Response: + body = await request.json() + if "id" not in body: + return Response(status_code=202) + stream = "".join(event.replace("{id}", json.dumps(body["id"])) for event in events) + return Response(stream, media_type="text/event-stream") + + return Route("/mcp", answer, methods=["POST"]) + + +def test_the_mcp_check_reads_whole_events_and_the_reply_to_its_own_request() -> None: + reply = '{"jsonrpc": "2.0", "id": {id},\n"result": {"serverInfo": {"name": "x"}, "tools": [{"name": "a"}]}}' + mcp = _streaming('event: message\ndata: {"jsonrpc": "2.0", "method": "notifications/message", "params": {}}\n\n', + ": a comment\n\n", + "".join(f"data: {line}\n" for line in reply.split("\n")) + "\n", + 'data: {"jsonrpc": "2.0", "method": "notifications/progress", "params": {}}\n\n') + client = TestClient(_declaring(mcp)) + card = client.get("/.well-known/agent-env.json").json() + found = conformance._outcome(lambda: conformance._Checker("http://testserver", client, "127.0.0.1")._mcp(card)) + assert found == ("passed", "x at /mcp lists the card's 1 tool(s)") + + def test_an_mcp_interface_on_another_server_fails_the_check_with_nothing_sent_there() -> None: sent = [] client = httpx.Client(transport=httpx.MockTransport(lambda request: sent.append(request) or httpx.Response(500))) diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 7561d9082..a588acdce 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -3,6 +3,7 @@ import logging import os import time +from urllib.parse import urljoin, urlsplit import httpx @@ -45,6 +46,7 @@ def parse_internal_servers(spec: str, port: int) -> list[InternalMCPServer]: # docker-compose `depends_on`), so the old 30s ceiling was far too short. _INTERNAL_SERVER_TIMEOUT_S = int(os.environ.get("GATEWAY_INTERNAL_SERVER_TIMEOUT_S", "180")) _INTERNAL_SERVER_POLL_S = 2 +_DEFAULT_PORTS = {"http": 80, "https": 443} def _ready_url(mcp_url: str) -> str: @@ -116,14 +118,31 @@ async def website_mcp_servers(sites: dict[str, str], taken: frozenset[str] = fro cards = await asyncio.gather(*(_site_card(client, name, base, deadline) for name, base in asked.items())) found: list[InternalMCPServer] = [] for (name, base), card in zip(asked.items(), cards): - path = next((i.get("url") for i in (card or {}).get("additionalInterfaces") or [] - if isinstance(i, dict) and i.get("transport") == MCP_TRANSPORT and i.get("url")), None) - if isinstance(path, str) and path.startswith("/"): - logger.info(f"Site {name} is an MCP server at {path}") - found.append(InternalMCPServer(name=name, mcp_url=f"{base}{path}")) + declared = next((i.get("url") for i in (card or {}).get("additionalInterfaces") or [] + if isinstance(i, dict) and i.get("transport") == MCP_TRANSPORT and i.get("url")), None) + if isinstance(declared, str) and (mcp_url := _site_mcp_url(base, declared)) is not None: + logger.info(f"Site {name} is an MCP server at {mcp_url}") + found.append(InternalMCPServer(name=name, mcp_url=mcp_url)) + elif declared is not None: + logger.warning(f"Site {name}'s card declares its MCP endpoint at {declared!r}, not on its own server; its " + "tools aren't served") return found +def _site_mcp_url(base: str, declared: str) -> str | None: + """Where the site at ``base`` serves the MCP endpoint its card declares at ``declared``: a path joined under the + site's address, or a full URL taken as it is when it names that same server; None for another server.""" + if declared.startswith("/"): + return f"{base}{declared}" + try: + given, own = urlsplit(urljoin(base + "/", declared)), urlsplit(base) + same = (given.scheme, given.hostname, given.port or _DEFAULT_PORTS.get(given.scheme)) == ( + own.scheme, own.hostname, own.port or _DEFAULT_PORTS.get(own.scheme)) + except ValueError: + return None + return given.geturl() if same and given.path.startswith("/") else None + + async def _site_card(client: httpx.AsyncClient, name: str, base: str, deadline: float) -> dict | None: """The card the site at ``base`` serves, None when it serves none; asked again while its server can't be reached or answers 5xx, until ``deadline``.""" diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index d92984657..75c1e91c5 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -15,7 +15,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Any -from urllib.parse import parse_qsl +from urllib.parse import parse_qsl, urlsplit from uuid import uuid4 import anyio @@ -1679,23 +1679,34 @@ def site_mcp_endpoints(servers: list[InternalMCPServer], website_urls: dict[str, for server in servers: base = rest_proxy_urls.get(server.name, "").rstrip("/") if server.name in website_urls and base and server.mcp_url.startswith(base + "/"): - endpoints[server.name] = server.mcp_url[len(base):] + endpoints[server.name] = _url_path(server.mcp_url[len(base):]) return endpoints def site_reserved_paths(card: dict) -> frozenset[str]: """The paths a site's card advertises for agentenv, which its route never reaches: its ``url``, every interface's, such as the MCP endpoint of a site that is an MCP server too, and every extension's endpoint and each of its - methods'; each one a path of its own, not the site's root.""" - paths = [card.get("url")] - paths += [i.get("url") for i in card.get("additionalInterfaces") or [] if isinstance(i, dict)] + methods'; each one a path of its own, not the site's root, read from a full URL or one with a query as well as from + a bare path. A full URL's path is reserved whatever its host, since a site is reached under names it never sees in + advance.""" + urls = [card.get("url")] + urls += [i.get("url") for i in card.get("additionalInterfaces") or [] if isinstance(i, dict)] for ext in (card.get("capabilities") or {}).get("extensions") or []: params = ext.get("params") if isinstance(ext, dict) else None if not isinstance(params, dict): continue - paths += [endpoint for *_, endpoint in operations(params)] - paths.append(params.get("endpoint")) - return frozenset(_normalized(p) for p in paths if isinstance(p, str) and p.startswith("/") and _normalized(p) != "/") + urls += [endpoint for *_, endpoint in operations(params)] + urls.append(params.get("endpoint")) + paths = (_url_path(url) for url in urls if isinstance(url, str)) + return frozenset(_normalized(p) for p in paths if p.startswith("/") and _normalized(p) != "/") + + +def _url_path(url: str) -> str: + """The path of ``url``, a bare path or a full URL, without its query or fragment; "" for one that has none.""" + try: + return urlsplit(url).path + except ValueError: + return "" def site_request_path(request: HTTPConnection) -> str | None: diff --git a/tst/unit/env/gateway/website_mcp_test.py b/tst/unit/env/gateway/website_mcp_test.py index 6aa9f17e0..b56c280ca 100644 --- a/tst/unit/env/gateway/website_mcp_test.py +++ b/tst/unit/env/gateway/website_mcp_test.py @@ -20,13 +20,14 @@ CARD = "/.well-known/agent-env.json" -def test_a_sites_mcp_interface_is_one_of_its_reserved_paths(): - card = {"url": "/agentenv", "additionalInterfaces": [{"url": "/mcp", "transport": "mcp"}, {"transport": "x"}]} +@pytest.mark.parametrize("declared", ["/mcp", "http://shop:8000/mcp", "/mcp?transport=http", "http://shop:8000/mcp/#x"]) +def test_a_sites_mcp_interface_is_one_of_its_reserved_paths_however_its_card_writes_it(declared): + card = {"url": "/agentenv", "additionalInterfaces": [{"url": declared, "transport": "mcp"}, {"transport": "x"}]} assert site_reserved_paths(card) == frozenset({"/agentenv", "/mcp"}) def test_a_sites_mcp_endpoint_is_its_internal_servers_path_under_its_svc_base(): - servers = [InternalMCPServer("shop", "http://shop-backend:8000/tools/mcp"), + servers = [InternalMCPServer("shop", "http://shop-backend:8000/tools/mcp?transport=http"), InternalMCPServer("slack", "http://slack:18765/mcp"), InternalMCPServer("odd", "http://elsewhere:9/mcp")] sites = {"shop": "http://shop-backend:8000", "odd": "http://odd-backend:8000"} @@ -40,6 +41,8 @@ async def test_the_sites_whose_cards_declare_an_mcp_interface_are_internal_serve "shop": {"additionalInterfaces": [{"url": "/tools/mcp", "transport": "mcp"}]}, "mail": {"additionalInterfaces": []}, "other": {"additionalInterfaces": [{"url": "/rpc", "transport": "grpc"}]}, + "full": {"additionalInterfaces": [{"url": "http://full:8000/tools/mcp", "transport": "mcp"}]}, + "away": {"additionalInterfaces": [{"url": "http://elsewhere:8000/mcp", "transport": "mcp"}]}, } def site(request: httpx.Request) -> httpx.Response: @@ -59,11 +62,13 @@ def site(request: httpx.Request) -> httpx.Response: real = httpx.AsyncClient monkeypatch.setattr(entrypoint_module.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(site), **kw)) monkeypatch.setattr(entrypoint_module, "_INTERNAL_SERVER_POLL_S", 0) - sites = {name: f"http://{name}:8000/" for name in ("shop", "mail", "other", "late", "nocard", "html", "slack")} + sites = {name: f"http://{name}:8000/" for name in ("shop", "mail", "other", "late", "nocard", "html", "full", "away", + "slack")} found = await website_mcp_servers(sites, taken=frozenset({"slack"})) - assert found == [InternalMCPServer("shop", "http://shop:8000/tools/mcp"), InternalMCPServer("late", "http://late:8000/mcp")] + assert found == [InternalMCPServer("shop", "http://shop:8000/tools/mcp"), InternalMCPServer("late", "http://late:8000/mcp"), + InternalMCPServer("full", "http://full:8000/tools/mcp")] assert "slack" not in calls and calls["late"] == 2 diff --git a/tst/unit/env/gateway/website_reserved_paths_test.py b/tst/unit/env/gateway/website_reserved_paths_test.py index 306a31d76..b5299a29e 100644 --- a/tst/unit/env/gateway/website_reserved_paths_test.py +++ b/tst/unit/env/gateway/website_reserved_paths_test.py @@ -62,7 +62,9 @@ async def _streamed(): def test_a_sites_reserved_paths_are_the_ones_its_card_advertises_each_a_path_of_its_own(): assert site_reserved_paths(_CARD) == {"/agentenv", "/agentenv/ext/website/ready", "/grade", "/grading/now", "/hooks"} - assert site_reserved_paths({"name": "x", "url": "http://elsewhere/agentenv"}) == frozenset() + # a full URL's path is reserved whatever its host: the site is reached under names it never sees in advance + assert site_reserved_paths({"name": "x", "url": "http://elsewhere/agentenv?v=1"}) == frozenset({"/agentenv"}) + assert site_reserved_paths({"name": "x", "url": "http://elsewhere"}) == frozenset() reserved = site_reserved_paths(_CARD) for path in ("/grade", "/grade/", "/x/../grade", "/grading/now", "/hooks/a"): assert reserved_site_path(path, reserved), path From 31b695a65b0912b0140f320753ae7f1e3412aa06 Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 15:22:53 -0800 Subject: [PATCH 12/20] fix(website): a site's MCP endpoint keeps the site's own spelling, and the /svc role stamp matches it by address A card may write its own server differently from the gateway's base (https://shop.test:443/mcp beside https://shop.test). Discovery now returns the endpoint under the base's own spelling, and site_mcp_endpoints compares parsed scheme, host and port rather than a string prefix, so a call through /svc// to that endpoint still gets the gateway's role in its _meta. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/env/gateway/entrypoint.py | 7 +++++-- src/agent_env/env/gateway/gateway.py | 17 +++++++++++++++-- tst/unit/env/gateway/website_mcp_test.py | 17 +++++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index a588acdce..0b651062b 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -131,7 +131,8 @@ async def website_mcp_servers(sites: dict[str, str], taken: frozenset[str] = fro def _site_mcp_url(base: str, declared: str) -> str | None: """Where the site at ``base`` serves the MCP endpoint its card declares at ``declared``: a path joined under the - site's address, or a full URL taken as it is when it names that same server; None for another server.""" + site's address, or a full URL's path and query under it when the URL names that same server, so the URL keeps the + address's own spelling that the gateway's /svc routes use; None for another server.""" if declared.startswith("/"): return f"{base}{declared}" try: @@ -140,7 +141,9 @@ def _site_mcp_url(base: str, declared: str) -> str | None: own.scheme, own.hostname, own.port or _DEFAULT_PORTS.get(own.scheme)) except ValueError: return None - return given.geturl() if same and given.path.startswith("/") else None + if not (same and given.path.startswith("/")): + return None + return f"{base}{given.path}" + (f"?{given.query}" if given.query else "") async def _site_card(client: httpx.AsyncClient, name: str, base: str, deadline: float) -> dict | None: diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 75c1e91c5..5068bf7b9 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -1678,11 +1678,24 @@ def site_mcp_endpoints(servers: list[InternalMCPServer], website_urls: dict[str, endpoints = {} for server in servers: base = rest_proxy_urls.get(server.name, "").rstrip("/") - if server.name in website_urls and base and server.mcp_url.startswith(base + "/"): - endpoints[server.name] = _url_path(server.mcp_url[len(base):]) + if server.name not in website_urls or not base: + continue + try: + given, own = urlsplit(server.mcp_url), urlsplit(base) + same = _address(given) == _address(own) + except ValueError: + continue + under = own.path.rstrip("/") + if same and (given.path == under or given.path.startswith(under + "/")): + endpoints[server.name] = given.path[len(under):] or "/" return endpoints +def _address(url) -> tuple: + """A split URL's scheme, host and port, its scheme's default port when it names none.""" + return url.scheme, url.hostname, url.port or {"http": 80, "https": 443}.get(url.scheme) + + def site_reserved_paths(card: dict) -> frozenset[str]: """The paths a site's card advertises for agentenv, which its route never reaches: its ``url``, every interface's, such as the MCP endpoint of a site that is an MCP server too, and every extension's endpoint and each of its diff --git a/tst/unit/env/gateway/website_mcp_test.py b/tst/unit/env/gateway/website_mcp_test.py index b56c280ca..930bfa494 100644 --- a/tst/unit/env/gateway/website_mcp_test.py +++ b/tst/unit/env/gateway/website_mcp_test.py @@ -32,6 +32,23 @@ def test_a_sites_mcp_endpoint_is_its_internal_servers_path_under_its_svc_base(): InternalMCPServer("odd", "http://elsewhere:9/mcp")] sites = {"shop": "http://shop-backend:8000", "odd": "http://odd-backend:8000"} assert site_mcp_endpoints(servers, sites, {**sites, "mcp-slack": "http://slack:18765"}) == {"shop": "/tools/mcp"} + spelled = [InternalMCPServer("tls", "https://TLS.test:443/mcp"), InternalMCPServer("deep", "http://d:80/base/mcp")] + bases = {"tls": "https://tls.test", "deep": "http://d/base/"} + assert site_mcp_endpoints(spelled, bases, bases) == {"tls": "/mcp", "deep": "/mcp"} + + +@pytest.mark.parametrize(("base", "declared", "mcp_url"), [ + ("https://shop.test", "https://shop.test:443/mcp", "https://shop.test/mcp"), + ("https://shop.test", "https://SHOP.test/mcp?transport=http", "https://shop.test/mcp?transport=http"), + ("http://shop:8000", "/mcp", "http://shop:8000/mcp"), + ("http://shop:8000", "http://shop:8001/mcp", None), + ("http://shop:8000", "https://shop:8000/mcp", None), +]) +def test_a_declared_mcp_url_on_the_sites_server_keeps_the_sites_own_spelling(base, declared, mcp_url): + assert entrypoint_module._site_mcp_url(base, declared) == mcp_url + if mcp_url is not None: + servers = [InternalMCPServer("shop", mcp_url)] + assert site_mcp_endpoints(servers, {"shop": base}, {"shop": base}) == {"shop": "/mcp"} @pytest.mark.asyncio From 3d9e4aa404cc36f532e307093d3f2693083e615b Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 15:33:44 -0800 Subject: [PATCH 13/20] fix(website): a full MCP URL on a site behind a path prefix keeps its own path A card's full URL already names its path from the server's root, so the gateway keeps that path on the site's scheme and host instead of joining it under the site's base path, which a prefixed base doubled. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/env/gateway/entrypoint.py | 7 ++++--- tst/unit/env/gateway/website_mcp_test.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 0b651062b..9c0e320b4 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -131,8 +131,9 @@ async def website_mcp_servers(sites: dict[str, str], taken: frozenset[str] = fro def _site_mcp_url(base: str, declared: str) -> str | None: """Where the site at ``base`` serves the MCP endpoint its card declares at ``declared``: a path joined under the - site's address, or a full URL's path and query under it when the URL names that same server, so the URL keeps the - address's own spelling that the gateway's /svc routes use; None for another server.""" + site's address, as a card's paths are relative to where it was read, or, when a full URL names that same server, + the URL's own path and query on the address's scheme and host as the address spells them, which the gateway's /svc + routes use; None for another server.""" if declared.startswith("/"): return f"{base}{declared}" try: @@ -143,7 +144,7 @@ def _site_mcp_url(base: str, declared: str) -> str | None: return None if not (same and given.path.startswith("/")): return None - return f"{base}{given.path}" + (f"?{given.query}" if given.query else "") + return f"{own.scheme}://{own.netloc}{given.path}" + (f"?{given.query}" if given.query else "") async def _site_card(client: httpx.AsyncClient, name: str, base: str, deadline: float) -> dict | None: diff --git a/tst/unit/env/gateway/website_mcp_test.py b/tst/unit/env/gateway/website_mcp_test.py index 930bfa494..3245ae16b 100644 --- a/tst/unit/env/gateway/website_mcp_test.py +++ b/tst/unit/env/gateway/website_mcp_test.py @@ -43,6 +43,8 @@ def test_a_sites_mcp_endpoint_is_its_internal_servers_path_under_its_svc_base(): ("http://shop:8000", "/mcp", "http://shop:8000/mcp"), ("http://shop:8000", "http://shop:8001/mcp", None), ("http://shop:8000", "https://shop:8000/mcp", None), + ("http://shop/base", "http://shop/base/mcp", "http://shop/base/mcp"), + ("http://shop/base", "/mcp", "http://shop/base/mcp"), ]) def test_a_declared_mcp_url_on_the_sites_server_keeps_the_sites_own_spelling(base, declared, mcp_url): assert entrypoint_module._site_mcp_url(base, declared) == mcp_url @@ -51,6 +53,17 @@ def test_a_declared_mcp_url_on_the_sites_server_keeps_the_sites_own_spelling(bas assert site_mcp_endpoints(servers, {"shop": base}, {"shop": base}) == {"shop": "/mcp"} +@pytest.mark.asyncio +async def test_a_site_behind_a_path_prefix_is_found_at_the_full_url_its_card_declares(monkeypatch): + def site(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/base" + CARD + return httpx.Response(200, json={"additionalInterfaces": [{"url": "http://shop/base/mcp", "transport": "mcp"}]}) + + real = httpx.AsyncClient + monkeypatch.setattr(entrypoint_module.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(site), **kw)) + assert await website_mcp_servers({"shop": "http://shop/base"}) == [InternalMCPServer("shop", "http://shop/base/mcp")] + + @pytest.mark.asyncio async def test_the_sites_whose_cards_declare_an_mcp_interface_are_internal_servers(monkeypatch): calls: dict[str, int] = {} From d7f3c7b3b3da4c8bb737f8564d2b81e1e67546ce Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 5 Nov 2026 17:57:49 -0800 Subject: [PATCH 14/20] fix(website): a full MCP URL under a site's base path stays off its route, and a tools/list with no list fails conformance (FD-4683) - A card naming its MCP endpoint by full URL, for a site whose address has a path (http://shop/base, the card's http://shop/base/mcp), reserved /base/mcp, while the site's route forwards /mcp to /base/mcp: a browser reached the endpoint with its own _meta role. A full URL's path is now reserved under the site's address's path. - A tools/list reply with a null result, no tools or a tools value that isn't a list counted as an empty list; it now fails the mcp check. Co-Authored-By: Claude Opus 5.5 --- .../agentenv_protocol/website/conformance.py | 4 ++- .../tests/test_website_mcp.py | 16 ++++++++++ src/agent_env/env/gateway/gateway.py | 20 +++++++----- .../gateway/website_reserved_paths_test.py | 31 +++++++++++++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py index 9d912f2cd..16ba8388c 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -233,7 +233,9 @@ def _mcp(self, card: dict) -> tuple[Status, str]: listed = _mcp_result(self.client.post(url, headers=headers, json={ "jsonrpc": "2.0", "id": "tools/list", "method": "tools/list", **({"params": {"cursor": cursor}} if cursor else {})}), "tools/list", "tools/list") - served += [t.get("name") for t in listed.get("tools") or [] if isinstance(t, dict)] + if not isinstance(listed, dict) or not isinstance(listed.get("tools"), list): + return "failed", f"tools/list at {path} answered no tools list: {str(listed)[:200]}" + served += [t.get("name") for t in listed["tools"] if isinstance(t, dict)] cursor = listed.get("nextCursor") if not cursor: break diff --git a/packages/agentenv-protocol/tests/test_website_mcp.py b/packages/agentenv-protocol/tests/test_website_mcp.py index b57610731..05ed7fd07 100644 --- a/packages/agentenv-protocol/tests/test_website_mcp.py +++ b/packages/agentenv-protocol/tests/test_website_mcp.py @@ -186,6 +186,22 @@ def refund(self, item: str) -> str: assert all(r.passed for r in results.values()), results +@pytest.mark.parametrize("result", [None, {}, {"tools": "a"}, {"tools": None}]) +def test_a_tools_list_answering_no_list_of_tools_fails_the_check(result: object) -> None: + async def answer(request: Request) -> Response: + body = await request.json() + if body.get("method") == "initialize": + return JSONResponse({"jsonrpc": "2.0", "id": body["id"], "result": {"serverInfo": {"name": "x"}}}) + if body.get("method") == "tools/list": + return JSONResponse({"jsonrpc": "2.0", "id": body["id"], "result": result}) + return Response(status_code=202) + + client = TestClient(_declaring(Route("/mcp", answer, methods=["POST"]))) + card = client.get("/.well-known/agent-env.json").json() + found = conformance._outcome(lambda: conformance._Checker("http://testserver", client, "127.0.0.1")._mcp(card)) + assert found[0] == "failed" and "answered no tools list" in found[1], found + + def _streaming(*events: str) -> Route: """An MCP endpoint answering each request with an event stream of ``events``, ``{id}`` the request's id.""" async def answer(request: Request) -> Response: diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 5068bf7b9..733ed7473 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -800,7 +800,8 @@ async def _site_reserved_paths(self, app: str) -> frozenset[str] | None: card = response.json() if response.is_success else None except ValueError: card = None - self._site_reserved[app] = site_reserved_paths(card) if isinstance(card, dict) else frozenset() + base_path = urlsplit(self.website_urls.get(app) or base_url).path + self._site_reserved[app] = site_reserved_paths(card, base_path) if isinstance(card, dict) else frozenset() return self._site_reserved[app] def _backing_keys(self) -> list[str]: @@ -1696,12 +1697,12 @@ def _address(url) -> tuple: return url.scheme, url.hostname, url.port or {"http": 80, "https": 443}.get(url.scheme) -def site_reserved_paths(card: dict) -> frozenset[str]: +def site_reserved_paths(card: dict, base_path: str = "") -> frozenset[str]: """The paths a site's card advertises for agentenv, which its route never reaches: its ``url``, every interface's, such as the MCP endpoint of a site that is an MCP server too, and every extension's endpoint and each of its methods'; each one a path of its own, not the site's root, read from a full URL or one with a query as well as from a bare path. A full URL's path is reserved whatever its host, since a site is reached under names it never sees in - advance.""" + advance, and as its route reaches it: under the site's address's path, ``base_path``, when it is under it.""" urls = [card.get("url")] urls += [i.get("url") for i in card.get("additionalInterfaces") or [] if isinstance(i, dict)] for ext in (card.get("capabilities") or {}).get("extensions") or []: @@ -1710,16 +1711,21 @@ def site_reserved_paths(card: dict) -> frozenset[str]: continue urls += [endpoint for *_, endpoint in operations(params)] urls.append(params.get("endpoint")) - paths = (_url_path(url) for url in urls if isinstance(url, str)) + base = base_path.rstrip("/") + paths = (_url_path(url, base) for url in urls if isinstance(url, str)) return frozenset(_normalized(p) for p in paths if p.startswith("/") and _normalized(p) != "/") -def _url_path(url: str) -> str: - """The path of ``url``, a bare path or a full URL, without its query or fragment; "" for one that has none.""" +def _url_path(url: str, base: str = "") -> str: + """The path of ``url``, a bare path or a full URL, without its query or fragment, a full URL's taken under + ``base`` when it is under it; "" for one that has none.""" try: - return urlsplit(url).path + parts = urlsplit(url) except ValueError: return "" + if parts.netloc and base and (parts.path == base or parts.path.startswith(base + "/")): + return parts.path[len(base):] or "/" + return parts.path def site_request_path(request: HTTPConnection) -> str | None: diff --git a/tst/unit/env/gateway/website_reserved_paths_test.py b/tst/unit/env/gateway/website_reserved_paths_test.py index b5299a29e..f93bf7d26 100644 --- a/tst/unit/env/gateway/website_reserved_paths_test.py +++ b/tst/unit/env/gateway/website_reserved_paths_test.py @@ -72,6 +72,37 @@ def test_a_sites_reserved_paths_are_the_ones_its_card_advertises_each_a_path_of_ assert not reserved_site_path(path, reserved), path + +def test_a_full_url_under_the_sites_address_is_reserved_where_its_route_reaches_it(): + card = {"name": "shop", "additionalInterfaces": [{"url": "http://shop/base/mcp", "transport": "mcp"}]} + assert site_reserved_paths(card, "/base") == frozenset({"/mcp"}) + assert site_reserved_paths(card, "/base/") == frozenset({"/mcp"}) + assert site_reserved_paths(card) == frozenset({"/base/mcp"}) + assert site_reserved_paths({"name": "shop", "url": "/base/agentenv"}, "/base") == frozenset({"/base/agentenv"}) + + +@pytest.mark.asyncio +async def test_a_site_behind_a_base_path_refuses_the_mcp_endpoint_its_card_names_by_full_url(monkeypatch): + reached = [] + card = {"name": "shop", "url": "/agentenv", + "additionalInterfaces": [{"url": "http://shop-website-backend:8000/base/mcp", "transport": "mcp"}]} + + def site(request): + reached.append(request.url.path) + if request.url.path == f"/base{WELL_KNOWN_PATH}": + return httpx.Response(200, json=card) + return httpx.Response(200, content=_streamed()) + + transport = httpx.MockTransport(site) + monkeypatch.setattr(rest_proxy_module.httpx, "AsyncClient", lambda **kw: _RealAsyncClient(transport=transport, **kw)) + gw = _gateway(_Changelog(), website_urls={"shop": "http://shop-website-backend:8000/base"}, + rest_proxy_urls={"shop": "http://shop-website-backend:8000/base"}) + + for path in ("mcp", "mcp/", "x/../mcp"): + assert (await _http(gw, "POST", f"/website/shop/{path}", body=b"{}"))[0] == 404, path + assert (await _http(gw, "GET", "/website/shop/orders"))[0] == 200 + assert reached == [f"/base{WELL_KNOWN_PATH}", "/base/orders"] + @pytest.mark.asyncio async def test_a_sites_route_refuses_what_its_card_advertises_reading_the_card_once(monkeypatch): reached = [] From bc3a539cc1a042d7c18aeec795e30b16a9e11109 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 5 Nov 2026 19:07:28 -0800 Subject: [PATCH 15/20] fix(gateway): the gateway doesn't start while a site's card can't be read (FD-4683) It waited up to its internal-server timeout for each site's card, then started without the tools of a site still down, and never added them once the site came up. It now raises, naming every such site, as it does for an MCP server that never comes up, and compose restarts it until the sites answer. A site answering no card (a 4xx) still counts as up, with no tools. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/entrypoint.py | 16 ++++++++++------ tst/unit/env/gateway/website_mcp_test.py | 12 +++++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 9c0e320b4..3e2803046 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -47,6 +47,7 @@ def parse_internal_servers(spec: str, port: int) -> list[InternalMCPServer]: _INTERNAL_SERVER_TIMEOUT_S = int(os.environ.get("GATEWAY_INTERNAL_SERVER_TIMEOUT_S", "180")) _INTERNAL_SERVER_POLL_S = 2 _DEFAULT_PORTS = {"http": 80, "https": 443} +_UNREAD = object() def _ready_url(mcp_url: str) -> str: @@ -111,11 +112,15 @@ async def website_mcp_servers(sites: dict[str, str], taken: frozenset[str] = fro the MCP endpoint its card declares, so the gateway serves its tools as it does an MCP env's. A site whose card declares no MCP interface, or that answers no card, is left out, as is one named as an internal server already (``taken``). Every site is asked at once, and one whose server can't be reached yet is asked again until - ``timeout_s``.""" + ``timeout_s``; raises, naming them, when a site's card still can't be read then, so the gateway doesn't start + without a site, as with an MCP server that never comes up.""" asked = {name: url.rstrip("/") for name, url in sites.items() if name not in taken} deadline = time.monotonic() + timeout_s async with httpx.AsyncClient() as client: - cards = await asyncio.gather(*(_site_card(client, name, base, deadline) for name, base in asked.items())) + cards = await asyncio.gather(*(_site_card(client, base, deadline) for base in asked.values())) + unread = [name for name, card in zip(asked, cards) if card is _UNREAD] + if unread: + raise RuntimeError(f"Website card(s) not readable after {timeout_s}s: {', '.join(sorted(unread))}") found: list[InternalMCPServer] = [] for (name, base), card in zip(asked.items(), cards): declared = next((i.get("url") for i in (card or {}).get("additionalInterfaces") or [] @@ -147,9 +152,9 @@ def _site_mcp_url(base: str, declared: str) -> str | None: return f"{own.scheme}://{own.netloc}{given.path}" + (f"?{given.query}" if given.query else "") -async def _site_card(client: httpx.AsyncClient, name: str, base: str, deadline: float) -> dict | None: +async def _site_card(client: httpx.AsyncClient, base: str, deadline: float) -> dict | None | object: """The card the site at ``base`` serves, None when it serves none; asked again while its server can't be reached - or answers 5xx, until ``deadline``.""" + or answers 5xx, until ``deadline``, then ``_UNREAD``.""" while True: try: answer = await client.get(base + WELL_KNOWN_PATH, timeout=5) @@ -162,8 +167,7 @@ async def _site_card(client: httpx.AsyncClient, name: str, base: str, deadline: return None return card if isinstance(card, dict) else None if time.monotonic() >= deadline: - logger.warning(f"site {name}'s card at {base} didn't answer in time; its MCP tools, if any, aren't served") - return None + return _UNREAD await asyncio.sleep(_INTERNAL_SERVER_POLL_S) diff --git a/tst/unit/env/gateway/website_mcp_test.py b/tst/unit/env/gateway/website_mcp_test.py index 3245ae16b..a59b7ebf1 100644 --- a/tst/unit/env/gateway/website_mcp_test.py +++ b/tst/unit/env/gateway/website_mcp_test.py @@ -103,18 +103,19 @@ def site(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio -async def test_a_site_whose_server_never_answers_is_left_out_once_the_time_is_up(monkeypatch): +async def test_a_site_whose_card_cant_be_read_in_time_stops_the_gateway_starting(monkeypatch): def down(request: httpx.Request) -> httpx.Response: raise httpx.ConnectError("down") real = httpx.AsyncClient monkeypatch.setattr(entrypoint_module.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(down), **kw)) monkeypatch.setattr(entrypoint_module, "_INTERNAL_SERVER_POLL_S", 0) - assert await website_mcp_servers({"shop": "http://shop:8000"}, timeout_s=0) == [] + with pytest.raises(RuntimeError, match="not readable after 0s: shop"): + await website_mcp_servers({"shop": "http://shop:8000"}, timeout_s=0) @pytest.mark.asyncio -async def test_sites_are_asked_at_once_so_one_never_up_leaves_another_its_retries(monkeypatch): +async def test_sites_are_asked_at_once_and_the_one_never_up_is_named(monkeypatch): calls: dict[str, int] = {} def site(request: httpx.Request) -> httpx.Response: @@ -127,8 +128,9 @@ def site(request: httpx.Request) -> httpx.Response: real = httpx.AsyncClient monkeypatch.setattr(entrypoint_module.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(site), **kw)) monkeypatch.setattr(entrypoint_module, "_INTERNAL_SERVER_POLL_S", 0.01) - found = await website_mcp_servers({"down": "http://down", "late": "http://late"}, timeout_s=1) - assert found == [InternalMCPServer("late", "http://late/mcp")] + with pytest.raises(RuntimeError, match=r"not readable after 1s: down$"): + await website_mcp_servers({"down": "http://down", "late": "http://late"}, timeout_s=1) + assert calls["late"] == 3 @environment_card(name="shop") From 493f52ff013950e045cf516eed4b7fbbba8f2317 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 5 Nov 2026 19:50:14 -0800 Subject: [PATCH 16/20] fix(gateway): an MCP endpoint a site's card names by a relative path stays off its route (FD-4683) Discovery resolves a relative interface url (mcp, tools/mcp) under the site's address, but the reserved paths kept only those starting with /, so /website//mcp reached the endpoint with a caller's own _meta role. A relative path is now reserved under the site's root. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/gateway.py | 5 ++++- tst/unit/env/gateway/website_mcp_test.py | 8 +++++++- .../env/gateway/website_reserved_paths_test.py | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 733ed7473..06862f8c3 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -1718,13 +1718,16 @@ def site_reserved_paths(card: dict, base_path: str = "") -> frozenset[str]: def _url_path(url: str, base: str = "") -> str: """The path of ``url``, a bare path or a full URL, without its query or fragment, a full URL's taken under - ``base`` when it is under it; "" for one that has none.""" + ``base`` when it is under it, and a relative one (``mcp``) under the site's root, as discovery resolves it; "" for + one that has none.""" try: parts = urlsplit(url) except ValueError: return "" if parts.netloc and base and (parts.path == base or parts.path.startswith(base + "/")): return parts.path[len(base):] or "/" + if not parts.scheme and not parts.netloc and parts.path and not parts.path.startswith("/"): + return "/" + parts.path return parts.path diff --git a/tst/unit/env/gateway/website_mcp_test.py b/tst/unit/env/gateway/website_mcp_test.py index a59b7ebf1..8f24864ed 100644 --- a/tst/unit/env/gateway/website_mcp_test.py +++ b/tst/unit/env/gateway/website_mcp_test.py @@ -20,12 +20,18 @@ CARD = "/.well-known/agent-env.json" -@pytest.mark.parametrize("declared", ["/mcp", "http://shop:8000/mcp", "/mcp?transport=http", "http://shop:8000/mcp/#x"]) +@pytest.mark.parametrize("declared", ["/mcp", "http://shop:8000/mcp", "/mcp?transport=http", "http://shop:8000/mcp/#x", + "mcp", "./mcp", "mcp?transport=http"]) def test_a_sites_mcp_interface_is_one_of_its_reserved_paths_however_its_card_writes_it(declared): card = {"url": "/agentenv", "additionalInterfaces": [{"url": declared, "transport": "mcp"}, {"transport": "x"}]} assert site_reserved_paths(card) == frozenset({"/agentenv", "/mcp"}) +def test_a_relative_mcp_interface_is_reserved_under_the_sites_root_as_discovery_resolves_it(): + card = {"additionalInterfaces": [{"url": "tools/mcp", "transport": "mcp"}]} + assert site_reserved_paths(card) == site_reserved_paths(card, "/base") == frozenset({"/tools/mcp"}) + + def test_a_sites_mcp_endpoint_is_its_internal_servers_path_under_its_svc_base(): servers = [InternalMCPServer("shop", "http://shop-backend:8000/tools/mcp?transport=http"), InternalMCPServer("slack", "http://slack:18765/mcp"), diff --git a/tst/unit/env/gateway/website_reserved_paths_test.py b/tst/unit/env/gateway/website_reserved_paths_test.py index f93bf7d26..40c238684 100644 --- a/tst/unit/env/gateway/website_reserved_paths_test.py +++ b/tst/unit/env/gateway/website_reserved_paths_test.py @@ -103,6 +103,24 @@ def site(request): assert (await _http(gw, "GET", "/website/shop/orders"))[0] == 200 assert reached == [f"/base{WELL_KNOWN_PATH}", "/base/orders"] + +@pytest.mark.asyncio +async def test_a_site_refuses_the_mcp_endpoint_its_card_names_by_a_relative_path(monkeypatch): + card = {"name": "shop", "url": "/agentenv", "additionalInterfaces": [{"url": "mcp", "transport": "mcp"}]} + + def site(request): + if request.url.path == WELL_KNOWN_PATH: + return httpx.Response(200, json=card) + return httpx.Response(200, content=_streamed()) + + transport = httpx.MockTransport(site) + monkeypatch.setattr(rest_proxy_module.httpx, "AsyncClient", lambda **kw: _RealAsyncClient(transport=transport, **kw)) + gw = _gateway(_Changelog(), website_urls={"shop": "http://shop-website-backend:8000"}, + rest_proxy_urls={"shop": "http://shop-website-backend:8000"}) + for path in ("mcp", "mcp/", "./mcp"): + assert (await _http(gw, "POST", f"/website/shop/{path}", body=b"{}"))[0] == 404, path + assert (await _http(gw, "GET", "/website/shop/orders"))[0] == 200 + @pytest.mark.asyncio async def test_a_sites_route_refuses_what_its_card_advertises_reading_the_card_once(monkeypatch): reached = [] From ba64a8a0510d0f279024bde64988466aac3b78a6 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 5 Nov 2026 22:03:23 -0800 Subject: [PATCH 17/20] refactor(gateway): one reading of a site card's URLs for discovery, /svc role stamping and reserved paths (FD-4683) Each worked out where a card's URL lands on the site by its own path and port arithmetic, and the two places they disagreed (a full URL under a base path, a relative path) were each a way past the reserved paths. card_url now resolves a card's URL against the site's address once: its path, its route path under the address's, and whether it is on the site's own server; the three read that. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/entrypoint.py | 26 +++----- src/agent_env/env/gateway/gateway.py | 85 +++++++++++++++---------- 2 files changed, 60 insertions(+), 51 deletions(-) diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 3e2803046..7f3606e17 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -3,13 +3,13 @@ import logging import os import time -from urllib.parse import urljoin, urlsplit +from urllib.parse import urlsplit import httpx from . import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode, InternalMCPServer from .constants import DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, MCP_TRANSPORT, WELL_KNOWN_PATH -from .gateway import Gateway +from .gateway import Gateway, card_url logger = logging.getLogger(__name__) @@ -46,7 +46,6 @@ def parse_internal_servers(spec: str, port: int) -> list[InternalMCPServer]: # docker-compose `depends_on`), so the old 30s ceiling was far too short. _INTERNAL_SERVER_TIMEOUT_S = int(os.environ.get("GATEWAY_INTERNAL_SERVER_TIMEOUT_S", "180")) _INTERNAL_SERVER_POLL_S = 2 -_DEFAULT_PORTS = {"http": 80, "https": 443} _UNREAD = object() @@ -135,21 +134,14 @@ async def website_mcp_servers(sites: dict[str, str], taken: frozenset[str] = fro def _site_mcp_url(base: str, declared: str) -> str | None: - """Where the site at ``base`` serves the MCP endpoint its card declares at ``declared``: a path joined under the - site's address, as a card's paths are relative to where it was read, or, when a full URL names that same server, - the URL's own path and query on the address's scheme and host as the address spells them, which the gateway's /svc - routes use; None for another server.""" - if declared.startswith("/"): - return f"{base}{declared}" - try: - given, own = urlsplit(urljoin(base + "/", declared)), urlsplit(base) - same = (given.scheme, given.hostname, given.port or _DEFAULT_PORTS.get(given.scheme)) == ( - own.scheme, own.hostname, own.port or _DEFAULT_PORTS.get(own.scheme)) - except ValueError: + """Where the site at ``base`` serves the MCP endpoint its card declares at ``declared`` (``card_url``), on the + address's scheme and host as the address spells them, which the gateway's /svc routes use; None for another + server.""" + resolved = card_url(base, declared) + if resolved is None or not resolved.own_server: return None - if not (same and given.path.startswith("/")): - return None - return f"{own.scheme}://{own.netloc}{given.path}" + (f"?{given.query}" if given.query else "") + own = urlsplit(base.rstrip("/")) + return f"{own.scheme}://{own.netloc}{resolved.path}" + (f"?{resolved.query}" if resolved.query else "") async def _site_card(client: httpx.AsyncClient, base: str, deadline: float) -> dict | None | object: diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 06862f8c3..8fec7558d 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -14,8 +14,8 @@ from contextlib import asynccontextmanager from datetime import datetime, timezone from pathlib import Path -from typing import TYPE_CHECKING, Any -from urllib.parse import parse_qsl, urlsplit +from typing import TYPE_CHECKING, Any, NamedTuple +from urllib.parse import parse_qsl, urljoin, urlsplit from uuid import uuid4 import anyio @@ -800,8 +800,8 @@ async def _site_reserved_paths(self, app: str) -> frozenset[str] | None: card = response.json() if response.is_success else None except ValueError: card = None - base_path = urlsplit(self.website_urls.get(app) or base_url).path - self._site_reserved[app] = site_reserved_paths(card, base_path) if isinstance(card, dict) else frozenset() + address = self.website_urls.get(app) or base_url + self._site_reserved[app] = site_reserved_paths(card, address) if isinstance(card, dict) else frozenset() return self._site_reserved[app] def _backing_keys(self) -> list[str]: @@ -1678,31 +1678,63 @@ def site_mcp_endpoints(servers: list[InternalMCPServer], website_urls: dict[str, name: where a call through /svc// gets the gateway's role in its JSON-RPC ``_meta``, as an MCP server's does.""" endpoints = {} for server in servers: - base = rest_proxy_urls.get(server.name, "").rstrip("/") + base = rest_proxy_urls.get(server.name, "") if server.name not in website_urls or not base: continue - try: - given, own = urlsplit(server.mcp_url), urlsplit(base) - same = _address(given) == _address(own) - except ValueError: - continue - under = own.path.rstrip("/") - if same and (given.path == under or given.path.startswith(under + "/")): - endpoints[server.name] = given.path[len(under):] or "/" + resolved = card_url(base, server.mcp_url) + if resolved is not None and resolved.own_server and resolved.route_path is not None: + endpoints[server.name] = resolved.route_path return endpoints +class CardURL(NamedTuple): + """A URL a site's card names, as the site at an address serves it: its path and query; the path a request on the + site's route reaches it at, its path under the address's (None when it isn't under it); and whether it is on the + site's own server.""" + path: str + query: str + route_path: str | None + own_server: bool + + +def card_url(base: str, url: str) -> CardURL | None: + """``url``, which a site's card names, against the site's address ``base`` (a URL, or only its path): a path, + absolute or relative, joined under the address's path, since a card's paths are relative to where it was read, and + on the site's own server; a full URL as it is, on the site's own server when its scheme, host and port are the + address's. The one reading of a card's URLs that discovery, /svc role stamping and a route's reserved paths share. + None for one with no path, or that can't be read.""" + try: + own, given = urlsplit(base.rstrip("/")), urlsplit(url) + full = bool(given.scheme or given.netloc) + same = not full or _address(given) == _address(own) + except ValueError: + return None + under = own.path.rstrip("/") + if full: + path = given.path + elif given.path.startswith("/"): + path = under + given.path + else: + path = urljoin(under + "/", given.path) if given.path else "" + if not path.startswith("/"): + return None + normalized = _normalized(path) + route = (normalized[len(under):] or "/") if normalized == under or normalized.startswith(under + "/") else None + return CardURL(path, given.query, route, same) + + def _address(url) -> tuple: """A split URL's scheme, host and port, its scheme's default port when it names none.""" return url.scheme, url.hostname, url.port or {"http": 80, "https": 443}.get(url.scheme) -def site_reserved_paths(card: dict, base_path: str = "") -> frozenset[str]: +def site_reserved_paths(card: dict, base: str = "") -> frozenset[str]: """The paths a site's card advertises for agentenv, which its route never reaches: its ``url``, every interface's, such as the MCP endpoint of a site that is an MCP server too, and every extension's endpoint and each of its methods'; each one a path of its own, not the site's root, read from a full URL or one with a query as well as from - a bare path. A full URL's path is reserved whatever its host, since a site is reached under names it never sees in - advance, and as its route reaches it: under the site's address's path, ``base_path``, when it is under it.""" + a bare path, as ``card_url`` reads them against the site's address ``base``. A full URL's path is reserved + whatever its host, since a site is reached under names it never sees in advance: as its route reaches it, under the + address's path, or as it is when it isn't under it.""" urls = [card.get("url")] urls += [i.get("url") for i in card.get("additionalInterfaces") or [] if isinstance(i, dict)] for ext in (card.get("capabilities") or {}).get("extensions") or []: @@ -1711,24 +1743,9 @@ def site_reserved_paths(card: dict, base_path: str = "") -> frozenset[str]: continue urls += [endpoint for *_, endpoint in operations(params)] urls.append(params.get("endpoint")) - base = base_path.rstrip("/") - paths = (_url_path(url, base) for url in urls if isinstance(url, str)) - return frozenset(_normalized(p) for p in paths if p.startswith("/") and _normalized(p) != "/") - - -def _url_path(url: str, base: str = "") -> str: - """The path of ``url``, a bare path or a full URL, without its query or fragment, a full URL's taken under - ``base`` when it is under it, and a relative one (``mcp``) under the site's root, as discovery resolves it; "" for - one that has none.""" - try: - parts = urlsplit(url) - except ValueError: - return "" - if parts.netloc and base and (parts.path == base or parts.path.startswith(base + "/")): - return parts.path[len(base):] or "/" - if not parts.scheme and not parts.netloc and parts.path and not parts.path.startswith("/"): - return "/" + parts.path - return parts.path + resolved = (card_url(base, url) for url in urls if isinstance(url, str)) + paths = (_normalized(r.path if r.route_path is None else r.route_path) for r in resolved if r is not None) + return frozenset(p for p in paths if p != "/") def site_request_path(request: HTTPConnection) -> str | None: From d5935877373595ec51c949a08d7d385fde43463b Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 5 Nov 2026 22:52:56 -0800 Subject: [PATCH 18/20] test(integration): throwaway registries run the mirror image the workflow pulls (FD-4683) The tests started registry:2 from Docker Hub, which the local-backends workflow doesn't pull, so each one pulled it anew; when that failed, local_parallel_deploy_test skipped ("could not start registry:2"), which the skip policy fails. They now run mirror.gcr.io/library/registry:2, the image the workflow pulls before the tests, named once in tst/integration/utils/registry.py, and that skip names docker's error. Co-Authored-By: Claude Opus 5.5 --- tst/integration/env/envs/server_env_local_test.py | 3 ++- tst/integration/store/local_image_composition_test.py | 3 ++- tst/integration/store/local_parallel_deploy_test.py | 7 ++++--- tst/integration/store/local_registry_image_store_test.py | 3 ++- tst/integration/store/local_task_lifecycle_test.py | 3 ++- tst/integration/store/oci_registry_image_store_test.py | 3 ++- .../task_step/test_container_sandbox_files_local.py | 3 ++- .../task_step/test_prompt_agent_file_parts_local.py | 3 ++- .../task_step/test_prompt_agent_live_trajectory_local.py | 3 ++- .../task_step/test_run_code_named_sandbox_local.py | 3 ++- tst/integration/utils/registry.py | 4 ++++ 11 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 tst/integration/utils/registry.py diff --git a/tst/integration/env/envs/server_env_local_test.py b/tst/integration/env/envs/server_env_local_test.py index cf63b3673..1285ea568 100644 --- a/tst/integration/env/envs/server_env_local_test.py +++ b/tst/integration/env/envs/server_env_local_test.py @@ -39,6 +39,7 @@ from agent_env.task_step.context import TaskStepContext from agent_env.task.teardown import TORN_DOWN_KEY from agent_env.task_step.task_steps.teardown_sandboxes import TeardownSandboxesTaskStep +from tst.integration.utils.registry import REGISTRY_IMAGE from tst.util.a2a_test_agent import put_test_agent pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -75,7 +76,7 @@ def local_stack(monkeypatch, tmp_path): pytest.skip("docker daemon not available") reg_port = _free_port() reg_name = f"server-e2e-registry-{uuid.uuid4().hex[:8]}" - if _docker("run", "-d", "--rm", "--name", reg_name, "-p", f"127.0.0.1:{reg_port}:5000", "registry:2").returncode != 0: + if _docker("run", "-d", "--rm", "--name", reg_name, "-p", f"127.0.0.1:{reg_port}:5000", REGISTRY_IMAGE).returncode != 0: pytest.skip("could not start registry:2") host = f"localhost:{reg_port}" if not _wait_ready(f"http://{host}/v2/", (200, 401)): diff --git a/tst/integration/store/local_image_composition_test.py b/tst/integration/store/local_image_composition_test.py index 66ddb522e..5404f1d42 100644 --- a/tst/integration/store/local_image_composition_test.py +++ b/tst/integration/store/local_image_composition_test.py @@ -23,6 +23,7 @@ from agent_env.config import configure, reset_config, set_image_store from agent_env.config.paths import state_root from agent_env.store.image_store import LocalRegistryImageStore +from tst.integration.utils.registry import REGISTRY_IMAGE pytestmark = pytest.mark.integration @@ -43,7 +44,7 @@ def _docker(*args): def all_local(monkeypatch, tmp_path): port = _free_port() name = f"local-composition-registry-{uuid.uuid4().hex[:8]}" - start = _docker("run", "-d", "--rm", "--name", name, "-p", f"127.0.0.1:{port}:5000", "registry:2") + start = _docker("run", "-d", "--rm", "--name", name, "-p", f"127.0.0.1:{port}:5000", REGISTRY_IMAGE) if start.returncode != 0: pytest.skip(f"could not start registry:2: {start.stderr.strip()}") host = f"localhost:{port}" diff --git a/tst/integration/store/local_parallel_deploy_test.py b/tst/integration/store/local_parallel_deploy_test.py index 4964e80c4..322bf7ea1 100644 --- a/tst/integration/store/local_parallel_deploy_test.py +++ b/tst/integration/store/local_parallel_deploy_test.py @@ -27,6 +27,7 @@ from agent_env.task import Task from agent_env.task.store import get_task_instance_store from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep +from tst.integration.utils.registry import REGISTRY_IMAGE from tst.task import journal_invariants as journal pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -71,9 +72,9 @@ def local_stack_at(monkeypatch: pytest.MonkeyPatch, path: Path): reg_port = _free_port() reg_name = f"par-e2e-registry-{uuid.uuid4().hex[:8]}" - if _docker("run", "-d", "--rm", "--name", reg_name, - "-p", f"127.0.0.1:{reg_port}:5000", "registry:2").returncode != 0: - pytest.skip("could not start registry:2") + start = _docker("run", "-d", "--rm", "--name", reg_name, "-p", f"127.0.0.1:{reg_port}:5000", REGISTRY_IMAGE) + if start.returncode != 0: + pytest.skip(f"could not start registry:2: {start.stderr.strip()}") host = f"localhost:{reg_port}" if not _wait_ready(f"http://{host}/v2/", (200, 401)): _docker("rm", "-f", reg_name) diff --git a/tst/integration/store/local_registry_image_store_test.py b/tst/integration/store/local_registry_image_store_test.py index 0d765a03c..16b94261e 100644 --- a/tst/integration/store/local_registry_image_store_test.py +++ b/tst/integration/store/local_registry_image_store_test.py @@ -13,6 +13,7 @@ import pytest from agent_env.store import LocalRegistryImageStore +from tst.integration.utils.registry import REGISTRY_IMAGE from tst.store import image_conformance pytestmark = pytest.mark.integration @@ -31,7 +32,7 @@ def registry_host(): port = _free_port() name = f"imagestore-conf-registry-{uuid.uuid4().hex[:8]}" start = subprocess.run( - ["docker", "run", "-d", "--rm", "--name", name, "-p", f"127.0.0.1:{port}:5000", "registry:2"], + ["docker", "run", "-d", "--rm", "--name", name, "-p", f"127.0.0.1:{port}:5000", REGISTRY_IMAGE], capture_output=True, text=True, ) if start.returncode != 0: diff --git a/tst/integration/store/local_task_lifecycle_test.py b/tst/integration/store/local_task_lifecycle_test.py index fa299e601..09a40d3e7 100644 --- a/tst/integration/store/local_task_lifecycle_test.py +++ b/tst/integration/store/local_task_lifecycle_test.py @@ -41,6 +41,7 @@ from agent_env.store.image_store import LocalRegistryImageStore from agent_env.task import Task from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep +from tst.integration.utils.registry import REGISTRY_IMAGE pytestmark = pytest.mark.integration @@ -120,7 +121,7 @@ def _wait_ready(url: str, codes: tuple[int, ...], timeout: int = 45) -> bool: def all_local(monkeypatch, tmp_path): reg_port = _free_port() reg_name = f"local-e2e-registry-{uuid.uuid4().hex[:8]}" - start = _docker("run", "-d", "--rm", "--name", reg_name, "-p", f"127.0.0.1:{reg_port}:5000", "registry:2") + start = _docker("run", "-d", "--rm", "--name", reg_name, "-p", f"127.0.0.1:{reg_port}:5000", REGISTRY_IMAGE) if start.returncode != 0: pytest.skip(f"could not start registry:2: {start.stderr.strip()}") host = f"localhost:{reg_port}" diff --git a/tst/integration/store/oci_registry_image_store_test.py b/tst/integration/store/oci_registry_image_store_test.py index 1a8ce4c0a..4e81b1f89 100644 --- a/tst/integration/store/oci_registry_image_store_test.py +++ b/tst/integration/store/oci_registry_image_store_test.py @@ -19,6 +19,7 @@ OciRegistryImageStore, SecretStoreCredentials, ) +from tst.integration.utils.registry import REGISTRY_IMAGE from tst.store import image_conformance pytestmark = pytest.mark.integration @@ -70,7 +71,7 @@ def authenticated_registry_host(tmp_path_factory): "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm", "-e", "REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd", - "registry:2", + REGISTRY_IMAGE, ) if start.returncode != 0: pytest.fail(f"could not start authenticated registry:2: {start.stderr.strip()}") diff --git a/tst/integration/task_step/test_container_sandbox_files_local.py b/tst/integration/task_step/test_container_sandbox_files_local.py index 68b84054d..00cfd9fa6 100644 --- a/tst/integration/task_step/test_container_sandbox_files_local.py +++ b/tst/integration/task_step/test_container_sandbox_files_local.py @@ -28,6 +28,7 @@ from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep from agent_env.task_step.task_steps.load_artifact import LoadArtifactTaskStep from agent_env.task_step.task_steps.verifiers.verify_sandbox import VerifySandboxTaskStep +from tst.integration.utils.registry import REGISTRY_IMAGE from tst.util.a2a_test_agent import put_test_agent pytestmark = pytest.mark.integration @@ -53,7 +54,7 @@ def local_registry(monkeypatch, tmp_path): monkeypatch.setenv("AGENT_ENV_LOCAL_SANDBOX_DIR", str(sandboxes)) port = _free_port() name = f"files-local-registry-{uuid.uuid4().hex[:8]}" - started = _docker("run", "-d", "--rm", "--name", name, "-p", f"127.0.0.1:{port}:5000", "registry:2") + started = _docker("run", "-d", "--rm", "--name", name, "-p", f"127.0.0.1:{port}:5000", REGISTRY_IMAGE) assert started.returncode == 0, started.stderr host = f"localhost:{port}" deadline = time.time() + 45 diff --git a/tst/integration/task_step/test_prompt_agent_file_parts_local.py b/tst/integration/task_step/test_prompt_agent_file_parts_local.py index c8bdc57f0..bb2ff6e66 100644 --- a/tst/integration/task_step/test_prompt_agent_file_parts_local.py +++ b/tst/integration/task_step/test_prompt_agent_file_parts_local.py @@ -27,6 +27,7 @@ from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep from agent_env.task_step.task_steps.deploy_human_agent import DeployHumanAgentTaskStep from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep +from tst.integration.utils.registry import REGISTRY_IMAGE from tst.util.a2a_test_agent import AGENT_DIR, put_test_agent pytestmark = pytest.mark.integration @@ -56,7 +57,7 @@ def local_registry(monkeypatch, tmp_path): monkeypatch.setenv("AGENT_ENV_LOCAL_SANDBOX_DIR", str(sandboxes)) port = _free_port() name = f"file-parts-registry-{uuid.uuid4().hex[:8]}" - started = _docker("run", "-d", "--rm", "--name", name, "-p", f"127.0.0.1:{port}:5000", "registry:2") + started = _docker("run", "-d", "--rm", "--name", name, "-p", f"127.0.0.1:{port}:5000", REGISTRY_IMAGE) assert started.returncode == 0, started.stderr host = f"localhost:{port}" deadline = time.time() + REGISTRY_READY_SECONDS diff --git a/tst/integration/task_step/test_prompt_agent_live_trajectory_local.py b/tst/integration/task_step/test_prompt_agent_live_trajectory_local.py index b82bd60f0..8c1c1ccbf 100644 --- a/tst/integration/task_step/test_prompt_agent_live_trajectory_local.py +++ b/tst/integration/task_step/test_prompt_agent_live_trajectory_local.py @@ -22,6 +22,7 @@ from agent_env.task import Task from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep +from tst.integration.utils.registry import REGISTRY_IMAGE from tst.util.a2a_test_agent import put_test_agent pytestmark = pytest.mark.integration @@ -52,7 +53,7 @@ def local_registry(monkeypatch, tmp_path): monkeypatch.setenv("AGENT_ENV_LOCAL_SANDBOX_DIR", str(sandboxes)) port = _free_port() name = f"live-trajectory-registry-{uuid.uuid4().hex[:8]}" - started = _docker("run", "-d", "--rm", "--name", name, "-p", f"127.0.0.1:{port}:5000", "registry:2") + started = _docker("run", "-d", "--rm", "--name", name, "-p", f"127.0.0.1:{port}:5000", REGISTRY_IMAGE) assert started.returncode == 0, started.stderr host = f"localhost:{port}" deadline = time.time() + REGISTRY_READY_SECONDS diff --git a/tst/integration/task_step/test_run_code_named_sandbox_local.py b/tst/integration/task_step/test_run_code_named_sandbox_local.py index 7c8b98f89..ab6f13793 100644 --- a/tst/integration/task_step/test_run_code_named_sandbox_local.py +++ b/tst/integration/task_step/test_run_code_named_sandbox_local.py @@ -31,6 +31,7 @@ from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep from agent_env.task_step.task_steps.load_artifact import LoadArtifactTaskStep from agent_env.task_step.task_steps.run_code import RunCodeTaskStep +from tst.integration.utils.registry import REGISTRY_IMAGE pytestmark = pytest.mark.integration @@ -151,7 +152,7 @@ def _free_port() -> int: def local_registry(local_backends): port = _free_port() name = f"run-code-registry-{uuid.uuid4().hex[:8]}" - started = _docker("run", "-d", "--rm", "--name", name, "-p", f"127.0.0.1:{port}:5000", "registry:2") + started = _docker("run", "-d", "--rm", "--name", name, "-p", f"127.0.0.1:{port}:5000", REGISTRY_IMAGE) assert started.returncode == 0, started.stderr host = f"localhost:{port}" deadline = time.time() + 45 diff --git a/tst/integration/utils/registry.py b/tst/integration/utils/registry.py new file mode 100644 index 000000000..6ddf19de0 --- /dev/null +++ b/tst/integration/utils/registry.py @@ -0,0 +1,4 @@ +"""The registry image the integration tests' throwaway registries run: the mirror the local-backends workflow pulls before +the tests, so a test starts one without pulling from Docker Hub.""" + +REGISTRY_IMAGE = "mirror.gcr.io/library/registry:2" From 677c5d60b1f589e483f31e44f6ae1ee8bbf7ea15 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Fri, 30 Oct 2026 10:37:57 -0700 Subject: [PATCH 19/20] fix(computers): a restarted desktop clears the X lock a killed X left docker restart keeps the container's files, so an X killed before it removed /tmp/.X99-lock leaves it behind. Pids start again from 1, so the pid it names is often taken; X then refuses to start and supervisord gives up on it, while the driver keeps answering not ready. start-desktop now removes the lock before starting X, since supervisord runs one X at a time. A base test plants a lock naming pid 1 and hard-restarts; the restart chaos test's timeout names the checks that failed. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 357a25c81b9ae961ecabbeaea34ec6bcb2a10774) --- .../computers/artifacts/ubuntu-24.04/start-desktop | 3 +++ tst/integration/computer/base_computer_test.py | 14 ++++++++++++++ tst/integration/computer/recording_chaos_test.py | 8 ++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop b/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop index d3dbf99f9..208dbe570 100755 --- a/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop @@ -15,6 +15,9 @@ case "${1:-}" in exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf ;; xvfb) + # supervisord runs one X at a time, so a lock here is one a killed X left, and X refuses to start while the pid + # it names is taken, which after a restart it often is. + rm -f "/tmp/.X${DISPLAY#:}-lock" exec Xvfb "$DISPLAY" -screen 0 "${resolution}x24" -nolisten tcp -ac ;; with-x) diff --git a/tst/integration/computer/base_computer_test.py b/tst/integration/computer/base_computer_test.py index 3920ea10e..4d3d3f83b 100644 --- a/tst/integration/computer/base_computer_test.py +++ b/tst/integration/computer/base_computer_test.py @@ -84,6 +84,20 @@ def test_the_desktop_comes_back_however_often_x_dies(base): assert "window id #" in wm +def test_the_desktop_comes_back_from_a_restart_that_killed_x_with_its_lock_naming_a_running_pid(base): + """A restart gives pids out again, so the lock a killed X left can name a running process; here tini, pid 1.""" + name = f"agentenv-test-base-{uuid.uuid4().hex[:8]}" + docker("run", "-d", "--name", name, base) + try: + _all_running(name) + _exec(name, "sh", "-c", 'printf "%10d\\n" 1 > /tmp/lock && chmod 444 /tmp/lock && mv -f /tmp/lock /tmp/.X99-lock') + docker("restart", "-t", "0", name) + running = _all_running(name) + finally: + subprocess.run(["docker", "rm", "-f", name], capture_output=True) + assert all(" RUNNING " in line for line in running.splitlines()), running + + @pytest.mark.parametrize("setting, value, error", [ ("RESOLUTION", "big", "resolution is x, each 1 to 32767 pixels, not 'big'"), ("RESOLUTION", "40000x800", "resolution is x, each 1 to 32767 pixels, not '40000x800'"), diff --git a/tst/integration/computer/recording_chaos_test.py b/tst/integration/computer/recording_chaos_test.py index a2ffb8583..6fe83a82e 100644 --- a/tst/integration/computer/recording_chaos_test.py +++ b/tst/integration/computer/recording_chaos_test.py @@ -128,17 +128,21 @@ async def _ready(name: str) -> Driver: """A client for the container's driver once its computer is ready, within the time a deploy gives one; a restart may move its port.""" deadline = time.monotonic() + READY_TIMEOUT_S + status = None while time.monotonic() < deadline: port = subprocess.run(["docker", "port", name, str(PORT)], capture_output=True, text=True).stdout if port: try: driver = await Driver.connect(f"http://{port.splitlines()[0]}", 30) - if (await driver.invoke(COMPUTER_READY, "status"))["ready"]: + status = await driver.invoke(COMPUTER_READY, "status") + if status["ready"]: return driver except Exception: # not serving yet pass await asyncio.sleep(0.25) - raise TimeoutError(f"{name}'s computer wasn't ready within {READY_TIMEOUT_S:.0f} s") + failing = [check for check in (status or {}).get("checks", []) if not check["ok"]] + raise TimeoutError(f"{name}'s computer wasn't ready within {READY_TIMEOUT_S:.0f} s: " + f"{failing or 'its driver never answered'}") async def _layout(driver: Driver) -> dict: From 56ba1e2295fe2b2bce8dcab94a55c6a9e5e0a853 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 5 Nov 2026 22:56:08 -0800 Subject: [PATCH 20/20] fix(cli): plugin list, show and check write their report to the stdout they started with (FD-4683) plugin_report_test failed now and then with an empty CliRunner stdout while the --json report sat in pytest's own captured stdout: sys.stdout had been replaced while the command ran, between CliRunner swapping it and the report being echoed. A plugin that replaces sys.stdout while it is imported does the same to a real run's --json. Each command now takes its stdout when it starts and writes everything there. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/cli/plugin.py | 26 +++++++++++++++++--------- tst/unit/cli/plugin_report_test.py | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/agent_env/cli/plugin.py b/src/agent_env/cli/plugin.py index 7f4f2b3f7..11dcd181c 100644 --- a/src/agent_env/cli/plugin.py +++ b/src/agent_env/cli/plugin.py @@ -10,7 +10,7 @@ from contextlib import contextmanager, redirect_stdout from importlib.metadata import PackageNotFoundError, distribution, packages_distributions from pathlib import Path -from typing import Optional +from typing import Optional, TextIO import click @@ -121,6 +121,11 @@ def _quiet() -> Iterator[None]: logger.setLevel(previous) +def _stdout() -> TextIO: + """The stdout a command started with, which its report goes to though a plugin it imports replaces sys.stdout.""" + return click.get_text_stream("stdout") + + def collect(root: click.Group, *, load: bool) -> Report: # A plugin that prints while it is imported must not corrupt `--json` on stdout. with _quiet(), redirect_stdout(sys.stderr): @@ -334,8 +339,9 @@ def plugin(): @click.option("--no-load", is_flag=True, help="Read installed metadata only; import no type plugin.") def list_command(as_json: bool, no_load: bool): """List every installed plugin package, what it provides, and whether it took effect.""" + out = _stdout() report = collect(_root(), load=not no_load) - click.echo(_json(_as_dict(report)) if as_json else render_list(report)) + click.echo(_json(_as_dict(report)) if as_json else render_list(report), file=out) @plugin.command() @@ -344,6 +350,7 @@ def list_command(as_json: bool, no_load: bool): @click.option("--no-load", is_flag=True, help="Read installed metadata only; import no type plugin.") def show(package: str, as_json: bool, no_load: bool): """Show one plugin PACKAGE: each contribution, its status and why.""" + out = _stdout() report = collect(_root(), load=not no_load) matches = [d for d in report.distributions if _normalized(d.name) == _normalized(package)] if not matches: @@ -352,9 +359,9 @@ def show(package: str, as_json: bool, no_load: bool): dist = matches[0] effect = None if no_load else config_effect(dist.name) if as_json: - click.echo(_json({**_as_dict(report), "plugins": [dataclasses.asdict(dist)], "config_effect": effect})) + click.echo(_json({**_as_dict(report), "plugins": [dataclasses.asdict(dist)], "config_effect": effect}), file=out) return - click.echo(render_show(report, dist, effect)) + click.echo(render_show(report, dist, effect), file=out) @plugin.command() @@ -366,6 +373,7 @@ def check(ctx: click.Context, as_json: bool): A replaced contribution is not a failure: config chose it. Meant for CI and image builds. """ + out = _stdout() report = collect(_root(), load=True) problems = [(d, c) for d in report.distributions for c in d.contributions if c.status in _CHECK_FAILS] error = report.inventory.config_error @@ -380,22 +388,22 @@ def check(ctx: click.Context, as_json: bool): **_as_dict(report), "ok": not reasons, "problems": [{"package": d.name, "version": d.version, **dataclasses.asdict(c)} for d, c in problems], - })) + }), file=out) if reasons: ctx.exit(1) return for d, c in problems: - click.echo(f"{d.name} {d.version}: {_KINDS[c.group][0]} {c.name}: {_outcome(c)}") + click.echo(f"{d.name} {d.version}: {_KINDS[c.group][0]} {c.name}: {_outcome(c)}", file=out) if error: - click.echo(_coded(f"config: {error.reason}", error.code)) + click.echo(_coded(f"config: {error.reason}", error.code), file=out) for line in _unreadable(report): - click.echo(line) + click.echo(line, file=out) if reasons: raise click.ClickException("; ".join(reasons)) total = sum(len(d.contributions) for d in report.distributions) replaced = sum(c.status == "replaced" for d in report.distributions for c in d.contributions) note = f"; {replaced} replaced by config" if replaced else ", all in effect" - click.echo(f"ok: {len(report.distributions)} plugin package(s), {total} contribution(s){note}") + click.echo(f"ok: {len(report.distributions)} plugin package(s), {total} contribution(s){note}", file=out) @plugin.command() diff --git a/tst/unit/cli/plugin_report_test.py b/tst/unit/cli/plugin_report_test.py index 5d5b74dff..231886ace 100644 --- a/tst/unit/cli/plugin_report_test.py +++ b/tst/unit/cli/plugin_report_test.py @@ -10,6 +10,8 @@ """ import ast +import importlib +import io import json import os import re @@ -32,6 +34,7 @@ from tst.unit.plugins_test import _EP, _HERE, _Dist, _fresh, _install, _use_config # noqa: F401 (_fresh: autouse) _THIS = "tst.unit.cli.plugin_report_test" +_PLUGIN_CLI = importlib.import_module("agent_env.cli.plugin") _GOLDEN = Path(__file__).parent / "golden" / "plugin_report" _REGENERATE = os.environ.get("AGENT_ENV_REGENERATE_GOLDENS") == "1" _COMMANDS = cli_plugins_mod.CLI_PLUGINS_GROUP @@ -205,6 +208,18 @@ def _report(case: str, monkeypatch, tmp_path) -> dict: return json.loads(result.stdout) + +@pytest.mark.parametrize("case", ["list", "show", "check"]) +def test_the_report_reaches_the_commands_stdout_though_a_plugin_replaces_sys_stdout(case, monkeypatch, tmp_path): + real = _PLUGIN_CLI.environment + + def replacing(*args): + sys.stdout = io.StringIO() + return real(*args) + + monkeypatch.setattr(_PLUGIN_CLI, "environment", replacing) + assert _report(case, monkeypatch, tmp_path)["format_version"] == FORMAT_VERSION + def _normalized(node, tmp_path: Path): """``node`` with machine-specific values and free text replaced by placeholders.""" if isinstance(node, dict):