From 190bfd1e164cc04b6543d5306b2a95638c74387d Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 16:21:06 -0800 Subject: [PATCH] feat(website): a session cookie per site, and conformance levels (FD-4683) - Session cookie per site: agentenv_session_, the name the card gives (a name a cookie name can't hold is spelled safely, with a hash), named on the card, so two sites a browser reaches under one gateway host (/website/a/, /website/b/) don't overwrite each other's sign-in. A site still reads the shared agentenv_session for one release. - Conformance: required vs recommended. What website/v1 requires of any server fails a run: any host, readiness, no page at the reserved paths (new), and the data-plane round trip when the card advertises one. The card's details, X-Universe-Now, the clock, forwarded headers and cookies (including the session path setting the card's cookie) are warned; --strict fails on a warning. Lifted onto main from the follow-ups branch. The clock-rate change is left out (main's site clock reads the gateway clock per request, so it no longer extrapolates), and models= is held for a design note (it read and wrote through the store main removed). Co-Authored-By: Claude Opus 5.5 --- .../examples/website/notes/README.md | 14 +-- .../agentenv_protocol/website/conformance.py | 87 ++++++++++++++----- .../agentenv_protocol/website/environment.py | 13 ++- .../src/agentenv_protocol/website/routes.py | 5 +- .../src/agentenv_protocol/website/session.py | 34 +++++++- .../tests/test_website_conformance.py | 87 +++++++++++++++---- .../tests/test_website_environment.py | 50 +++++++++-- .../tests/test_website_notes_example.py | 8 +- 8 files changed, 236 insertions(+), 62 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index 888054126..a4358d10e 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -22,9 +22,11 @@ them. The page shows the universe's time. 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 - 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. +- **Users.** `ApiContext.user` is the user in the signed session cookie, `agentenv_session_notes` (the site's own, so + it doesn't collide with another site's under the same gateway host; the card names it). With no cookie, it is the + acting user (the 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 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. @@ -65,11 +67,13 @@ the test above runs this repo's. ```bash docker build -t notes-site . docker run -d --name notes-site -p 18080:8000 notes-site -python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal +python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal --strict curl -s http://localhost:18080/api/notes ``` -Use `--clock-host` so the container can reach the checker's clock. The clock check leaves the server following a +Use `--clock-host` so the container can reach the checker's clock. `--strict` fails on a recommended check's warning +too (the card, `X-Universe-Now`, the clock, forwarded headers, cookies); without it only the contract's required checks +fail the run, and Notes passes every check either way. The clock check leaves the server following a clock that is gone once the check ends: later timestamps start from 2031 until the server is synced again, so restart the container before using it. diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py index 9f6c7debc..1ccd9ff4f 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -1,13 +1,18 @@ -"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in: -``python -m agentenv_protocol.website.conformance `` prints each check and exits 1 when one failed (a warning -doesn't), 2 when the server couldn't be reached. Everything goes over HTTP: the card, ``/`` under a made-up host, readiness by the card's -``website/v1`` ``ready``, whose answer must be a ``WebsiteReadiness`` (by the declared health path, deprecated, for a -card without it), ``X-Universe-Now`` on every answer the other checks got, a data-plane round trip at the card's ``url`` -(data/get, data/reset, data/get, which no longer returns the exported data, data/add of what the first data/get -returned, data/get again), ``clock/v1``'s -``sync_time`` against a clock the checker serves, and the forwarded headers wherever an answer shows them, on ``/`` and -on a GET route with its trailing slash toggled, which a server may redirect, and the cookies those answers set: -none tied to a domain, and a warning for one whose lifetime is an absolute ``Expires`` with no ``Max-Age``. +"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in: ``python -m +agentenv_protocol.website.conformance `` prints each check and exits 1 when a required one failed (or, with +``--strict``, when a recommended one warned), 2 when the server couldn't be reached. Everything goes over HTTP. + +The required checks are the contract, what the gateway and a browser rely on of any server: ``/`` serves HTML under a +made-up host; the site is ready, by the card's ``website/v1`` ``ready``, whose answer must be a ``WebsiteReadiness`` +(by the declared health path, deprecated, for a card without it); the data plane's and card's paths are not the site's +pages; and, when the card advertises the data plane, a round trip at the card's ``url`` (data/get, data/reset, +data/get, which no longer returns the exported data, data/add of what the first data/get returned, data/get again) +returns what it was given. A failed one is ``failed``. The recommended checks are what an SDK site does and the gateway +uses when a server offers it: a card declaring the site's domains, readiness and routes, ``X-Universe-Now`` on every +answer the other checks got, ``clock/v1``'s ``sync_time`` against a clock the checker serves, the forwarded headers +wherever an answer shows them (on ``/`` and on a GET route with its trailing slash toggled, which a server may +redirect), and the cookies those answers set: none tied to a domain, the session path's the one the card names, and +none whose lifetime is an absolute ``Expires`` with no ``Max-Age``. A failed one is ``warned``. 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 @@ -40,18 +45,27 @@ Status = Literal["passed", "failed", "skipped", "warned"] CLOCK_START = datetime.datetime(2031, 5, 6, 7, 8, 9, tzinfo=datetime.timezone.utc) CLOCK_SLACK_S = 120 +REQUIRED = ("any_host", "health", "reserved_paths", "data_plane") @dataclasses.dataclass(frozen=True) class Result: + """One check's outcome; ``required`` for a check of the contract, whose failure is ``failed``, and not for a + recommended one, whose failure is ``warned``.""" + name: str status: Status detail: str = "" + required: bool = True @property def passed(self) -> bool: return self.status != "failed" + def fails(self, strict: bool = False) -> bool: + """Whether the result fails the run: a failed check, or with ``strict`` a warned one too.""" + return self.status == "failed" or (strict and self.status == "warned") + def check(base_url: str, *, client: httpx.Client | None = None, clock_host: str = "127.0.0.1", timeout: float = 30) -> list[Result]: @@ -92,19 +106,26 @@ def run(self) -> list[Result]: card = response.json() if response.status_code == 200 else {} except ValueError: card = {} + card = card if isinstance(card, dict) else {} site = (find_extension(card, WEBSITE_EXTENSION_URI) or {}).get("params") or {} health = site.get("health") if isinstance(site.get("health"), str) else DEFAULT_HEALTH_PATH - steps = (("any_host", self._any_host), ("health", lambda: self._health(card, health)), - ("universe_now", self._universe_now), ("data_plane", lambda: self._data_plane(card)), + steps = (("card", lambda: self._card(response, card, site)), ("any_host", self._any_host), + ("health", lambda: self._health(card, health)), ("universe_now", self._universe_now), + ("reserved_paths", self._reserved_paths), ("data_plane", lambda: self._data_plane(card)), ("clock", lambda: self._clock(card)), ("forwarded", lambda: self._forwarded(site)), ("cookies", lambda: self._cookies(site))) outcomes = {name: _outcome(step) for name, step in steps if name != "universe_now"} outcomes["universe_now"] = _outcome(self._universe_now) - return [self._card(response, card, site), *(Result(name, *outcomes[name]) for name, _step in steps)] - - def _card(self, response: httpx.Response, card: dict, site: dict) -> Result: + results = [] + for name, _step in steps: + status, detail = outcomes[name] + required = name in REQUIRED + results.append(Result(name, "warned" if status == "failed" and not required else status, detail, required)) + return results + + def _card(self, response: httpx.Response, card: dict, site: dict) -> tuple[Status, str]: if response.status_code != 200: - return Result("card", "failed", f"{WELL_KNOWN_PATH} answered {response.status_code}") + return "failed", f"{WELL_KNOWN_PATH} answered {response.status_code}" problems = [] if not site: problems.append(f"declares no {WEBSITE_EXTENSION_URI} with params") @@ -117,7 +138,7 @@ def _card(self, response: httpx.Response, card: dict, site: dict) -> Result: if not isinstance(routes, list) or not all(isinstance(r, dict) and {"method", "path", "name"} <= set(r) for r in routes): problems.append("its params' routes aren't a list of {method, path, name}") - return Result("card", "failed" if problems else "passed", "; ".join(problems)) + return ("failed" if problems else "passed"), "; ".join(problems) def _any_host(self) -> tuple[Status, str]: host = f"conformance-{uuid.uuid4().hex[:8]}.invalid" @@ -159,6 +180,15 @@ def _universe_now(self) -> tuple[Status, str]: parse_time(response.headers[UNIVERSE_NOW_HEADER]) return "passed", f"on all {len(self.responses)} answers" + def _reserved_paths(self) -> tuple[Status, str]: + paths = [RPC_PATH, WELL_KNOWN_PATH, f"{RPC_PATH}/conformance-{uuid.uuid4().hex[:8]}", f"{RPC_PATH}/", + f"{WELL_KNOWN_PATH}/"] + for path in paths: + response = self.get(path) + if response.is_success and response.headers.get("content-type", "").startswith("text/html"): + return "failed", f"{path} answered a page, but the gateway keeps {RPC_PATH} and {WELL_KNOWN_PATH}" + return "passed", f"no page at {', '.join(paths)}" + def _rpc(self, method: str, params: dict | None = None) -> dict: response = self._seen(self.client.post(self.rpc_url, json={"jsonrpc": "2.0", "id": method, "method": method, "params": params or {}})) @@ -171,7 +201,10 @@ def _rpc(self, method: str, params: dict | None = None) -> dict: def _data_plane(self, card: dict) -> tuple[Status, str]: operations = (card.get("capabilities") or {}).get("operations") - missing = [m for m in (METHOD_GET, METHOD_RESET, METHOD_ADD) if operations is not None and m not in operations] + operations = operations if isinstance(operations, list) else [] + missing = [m for m in (METHOD_GET, METHOD_RESET, METHOD_ADD) if m not in operations] + if len(missing) == 3: + return "skipped", "the card advertises no data plane" if missing: return "failed", f"the card's operations lack {', '.join(missing)}" url = card.get("url") @@ -238,14 +271,19 @@ def _forwarded(self, site: dict) -> tuple[Status, str]: return "passed", f"no answer to {', '.join(paths)} named the internal Host" def _cookies(self, site: dict) -> tuple[Status, str]: + session = [] if isinstance(site.get("session_path"), str): - self.get(site["session_path"]) + session = self.get(site["session_path"]).headers.get_list("set-cookie") cookies = [c for r in self.responses for c in r.headers.get_list("set-cookie")] if not cookies: return "skipped", "no answer set a cookie" scoped = sorted({c.split("=", 1)[0] for c in cookies if "domain" in _attributes(c)}) if scoped: return "failed", f"cookies tied to a domain: {', '.join(scoped)}" + named = site.get("session_cookie") + if isinstance(named, str) and session and named not in {c.split("=", 1)[0].strip() for c in session}: + return "failed", (f"{site['session_path']} set {', '.join(c.split('=', 1)[0] for c in session)}, not the " + f"card's {named}") absolute = sorted({c.split("=", 1)[0] for c in cookies if "expires" in _attributes(c) and "max-age" not in _attributes(c)}) if absolute: return "warned", (f"cookies with an absolute Expires and no Max-Age, which a browser on another clock " @@ -341,6 +379,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("url", help="where the server answers, such as http://localhost:8000") parser.add_argument("--clock-host", default="127.0.0.1", help="the address the server reaches this checker at") parser.add_argument("--timeout", type=float, default=30, help="seconds for each request") + parser.add_argument("--strict", action="store_true", help="fail on a recommended check's warning too") args = parser.parse_args(argv) try: results = check(args.url, clock_host=args.clock_host, timeout=args.timeout) @@ -348,10 +387,12 @@ def main(argv: list[str] | None = None) -> int: print(f"couldn't reach {args.url}: {type(e).__name__}: {e}", file=sys.stderr) return 2 for result in results: - print(f"{_MARK[result.status]} {result.name}{': ' + result.detail if result.detail else ''}") - failed = sum(not r.passed for r in results) - print(f"{args.url}: {len(results) - failed} passed, warned or skipped, {failed} failed") - return 1 if failed else 0 + kind = "required" if result.required else "recommended" + print(f"{_MARK[result.status]} {result.name} ({kind}){': ' + result.detail if result.detail else ''}") + counts = {status: sum(r.status == status for r in results) for status in _MARK} + print(f"{args.url}: {counts['passed']} passed, {counts['failed']} failed, {counts['warned']} warned, " + f"{counts['skipped']} skipped{' (strict)' if args.strict else ''}") + return 1 if any(r.fails(args.strict) for r in results) else 0 if __name__ == "__main__": diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py index 9c4db1ad3..ba7acadee 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py @@ -42,7 +42,7 @@ from .readiness import SITE_READY_TIMEOUT_S, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness from .routes import (ApiContext, ApiRoute, endpoint, error_response, forwarded_prefix, openapi, openapi_path, request_role, reserved_path, routes_of) -from .session import SESSION_COOKIE, SessionSigner +from .session import SessionSigner, session_cookie_name SESSION_PATH = "/api/session" CHECK_TIMEOUT_S = 10.0 @@ -114,7 +114,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None: def __init__(self) -> None: self.clock = UniverseClock() - self._signer = SessionSigner() + self._signer = SessionSigner(cookie_name=session_cookie_name(self._build_card().name)) self._started = time.monotonic() self._checking: dict[str, asyncio.Future] = {} @@ -126,6 +126,11 @@ def declaration(cls) -> WebsiteDeclaration: def routes(cls) -> list[ApiRoute]: return routes_of(cls) + @property + def session_cookie(self) -> str: + """The name of the site's session cookie, ``agentenv_session_``.""" + return self._session_signer().cookie_name + @property def database_url(self) -> str | None: """Where the site keeps its task state: its own schema of the env's database.""" @@ -148,7 +153,7 @@ def acting_user(self) -> str | None: def context(self, request: Request) -> ApiContext: """The ``ApiContext`` of ``request``, for a mounted ``app``'s routes as for ``@api`` ones.""" - session = self._session_signer().decode(request.cookies.get(SESSION_COOKIE)) + session = self._session_signer().read(request.cookies) user = session["user"] if session is not None else self.acting_user() return ApiContext(request, user, request_role(request), session, self._session_signer()) @@ -186,7 +191,7 @@ def extension_params(self, uri: str) -> dict: if uri == WEBSITE_EXTENSION_URI: declaration = self.declaration() return {"domains": list(declaration.domains), - "routes": [r.advertised() for r in self.routes()], "session_cookie": SESSION_COOKIE, + "routes": [r.advertised() for r in self.routes()], "session_cookie": self.session_cookie, "session_path": SESSION_PATH} return super().extension_params(uri) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py b/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py index 66f5728f2..b13037a5e 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py @@ -28,7 +28,7 @@ from ..tool_context import DEFAULT_ROLE, caller_from_request from ..types import RPC_PATH, WELL_KNOWN_PATH from .errors import ApiError, BadRequest, Forbidden -from .session import SESSION_COOKIE, SESSION_TTL_S, SessionSigner +from .session import SESSION_TTL_S, SessionSigner logger = logging.getLogger(__name__) @@ -154,7 +154,8 @@ def _set_session(self, user: str | None) -> None: def apply(self, response: Response) -> Response: for value in self._set_cookie: - response.set_cookie(SESSION_COOKIE, value, max_age=SESSION_TTL_S, path="/", httponly=True, samesite="lax") + response.set_cookie(self._signer.cookie_name, value, max_age=SESSION_TTL_S, path="/", httponly=True, + samesite="lax") return response diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/session.py b/packages/agentenv-protocol/src/agentenv_protocol/website/session.py index dd068046a..3d2f05916 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/session.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/session.py @@ -2,7 +2,9 @@ under ``WEBSITE_SESSION_SECRET``, which a deploy sets for each site so its sessions outlive its process, or a secret of the process's own when that isn't set. The cookie names no domain, so it holds under whatever host the site is reached at, and its lifetime is a ``Max-Age``, never an absolute ``Expires``, so the browser's clock and the site's -needn't agree.""" +needn't agree. Its name is the site's own, ``agentenv_session_``, so two sites a browser reaches under one gateway +host (``/website/a/``, ``/website/b/``) keep a session each; a site still reads the shared ``agentenv_session`` of +older sites, for one release.""" from __future__ import annotations import base64 @@ -11,6 +13,7 @@ import hmac import json import os +import re import secrets import time @@ -18,12 +21,30 @@ SESSION_SECRET_ENV = "WEBSITE_SESSION_SECRET" SESSION_TTL_S = 7 * 24 * 3600 SECRET_BYTES = 32 +_NOT_TOKEN = re.compile(r"[^A-Za-z0-9_.-]") + + +def _cookie_suffix(environment_name: str) -> str: + """The part of a site's cookie names that is its own: the name itself when a cookie name can hold each of its + characters, else the name with each such character made ``_`` and ``~`` and a hash of the whole name appended. No + name kept as itself holds a ``~``, so two distinct names never make one suffix.""" + if not _NOT_TOKEN.search(environment_name): + return environment_name + digest = hashlib.sha256(environment_name.encode("utf-8", "surrogatepass")).hexdigest()[:12] + return f"{_NOT_TOKEN.sub('_', environment_name)}~{digest}" + + +def session_cookie_name(environment_name: str | None) -> str: + """The session cookie of the site named ``environment_name``: ``agentenv_session_``, a name a cookie name + can't hold spelled as ``_cookie_suffix`` spells it; the shared ``agentenv_session`` for a site with no name.""" + return f"{SESSION_COOKIE}_{_cookie_suffix(environment_name)}" if environment_name else SESSION_COOKIE class SessionSigner: - def __init__(self, secret: str | bytes | None = None) -> None: + def __init__(self, secret: str | bytes | None = None, cookie_name: str = SESSION_COOKIE) -> None: secret = secret if secret is not None else os.environ.get(SESSION_SECRET_ENV) self._key = (secret.encode() if isinstance(secret, str) else secret) or secrets.token_bytes(SECRET_BYTES) + self.cookie_name = cookie_name def encode(self, user: str | None, ttl_s: int = SESSION_TTL_S) -> str: """The cookie value for ``user`` signed in, or signed out when None, good for ``ttl_s`` seconds.""" @@ -45,10 +66,17 @@ def decode(self, value: str | None) -> dict | None: return None return {"user": data.get("u")} + def read(self, cookies: dict) -> dict | None: + """The session of a request's ``cookies``: its own cookie's, else the shared legacy cookie's.""" + session = self.decode(cookies.get(self.cookie_name)) + if session is None and self.cookie_name != SESSION_COOKIE: + session = self.decode(cookies.get(SESSION_COOKIE)) + return session + def cookie(self, user: str | None, ttl_s: int = SESSION_TTL_S) -> dict: """The cookie a browser adds to be signed in as ``user``, in browser/v1's shape with ``max_age`` for its lifetime; its domain is left to whoever adds it.""" - return {"name": SESSION_COOKIE, "value": self.encode(user, ttl_s), "path": "/", "max_age": ttl_s, + return {"name": self.cookie_name, "value": self.encode(user, ttl_s), "path": "/", "max_age": ttl_s, "httpOnly": True, "sameSite": "Lax"} def _sign(self, payload: str) -> str: diff --git a/packages/agentenv-protocol/tests/test_website_conformance.py b/packages/agentenv-protocol/tests/test_website_conformance.py index c475846f0..509154964 100644 --- a/packages/agentenv-protocol/tests/test_website_conformance.py +++ b/packages/agentenv-protocol/tests/test_website_conformance.py @@ -7,6 +7,7 @@ import json from pathlib import Path +import httpx import pytest from starlette.applications import Starlette from starlette.requests import Request @@ -19,7 +20,7 @@ from agentenv_protocol.website import conformance from agentenv_protocol.website.clock import UNIVERSE_NOW_HEADER, iso -CHECKS = ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"] +CHECKS = ["card", "any_host", "health", "universe_now", "reserved_paths", "data_plane", "clock", "forwarded", "cookies"] def statuses(results: list[conformance.Result]) -> dict[str, str]: @@ -158,10 +159,14 @@ async def sync_time(request: Request) -> Response: state["at"] = datetime.datetime.fromisoformat(virtual.replace("Z", "+00:00")) return stamp(JSONResponse({"gateway_virtual_time": virtual})) + async def unrouted(request: Request, exc: Exception) -> Response: + return stamp(JSONResponse({"error": "not found"}, status_code=getattr(exc, "status_code", 404))) + app = Starlette(routes=[Route("/", page), Route("/healthz", health), Route("/ready", ready), Route("/items", items), Route("/items/", items_slash), Route("/.well-known/agent-env.json", card), Route(rpc_path or "/agentenv", rpc, methods=["POST"]), - Route("/agentenv/ext/sync_time", sync_time, methods=["POST"])]) + Route("/agentenv/ext/sync_time", sync_time, methods=["POST"])], + exception_handlers={404: unrouted, 405: unrouted}) app.state.data = state return app @@ -207,7 +212,7 @@ def test_an_answer_to_ready_that_isnt_a_website_readiness_fails_health_saying_wh def test_a_server_breaking_the_contract_fails_those_checks() -> None: results = statuses(conformance.check("http://testserver", client=TestClient(_handwritten(leak_host=True, domain_cookie=True, clock=False)))) - assert results["forwarded"] == results["cookies"] == results["clock"] == "failed" + assert results["forwarded"] == results["cookies"] == results["clock"] == "warned" assert results["card"] == results["health"] == results["data_plane"] == "passed" @@ -221,13 +226,13 @@ def test_the_forwarded_check_follows_no_redirect_and_reads_its_location(sdk_site assert forwarded.status == "passed" and "/api/notes/" in forwarded.detail leaked = {r.name: r for r in conformance.check("http://testserver", client=TestClient(_handwritten(leak_redirect=True)))}["forwarded"] - assert leaked.status == "failed" and "Location of /items/" in leaked.detail + assert leaked.status == "warned" and "Location of /items/" in leaked.detail def test_universe_now_is_checked_on_the_answers_of_every_check() -> None: result = {r.name: r for r in conformance.check("http://testserver", client=TestClient(_handwritten(stamp_rpc=False)))}["universe_now"] - assert result.status == "failed" and "/agentenv" in result.detail + assert result.status == "warned" and "/agentenv" in result.detail def test_a_reset_that_clears_nothing_fails_the_data_plane_and_leaves_the_data() -> None: @@ -308,24 +313,74 @@ def test_the_clock_check_calls_sync_time_whichever_operation_the_card_lists_firs assert results["clock"] == "passed", results -def test_a_server_with_no_card_health_or_header_fails_them() -> None: +def test_a_server_with_no_card_health_or_header_fails_the_contract_and_warns_of_the_rest() -> None: bare = Starlette(routes=[Route("/", lambda request: JSONResponse({"not": "html"}))]) - results = statuses(conformance.check("http://testserver", client=TestClient(bare))) - assert results["card"] == results["any_host"] == results["health"] == results["universe_now"] == "failed" - assert results["data_plane"] == results["clock"] == "failed" - assert results["cookies"] == "skipped" + results = conformance.check("http://testserver", client=TestClient(bare)) + assert statuses(results) == {"card": "warned", "any_host": "failed", "health": "failed", "universe_now": "warned", + "reserved_paths": "passed", "data_plane": "skipped", "clock": "warned", + "forwarded": "passed", "cookies": "skipped"} + assert [r.name for r in results if r.required] == ["any_host", "health", "reserved_paths", "data_plane"] + + +def test_a_bare_server_meeting_only_the_contract_passes_with_warnings() -> None: + async def page(request: Request) -> Response: + return HTMLResponse("hi") + + bare = Starlette(routes=[Route("/", page), Route("/api/health", lambda request: JSONResponse({"ok": True}))]) + results = conformance.check("http://testserver", client=TestClient(bare)) + assert not any(r.fails() for r in results) and any(r.fails(strict=True) for r in results) + assert {r.name for r in results if r.status == "warned"} == {"card", "universe_now", "clock"} + + +def test_a_spa_fallback_that_serves_the_reserved_paths_fails() -> None: + async def page(request: Request) -> Response: + return HTMLResponse("app") + + spa = Starlette(routes=[Route("/{path:path}", page)]) + reserved = {r.name: r for r in conformance.check("http://testserver", client=TestClient(spa))}["reserved_paths"] + assert reserved.status == "failed" and reserved.detail.startswith("/agentenv answered a page") + + +@pytest.mark.parametrize("exact", ["/agentenv", "/.well-known/agent-env.json"]) +def test_a_page_at_a_reserved_path_itself_fails(exact: str) -> None: + async def page(request: Request) -> Response: + return HTMLResponse("app") + + server = Starlette(routes=[Route("/", page), Route(exact, page)]) + reserved = {r.name: r for r in conformance.check("http://testserver", client=TestClient(server))}["reserved_paths"] + assert reserved.status == "failed" and reserved.detail.startswith(f"{exact} answered a page") def test_main_prints_each_check_and_exits_by_outcome(monkeypatch, capsys) -> None: - monkeypatch.setattr(conformance, "check", lambda url, **options: [conformance.Result("card", "passed"), - conformance.Result("cookies", "warned", "dated"), - conformance.Result("clock", "failed", "late")]) - assert conformance.main(["http://site"]) == 1 + results = [conformance.Result("health", "passed"), conformance.Result("clock", "warned", "late", required=False)] + monkeypatch.setattr(conformance, "check", lambda url, **options: results) + assert conformance.main(["http://site"]) == 0 printed = capsys.readouterr().out - assert "PASS card" in printed and "WARN cookies: dated" in printed and "FAIL clock: late" in printed - assert "2 passed, warned or skipped, 1 failed" in printed + assert "PASS health (required)" in printed and "WARN clock (recommended): late" in printed + assert "1 passed, 0 failed, 1 warned, 0 skipped" in printed + assert conformance.main(["http://site", "--strict"]) == 1 + results.append(conformance.Result("any_host", "failed", "json")) + assert conformance.main(["http://site"]) == 1 + assert "FAIL any_host (required): json" in capsys.readouterr().out def test_main_exits_2_when_the_server_cannot_be_reached(capsys) -> None: assert conformance.main(["http://127.0.0.1:9", "--timeout", "2"]) == 2 assert "couldn't reach" in capsys.readouterr().err + + +def test_the_cookie_check_holds_the_session_path_to_the_cookie_the_card_names(sdk_site: TestClient) -> None: + card = sdk_site.get("/.well-known/agent-env.json").json() + for extension in card["capabilities"]["extensions"]: + if extension["uri"] == "urn:agentenv:website/v1": + extension["params"]["session_cookie"] = "agentenv_session_other" + real = sdk_site.get + + def get(url, **kwargs): + if url.endswith("/.well-known/agent-env.json"): + return httpx.Response(200, json=card, request=httpx.Request("GET", url)) + return real(url, **kwargs) + + sdk_site.get = get + cookies = {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["cookies"] + assert cookies.status == "warned" and "not the card's agentenv_session_other" in cookies.detail diff --git a/packages/agentenv-protocol/tests/test_website_environment.py b/packages/agentenv-protocol/tests/test_website_environment.py index 0eda64c1d..2f7739d4b 100644 --- a/packages/agentenv-protocol/tests/test_website_environment.py +++ b/packages/agentenv-protocol/tests/test_website_environment.py @@ -42,6 +42,7 @@ from agentenv_protocol.website.clock import parse_time RPC = "/agentenv" +COOKIE = "agentenv_session_Shop" def rpc(client: TestClient, method: str, params: dict | None = None) -> dict: @@ -362,7 +363,7 @@ def test_the_acting_user_is_signed_in_until_a_session_says_else(client: TestClie first = client.get("/api/session") assert first.json() == {"user": "alice", "role": "default", "signed_in": True} cookie = first.headers["set-cookie"] - assert cookie.startswith(f"{SESSION_COOKIE}=") and "domain" not in cookie.lower() and "httponly" in cookie.lower() + assert cookie.startswith(f"{COOKIE}=") and "domain" not in cookie.lower() and "httponly" in cookie.lower() client.post("/api/sign-in", json={"user": "bob"}) assert client.get("/api/orders").json() == [{"id": 1, "user": "bob"}] assert client.get("/api/session").json()["user"] == "bob" @@ -370,7 +371,7 @@ def test_the_acting_user_is_signed_in_until_a_session_says_else(client: TestClie assert client.get("/api/session").json()["user"] is None # The jar holds the site's signed-out cookie under its own domain; a second cookie of the name would race it. client.cookies.clear() - client.cookies.set(SESSION_COOKIE, "forged.signature") + client.cookies.set(COOKIE, "forged.signature") assert client.get("/api/session").json()["user"] == "alice" @@ -385,7 +386,7 @@ def test_a_site_restarted_under_its_deploys_secret_keeps_its_sessions(dist: Path before.data = after.data = copy.deepcopy(SEED) browser = TestClient(before.create_app()) browser.post("/api/sign-in", json={"user": "bob"}) - restarted = TestClient(after.create_app(), cookies={SESSION_COOKIE: browser.cookies[SESSION_COOKIE]}) + restarted = TestClient(after.create_app(), cookies={COOKIE: browser.cookies[COOKIE]}) assert restarted.get("/api/session").json()["user"] == "bob" @@ -394,12 +395,49 @@ def test_the_session_operation_mints_cookies_for_a_user_or_signed_out(shop: Webs minted = client.post("/agentenv/ext/website/session", json={"user": "bob"}).json() assert minted.keys() == {"cookies", "local_storage"} and minted["local_storage"] == [] (cookie,) = minted["cookies"] - assert cookie["name"] == SESSION_COOKIE and cookie["path"] == "/" and cookie["max_age"] == 7 * 24 * 3600 + assert cookie["name"] == COOKIE and cookie["path"] == "/" and cookie["max_age"] == 7 * 24 * 3600 assert "domain" not in cookie and "expires" not in cookie - browser = TestClient(shop.asgi, cookies={SESSION_COOKIE: cookie["value"]}) + browser = TestClient(shop.asgi, cookies={COOKIE: cookie["value"]}) assert browser.get("/api/session").json()["user"] == "bob" signed_out = client.post("/agentenv/ext/website/session", json={}).json()["cookies"][0]["value"] - assert TestClient(shop.asgi, cookies={SESSION_COOKIE: signed_out}).get("/api/session").json()["user"] is None + assert TestClient(shop.asgi, cookies={COOKIE: signed_out}).get("/api/session").json()["user"] is None + + +def test_each_site_keeps_its_own_session_cookie_and_reads_the_legacy_one(dist: Path, monkeypatch) -> None: + monkeypatch.setenv("ENVIRONMENT_NAME", "shop a/1") + first = site(dist)() + monkeypatch.setenv("ENVIRONMENT_NAME", "shop-b") + second = site(dist)() + for env in (first, second): + env.data = copy.deepcopy(SEED) + named = "agentenv_session_shop_a_1~030f5785d67a" + assert (first.session_cookie, second.session_cookie) == (named, "agentenv_session_shop-b") + browser = TestClient(first.create_app()) + browser.post("/api/sign-in", json={"user": "bob"}) + other = TestClient(second.create_app(), cookies=browser.cookies) + other.post("/api/sign-in", json={"user": "alice"}) + assert set(other.cookies.keys()) == {named, "agentenv_session_shop-b"} + assert TestClient(first.asgi, cookies=other.cookies).get("/api/session").json()["user"] == "bob" + card = browser.get("/.well-known/agent-env.json").json() + assert _extension(card, WEBSITE_EXTENSION_URI)["params"]["session_cookie"] == named + legacy = first._session_signer().encode("bob") + assert TestClient(first.asgi, cookies={SESSION_COOKIE: legacy}).get("/api/orders").json() == [{"id": 1, "user": "bob"}] + + +def test_sites_whose_names_spell_alike_keep_their_own_cookies(dist: Path, monkeypatch) -> None: + apps = [] + for name in ("shop a/1", "shop_a_1"): + monkeypatch.setenv("ENVIRONMENT_NAME", name) + env = site(dist)() + env.data = copy.deepcopy(SEED) + apps.append(env.create_app()) + browser = TestClient(apps[0]) + browser.post("/api/sign-in", json={"user": "bob"}) + other = TestClient(apps[1], cookies=browser.cookies) + other.post("/api/sign-in", json={"user": "alice"}) + assert len(other.cookies.keys()) == 2 + back = TestClient(apps[0], cookies=other.cookies) + assert back.get("/api/session").json()["user"] == "bob" def test_every_answer_carries_the_universe_time(client: TestClient) -> None: diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index 53127567d..6af42c236 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -19,7 +19,7 @@ from starlette.responses import PlainTextResponse from starlette.testclient import TestClient -from agentenv_protocol.website import (ROLE_KEY_ENV, ROLE_KEY_HEADER, SESSION_COOKIE, UNIVERSE_NOW_HEADER, WebsiteEnvironment, +from agentenv_protocol.website import (ROLE_KEY_ENV, ROLE_KEY_HEADER, UNIVERSE_NOW_HEADER, WebsiteEnvironment, conformance, website) NOTES_DIR = Path(__file__).resolve().parents[1] / "examples" / "website" / "notes" @@ -112,7 +112,8 @@ 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 + ["card", "any_host", "health", "universe_now", "reserved_paths", "data_plane", "clock", "forwarded", + "cookies"], "passed"), results def test_the_card_declares_the_site_and_its_routes_by_name_and_it_is_ready_through_website_v1( @@ -130,7 +131,7 @@ def test_the_card_declares_the_site_and_its_routes_by_name_and_it_is_ready_throu def test_a_browser_is_signed_in_as_the_acting_user_and_can_sign_in_as_another(client: TestClient) -> None: session = client.get("/api/session") assert session.json() == {"user": "ann", "role": "default", "signed_in": True} - assert SESSION_COOKIE in session.cookies + assert "agentenv_session_notes" in session.cookies assert [n["title"] for n in client.get("/api/notes").json()] == ["Buy milk", "Book dentist"] assert client.post("/api/sign-in", json={"user": "ben"}).json() == {"user": "ben"} assert client.get("/api/session").json()["user"] == "ben" @@ -236,6 +237,7 @@ def test_the_seed_loads_exports_and_resets_through_the_data_plane(client: TestCl assert next(note for note in toggled if note["id"] == 1)["done"] is True + GATEWAY_PREFIX = "/website/notes"