diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index bd837fb0..d67c10d1 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -24,12 +24,14 @@ them. The page shows the universe's time. on only beside a role it verified with the same key, so a page cannot claim `admin` through it. Any other deploy sets no key, and its gateway passes on whatever role a browser sends; with no `WEBSITE_ROLE_KEY` set, every caller is `default` and the route is closed. -- **Users.** `ApiContext.user` is the user in the signed session cookie. With no cookie, it is the acting user (the +- **Users.** `ApiContext.user` is the user in the signed session cookie, `agentenv_session_notes` (the site's own, so + it doesn't collide with another site's under the same gateway host; the card names it). With no cookie, it is the acting user (the store's `current_user`), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded user. A user's notes are invisible to every other user, and another user's note answers 404. - **State.** The site writes no data hooks, so it uses the default store. `data/add` loads the seed (as a data part, or as a file part by bytes or `file://`), `data/get` exports it, `data/reset` empties it, and `/go` returns - `{initial_state, current_state, state_diff}`. + `{initial_state, current_state, state_diff}`. A page opened at `?sid=` keeps a state of its own, started + from the seed, which `/go?sid=` reports; without `sid` it is the default session, the data plane's. - **Time.** Notes are stamped with `self.now()`, which follows the gateway's clock once `clock/v1`'s `sync_time` names it. The frontend reads `X-Universe-Now` from every API response into `universeNow()` and shows it in the header. It uses this helper rather than `clock_patch=True`, so the page works the same against a server written without the @@ -66,11 +68,13 @@ The build context is the repo root, because the image installs the protocol pack ```bash docker build -f packages/agentenv-protocol/examples/website/notes/Dockerfile -t notes-site . docker run -d --name notes-site -p 18080:8000 notes-site -python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal +python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal --strict curl -s http://localhost:18080/api/notes ``` -Use `--clock-host` so the container can reach the checker's clock. The clock check leaves the server following a +Use `--clock-host` so the container can reach the checker's clock. `--strict` fails on a recommended check's warning +too (the card, `X-Universe-Now`, the clock, forwarded headers, cookies); without it only the contract's required checks +fail the run, and Notes passes every check either way. The clock check leaves the server following a clock that is gone once the check ends: later timestamps start from 2031 until the server is synced again, so restart the container before using it. diff --git a/packages/agentenv-protocol/pyproject.toml b/packages/agentenv-protocol/pyproject.toml index 6c782636..70b7d25a 100644 --- a/packages/agentenv-protocol/pyproject.toml +++ b/packages/agentenv-protocol/pyproject.toml @@ -49,6 +49,10 @@ computer = [ "Pillow>=10.4", "uvicorn>=0.30", ] +# A website whose state is an MCP server's database: @website(models=) and self.db. +db = [ + "sqlalchemy>=2.0", +] dev = [ "a2a-sdk[http-server]>=0.3.26,<0.4", "python-multipart>=0.0.13", @@ -59,6 +63,7 @@ dev = [ "fastmcp>=3.4,<4", # the standalone-fastmcp mount path "Pillow>=10.4", # the computer extra's frame crops and encodings "av>=17", # the computer extra's encoder, and the tests' decoder + "sqlalchemy>=2.0", # the db extra's models= site, tested on sqlite ] [project.urls] Homepage = "https://www.agentenvframework.com" diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/clock.py b/packages/agentenv-protocol/src/agentenv_protocol/website/clock.py index d70b2882..4b1a172d 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/clock.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/clock.py @@ -1,8 +1,9 @@ """The universe's clock as a site reads it: ``clock/v1``'s ``sync_time`` hands the server the gateway's ``/clock/time`` URL, as it hands it to MCP servers, and the site's time then follows that clock's ``{"virtual_time": }``, -read at most once a second and extrapolated between reads. The rate it extrapolates at is one two successive intervals -between reads agreed on, so a clock set or advanced between two reads is taken for a jump, not a speed; until two agree -it is real time's. An unsynced site reads wall time. Every answer of a site carries the time it answered at as ``X-Universe-Now``, +read at most once a second and extrapolated between reads. The rate it extrapolates at is the one the clock answers +with, ``virtual_seconds_per_real_second``, as the gateway's does; for a clock that answers none, it is one two +successive intervals between reads agreed on, so a clock set or advanced between two reads is taken for a jump, not a +speed, and until two agree it is real time's. An unsynced site reads wall time. Every answer of a site carries the time it answered at as ``X-Universe-Now``, RFC3339 in UTC. ``sync_time`` may also name the universe's time zone, an IANA name, which the clock keeps for the site to show local times in; the time itself stays UTC.""" from __future__ import annotations @@ -11,6 +12,7 @@ import datetime import json import logging +import math import time import httpx @@ -59,8 +61,8 @@ async def sync(self, url: str, timezone: str | None = None) -> datetime.datetime can't be read, keeping the clock and zone it followed. It waits for a refresh reading the clock it followed, so that read never lands on the new clock.""" async with self._reading(): - virtual = await _read(url) - self.url, self._virtual, self._rate, self._measured = url, virtual, 1.0, None + virtual, rate = await _read(url) + self.url, self._virtual, self._rate, self._measured = url, virtual, 1.0 if rate is None else rate, None self.timezone = timezone self._read_at = self._tried_at = time.monotonic() return virtual @@ -75,12 +77,14 @@ async def refresh(self) -> None: return self._tried_at = started try: - virtual = await _read(self.url) + virtual, rate = await _read(self.url) except Exception as e: logger.debug("reading the universe's clock at %s failed: %r", self.url, e) return read_at = time.monotonic() - if self._virtual is not None and read_at > self._read_at: + if rate is not None: + self._rate = rate + elif self._virtual is not None and read_at > self._read_at: measured = max(0.0, (virtual - self._virtual).total_seconds() / (read_at - self._read_at)) if self._measured is not None and abs(measured - self._measured) <= 0.1 * max(measured, self._measured, 1.0): self._rate = measured @@ -93,11 +97,15 @@ def _reading(self) -> asyncio.Lock: return self._lock -async def _read(url: str) -> datetime.datetime: +async def _read(url: str) -> tuple[datetime.datetime, float | None]: + """The clock's time, and the rate it says it runs at: None when it says none, or none that is a finite rate.""" async with httpx.AsyncClient() as client: response = await client.get(url, timeout=READ_TIMEOUT_S) response.raise_for_status() - return parse_time(response.json()["virtual_time"]) + body = response.json() + rate = body.get("virtual_seconds_per_real_second") + ok = isinstance(rate, (int, float)) and not isinstance(rate, bool) and math.isfinite(rate) and rate >= 0 + return parse_time(body["virtual_time"]), float(rate) if ok else None def clock_patch_script(now: datetime.datetime) -> bytes: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py index c1ed1fa3..1180ba9f 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -1,18 +1,26 @@ -"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in: -``python -m agentenv_protocol.website.conformance `` prints each check and exits 1 when one failed, 2 when the -server couldn't be reached. Everything goes over HTTP: the card, ``/`` under a made-up host, readiness by the card's -``website/v1`` ``ready`` (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 (data/get, data/reset, data/get, which -no longer returns the exported data, data/add of what the first data/get returned, data/get again), ``clock/v1``'s -``sync_time`` against a clock the checker serves, and the forwarded headers wherever an answer shows them, on ``/`` and -on a GET route with its trailing slash toggled, which a server may redirect. +"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in: ``python -m +agentenv_protocol.website.conformance `` prints each check and exits 1 when a required one failed (or, with +``--strict``, when a recommended one warned), 2 when the server couldn't be reached. Everything goes over HTTP. + +The required checks are the contract, what the gateway and a browser rely on of any server: ``/`` serves HTML under a +made-up host, the site is ready, by the card's ``website/v1`` ``ready`` or, for a card without it, by the health +path (the card's, else ``/api/health``) answering 200, which is deprecated, the data plane and card's paths are not +the site's pages, and, when the card advertises the data plane, a round trip (data/get, data/reset, data/get, which +no longer returns the exported data, data/add of what the first data/get returned, data/get again) returns what it was +given. A failed one is ``failed``. The recommended checks are what an SDK site does and the gateway uses when a server +offers it: a card declaring the site's domains, health path and routes, ``X-Universe-Now`` on every answer the other +checks got, ``clock/v1``'s ``sync_time`` against a clock the checker serves, the forwarded headers wherever an answer +shows them (on ``/`` and on a GET route with its trailing slash toggled, which a server may redirect), and cookies +tied to no domain, the session path's the one the card names. A failed one is a ``warning``. The round trip leaves the server's data as it found it, adding the export back when a step after the reset fails too. -data/add sets the state a task starts from, so a server whose ``/go`` shows changes since its initial state, as an SDK -site's does, would have them folded into it: the round trip is skipped for such a server. The clock check leaves the -server following a clock that is gone once the check ends, so a server it ran against keeps extrapolating from that -clock's time until it's synced again. The checker's clock starts at ``CLOCK_START``, far enough from wall time that a -synced server shows it, and a synced server's ``X-Universe-Now`` may be ``CLOCK_SLACK_S`` seconds from it.""" +data/add sets the state a task starts from, so a server whose ``/go`` shows changes since its initial state, as an +SDK site's does, would have them folded into it, and data/reset drops a site's named sessions, which data/get doesn't +export: the round trip is skipped for a server whose ``/go`` shows changes or ``changed_sessions``. The clock +check leaves the server following a clock that is gone once the check ends, so a server it ran against keeps +extrapolating from that clock's time until it's synced again. The checker's clock starts at ``CLOCK_START``, far +enough from wall time that a synced server shows it, and a synced server's ``X-Universe-Now`` may be +``CLOCK_SLACK_S`` seconds from it.""" from __future__ import annotations import argparse @@ -34,21 +42,30 @@ from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, iso, parse_time from .environment import DEFAULT_HEALTH_PATH, STATE_PATH, WEBSITE_EXTENSION_URI -Status = Literal["passed", "failed", "skipped"] +Status = Literal["passed", "failed", "warning", "skipped"] CLOCK_START = datetime.datetime(2031, 5, 6, 7, 8, 9, tzinfo=datetime.timezone.utc) CLOCK_SLACK_S = 120 +REQUIRED = ("any_host", "health", "reserved_paths", "data_plane") @dataclasses.dataclass(frozen=True) class Result: + """One check's outcome; ``required`` for a check of the contract, whose failure is ``failed``, and not for a + recommended one, whose failure is a ``warning``.""" + name: str status: Status detail: str = "" + required: bool = True @property def passed(self) -> bool: return self.status != "failed" + def fails(self, strict: bool = False) -> bool: + """Whether the result fails the run: a failed check, or with ``strict`` a warning too.""" + return self.status == "failed" or (strict and self.status == "warning") + def check(base_url: str, *, client: httpx.Client | None = None, clock_host: str = "127.0.0.1", timeout: float = 30) -> list[Result]: @@ -81,19 +98,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, site)), ("any_host", self._any_host), + ("health", lambda: self._health(card, health)), ("universe_now", self._universe_now), + ("reserved_paths", self._reserved_paths), ("data_plane", lambda: self._data_plane(card)), ("clock", lambda: self._clock(card, health)), ("forwarded", lambda: self._forwarded(site)), ("cookies", lambda: self._cookies(site))) outcomes = {name: _outcome(step) for name, step in steps if name != "universe_now"} outcomes["universe_now"] = _outcome(self._universe_now) - return [self._card(response, site), *(Result(name, *outcomes[name]) for name, _step in steps)] - - def _card(self, response: httpx.Response, site: dict) -> Result: + results = [] + for name, _step in steps: + status, detail = outcomes[name] + required = name in REQUIRED + results.append(Result(name, "warning" if status == "failed" and not required else status, detail, required)) + return results + + def _card(self, response: httpx.Response, site: dict) -> tuple[Status, str]: if response.status_code != 200: - return Result("card", "failed", f"{WELL_KNOWN_PATH} answered {response.status_code}") + return "failed", f"{WELL_KNOWN_PATH} answered {response.status_code}" problems = [] if not site: problems.append(f"declares no {WEBSITE_EXTENSION_URI} with params") @@ -105,7 +129,7 @@ def _card(self, response: httpx.Response, site: dict) -> Result: if not isinstance(routes, list) or not all(isinstance(r, dict) and {"method", "path", "name"} <= set(r) for r in routes): problems.append("its params' routes aren't a list of {method, path, name}") - return Result("card", "failed" if problems else "passed", "; ".join(problems)) + return ("failed" if problems else "passed"), "; ".join(problems) def _any_host(self) -> tuple[Status, str]: host = f"conformance-{uuid.uuid4().hex[:8]}.invalid" @@ -136,6 +160,14 @@ def _universe_now(self) -> tuple[Status, str]: parse_time(response.headers[UNIVERSE_NOW_HEADER]) return "passed", f"on all {len(self.responses)} answers" + def _reserved_paths(self) -> tuple[Status, str]: + paths = [RPC_PATH, WELL_KNOWN_PATH, f"{RPC_PATH}/conformance-{uuid.uuid4().hex[:8]}", f"{RPC_PATH}/", f"{WELL_KNOWN_PATH}/"] + for path in paths: + response = self.get(path) + if response.is_success and response.headers.get("content-type", "").startswith("text/html"): + return "failed", f"{path} answered a page, but the gateway keeps {RPC_PATH} and {WELL_KNOWN_PATH}" + return "passed", f"no page at {', '.join(paths)}" + def _rpc(self, method: str, params: dict | None = None) -> dict: response = self._seen(self.client.post(self.base_url + RPC_PATH, json={"jsonrpc": "2.0", "id": method, "method": method, "params": params or {}})) @@ -148,13 +180,17 @@ def _rpc(self, method: str, params: dict | None = None) -> dict: def _data_plane(self, card: dict) -> tuple[Status, str]: operations = (card.get("capabilities") or {}).get("operations") - missing = [m for m in (METHOD_GET, METHOD_RESET, METHOD_ADD) if operations is not None and m not in operations] + operations = operations if isinstance(operations, list) else [] + missing = [m for m in (METHOD_GET, METHOD_RESET, METHOD_ADD) if m not in operations] + if len(missing) == 3: + return "skipped", "the card advertises no data plane" if missing: return "failed", f"the card's operations lack {', '.join(missing)}" exported = self._rpc(METHOD_GET).get("parts") or [] if self._changed_since_initial(): - return "skipped", (f"data/get answered; {STATE_PATH} shows changes since the initial state, which data/reset " - "and data/add would fold into it, so the round trip was not run") + return "skipped", (f"data/get answered; {STATE_PATH} shows changes since the initial state, or named sessions " + "with changes, which data/reset and data/add would fold in or drop, so the round trip " + "was not run") self._rpc(METHOD_RESET) added_back = False try: @@ -177,13 +213,13 @@ def _data_plane(self, card: dict) -> tuple[Status, str]: return "passed", f"{len(exported)} part(s) round-tripped through reset and add" def _changed_since_initial(self) -> bool: - """Whether the server's ``/go`` answers a state whose ``state_diff`` names changes; read aside, as no check's - answer, since a server without one needn't stamp its 404.""" + """Whether the server's ``/go`` answers a state whose ``state_diff`` names changes, or that names + ``changed_sessions``; read aside, as no check's answer, since a server without one needn't stamp its 404.""" try: state = self.client.get(self.base_url + STATE_PATH).json() except Exception: return False - return isinstance(state, dict) and bool(state.get("state_diff")) + return isinstance(state, dict) and bool(state.get("state_diff") or state.get("changed_sessions")) def _clock(self, card: dict, health: str) -> tuple[Status, str]: if find_extension(card, CLOCK_EXTENSION_URI) is None: @@ -222,14 +258,18 @@ def _forwarded(self, site: dict) -> tuple[Status, str]: return "passed", f"no answer to {', '.join(paths)} named the internal Host" def _cookies(self, site: dict) -> tuple[Status, str]: + session = [] if isinstance(site.get("session_path"), str): - self.get(site["session_path"]) + session = self.get(site["session_path"]).headers.get_list("set-cookie") cookies = [c for r in self.responses for c in r.headers.get_list("set-cookie")] if not cookies: return "skipped", "no answer set a cookie" scoped = [c.split("=", 1)[0] for c in cookies if any(a.strip().lower().startswith("domain=") for a in c.split(";"))] if scoped: return "failed", f"cookies tied to a domain: {', '.join(sorted(set(scoped)))}" + named = site.get("session_cookie") + if isinstance(named, str) and session and named not in {c.split("=", 1)[0].strip() for c in session}: + return "failed", f"{site['session_path']} set {', '.join(c.split('=', 1)[0] for c in session)}, not the card's {named}" return "passed", f"{len(cookies)} cookie(s), none tied to a domain" @@ -271,7 +311,7 @@ def virtual_now() -> datetime.datetime: class Clock(BaseHTTPRequestHandler): def do_GET(self) -> None: - body = json.dumps({"virtual_time": iso(virtual_now())}).encode() + body = json.dumps({"virtual_time": iso(virtual_now()), "virtual_seconds_per_real_second": 1.0}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) @@ -291,7 +331,7 @@ def log_message(self, *args) -> None: server.server_close() -_MARK = {"passed": "PASS", "failed": "FAIL", "skipped": "----"} +_MARK = {"passed": "PASS", "failed": "FAIL", "warning": "WARN", "skipped": "----"} def main(argv: list[str] | None = None) -> int: @@ -300,6 +340,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) @@ -307,10 +348,12 @@ def main(argv: list[str] | None = None) -> int: print(f"couldn't reach {args.url}: {type(e).__name__}: {e}", file=sys.stderr) return 2 for result in results: - print(f"{_MARK[result.status]} {result.name}{': ' + result.detail if result.detail else ''}") - failed = sum(not r.passed for r in results) - print(f"{args.url}: {len(results) - failed} passed or skipped, {failed} failed") - return 1 if failed else 0 + kind = "required" if result.required else "recommended" + print(f"{_MARK[result.status]} {result.name} ({kind}){': ' + result.detail if result.detail else ''}") + counts = {status: sum(r.status == status for r in results) for status in _MARK} + print(f"{args.url}: {counts['passed']} passed, {counts['failed']} failed, {counts['warning']} warning(s), " + f"{counts['skipped']} skipped{' (strict)' if args.strict else ''}") + return 1 if any(r.fails(args.strict) for r in results) else 0 if __name__ == "__main__": diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/database.py b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py new file mode 100644 index 00000000..0286ff72 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/database.py @@ -0,0 +1,225 @@ +"""A site whose state is a database's: ``@website(models=...)`` names the data an MCP server keeps, so the site's pages +and the server's tools read and write the same rows under the same constraints (and the same rules, where the site +calls the server's own service code). ``models`` is either an object with +``load(data)``, ``export()`` and ``reset()`` (each may be async), the data hooks themselves, or SQLAlchemy models: a +declarative ``Base``, a ``MetaData``, or a module holding one (its ``Base``, else its one metadata), as the +synthetic_servers kit's ``database.py`` declares them. For models, the site's ``self.db`` is a SQLAlchemy session +factory on ``DATABASE_URL``, which agent-env points at the env's own schema of the service database (its +``search_path``), creating the models' tables there on first use; its data is a JSON object of each table's rows by +table name, which data/add inserts, data/get exports, and data/reset deletes. SQLAlchemy is imported only for models, +from the ``db`` extra, which carries no database driver: the site installs the one its ``DATABASE_URL`` names +(for ``postgresql://``, SQLAlchemy 2.0's default ``psycopg2`` or 2.1's ``psycopg``), and an engine whose driver isn't +installed raises ImportError naming it. + +Beside the tables, the data's string ``current_user`` names the user a browser starts signed in as, as the default +store's does, and the state data/add last loaded is the initial one ``/go`` reports. A models site keeps one state, +the database's, so ``/go`` answers it for every ``sid``.""" +from __future__ import annotations + +import copy +import datetime +import decimal +import json +import types +import uuid +from typing import Any, Callable, Iterable + +from pydantic_core import to_jsonable_python + +from ..agent_env_environment import _maybe_await +from .store import CURRENT_USER, part_data, verifier_state + +HOOKS = ("load", "export", "reset") + + +def is_hooks(models: Any) -> bool: + """Whether ``models`` is data hooks of its own: an object with callable ``load``, ``export`` and ``reset``.""" + return all(callable(getattr(models, name, None)) for name in HOOKS) + + +def metadata_of(models: Any) -> Any: + """The SQLAlchemy ``MetaData`` ``models`` names: itself, its ``metadata``, or a module's ``Base``'s or one + metadata; raises TypeError for anything else.""" + sqlalchemy = _sqlalchemy() + if isinstance(models, sqlalchemy.MetaData): + return models + if isinstance(getattr(models, "metadata", None), sqlalchemy.MetaData): + return models.metadata + if isinstance(models, types.ModuleType): + if isinstance(getattr(getattr(models, "Base", None), "metadata", None), sqlalchemy.MetaData): + return models.Base.metadata + found = {id(m): m for value in vars(models).values() if isinstance( + m := value if isinstance(value, sqlalchemy.MetaData) else getattr(value, "metadata", None), + sqlalchemy.MetaData)} + if len(found) == 1: + return next(iter(found.values())) + if found: + raise TypeError(f"module {models.__name__} holds {len(found)} MetaData; pass models= its Base") + raise TypeError(f"models= is an object with load/export/reset, or SQLAlchemy models (a declarative Base, a " + f"MetaData, or a module holding one), not {models!r}") + + +def _sqlalchemy() -> Any: + try: + import sqlalchemy + except ImportError as e: + raise ImportError("@website(models=...) of SQLAlchemy models needs sqlalchemy: " + "pip install 'agentenv-framework-protocol[db]'") from e + return sqlalchemy + + +class Tables: + """The data hooks of SQLAlchemy ``metadata`` on the database at ``url()``: each table's rows by table name.""" + + def __init__(self, metadata: Any, url: Callable[[], str | None]) -> None: + self.metadata, self._url = metadata, url + self._engine: Any = None + self._sessions: Any = None + + @property + def engine(self) -> Any: + """The engine on ``DATABASE_URL``, made on first use with the models' tables created there; raises + ImportError naming the driver when the URL's isn't installed.""" + if self._engine is None: + url = self._url() + if not url: + raise RuntimeError("a site of SQLAlchemy models keeps its rows in DATABASE_URL, which isn't set") + sqlalchemy = _sqlalchemy() + try: + engine = sqlalchemy.create_engine(url) + except ImportError as e: + scheme = sqlalchemy.engine.make_url(url).drivername + raise ImportError(f"DATABASE_URL's {scheme}:// needs the database driver {e.name or 'it names'}, which " + f"isn't installed; the db extra carries none, so the site installs it " + f"(pip install {e.name or 'the driver'})") from e + self.metadata.create_all(engine) + self._engine = engine + return self._engine + + @property + def sessions(self) -> Any: + if self._sessions is None: + from sqlalchemy.orm import sessionmaker + + self._sessions = sessionmaker(bind=self.engine) + return self._sessions + + def export(self) -> dict: + sqlalchemy = _sqlalchemy() + with self.engine.connect() as connection: + return {table.name: [to_jsonable_python(dict(row._mapping)) for row in connection.execute( + sqlalchemy.select(table).order_by(*table.primary_key.columns))] for table in self.metadata.sorted_tables} + + def load(self, data: dict) -> None: + """Insert ``data``'s rows, each table's after the tables it refers to; refuses a name that is no table.""" + tables = {table.name: table for table in self.metadata.sorted_tables} + unknown = sorted(set(data) - set(tables)) + if unknown: + raise ValueError(f"no table {', '.join(map(repr, unknown))}; the tables are {sorted(tables)}") + with self.engine.begin() as connection: + for name, table in tables.items(): + rows = data.get(name) or [] + if not (isinstance(rows, list) and all(isinstance(row, dict) for row in rows)): + raise ValueError(f"table {name!r}'s data is not a list of rows") + if rows: + connection.execute(table.insert(), [_row(table, row) for row in rows]) + if connection.dialect.name == "postgresql": + _advance_sequences(connection, tables.values()) + + def reset(self) -> None: + with self.engine.begin() as connection: + for table in reversed(self.metadata.sorted_tables): + connection.execute(table.delete()) + + def key(self, name: str, row: dict) -> str: + """A row's identity, its primary key's values, which ``/go``'s diff matches rows by.""" + table = next((t for t in self.metadata.sorted_tables if t.name == name), None) + columns = [c.name for c in table.primary_key.columns] if table is not None else [] + return json.dumps([row.get(c) for c in columns] if columns else row, sort_keys=True, default=str) + + +_PARSERS: dict = {datetime.datetime: datetime.datetime.fromisoformat, datetime.date: datetime.date.fromisoformat, + datetime.time: datetime.time.fromisoformat, decimal.Decimal: decimal.Decimal, uuid.UUID: uuid.UUID} + + +def _row(table: Any, row: dict) -> dict: + """``row`` with each JSON string a date, time, decimal or UUID column holds parsed to that type.""" + parsed = dict(row) + for column in table.columns: + value = parsed.get(column.name) + if isinstance(value, str): + kind = _python_type(column) + if kind in _PARSERS: + parsed[column.name] = _PARSERS[kind](value.replace("Z", "+00:00") if kind is datetime.datetime else value) + return parsed + + +def _python_type(column: Any) -> type | None: + try: + return column.type.python_type + except NotImplementedError: + return None + + +def _advance_sequences(connection: Any, tables: Iterable[Any]) -> None: + """Move each serial key's sequence past the rows just loaded, so the next row the site or server adds takes a key + of its own.""" + sqlalchemy = _sqlalchemy() + for table in tables: + keys = list(table.primary_key.columns) + if len(keys) == 1 and keys[0].autoincrement in (True, "auto") and _python_type(keys[0]) is int: + quote = connection.dialect.identifier_preparer + name = quote.format_table(table) + connection.execute(sqlalchemy.text( + f"SELECT setval(pg_get_serial_sequence(:table, :column), COALESCE(MAX({quote.quote(keys[0].name)}), 0) + 1, " + f"false) FROM {name}"), {"table": name, "column": keys[0].name}) + + +class Sessions: + """A site's ``self.db``: ``self.db()`` is a SQLAlchemy ``Session`` on the site's database, and ``self.db.begin()`` + one in a transaction that commits as its ``with`` ends; the engine is made on first use, not at mount.""" + + def __init__(self, tables: Tables) -> None: + self._tables = tables + + def __call__(self, **options: Any) -> Any: + return self._tables.sessions(**options) + + def begin(self) -> Any: + return self._tables.sessions.begin() + + @property + def engine(self) -> Any: + return self._tables.engine + + +class ModelState: + """A models site's data plane and ``/go``: ``hooks`` loads, exports and resets its data, the state data/add last + loaded is the initial one, and the data's ``current_user`` is kept beside it.""" + + def __init__(self, hooks: Any) -> None: + self.hooks = hooks + self.initial: dict = {} + self.current_user: str | None = None + + async def reset(self) -> None: + await _maybe_await(self.hooks.reset()) + self.initial, self.current_user = {}, None + + async def load_parts(self, parts: list) -> None: + for part in parts: + data = dict(part_data(part)) + if CURRENT_USER in data: + self.current_user = data.pop(CURRENT_USER) + await _maybe_await(self.hooks.load(data)) + self.initial = copy.deepcopy(await self.export()) + + async def export(self) -> dict: + data = dict(await _maybe_await(self.hooks.export())) + if self.current_user is not None: + data[CURRENT_USER] = self.current_user + return data + + async def state(self) -> dict: + key = getattr(self.hooks, "key", None) + return verifier_state(copy.deepcopy(self.initial), await self.export(), key) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py index 767b70c9..acf3c86c 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py @@ -2,12 +2,21 @@ plain HTTP on ``PORT`` for any host name: its ``@api`` routes, its static folder at ``/`` with ``index.html`` for the pages a single-page app routes itself, its health path, and the data plane and card the base class serves. Every answer carries ``X-Universe-Now``, the universe's time, which ``self.now()`` reads and ``clock/v1``'s ``sync_time`` ties to -the gateway's virtual clock. Without data hooks of its own a site keeps its state in ``self.store``. +the gateway's virtual clock. Without data hooks of its own a site keeps its state in ``self.store``, or with +``@website(models=...)`` in an MCP server's database, which ``self.db`` opens sessions of (``website.database``). + +A store-backed site keeps a state per session, the CUA-Gym convention the website state verifier reads: a request's +session is its ``?sid=``, else the one its browser last named, which the site keeps in the ``agentenv_sid_`` +cookie, else the default one. ``self.store`` is the request's session's store, each session started as a copy of the +initial state; the default session, ``sid=default``, is the state the data plane loads, exports and resets, and the +one a browser that names no session uses, so a site browsed without ``sid`` works as it did before sessions. Two routes are built in, each replaced by a site's own ``@api`` route at its very path and answering before one whose path parameters would also match it: ``/api/session``, which a frontend calls to learn who is signed in, signing the -acting user in when nobody is (GET), or to sign out (DELETE); and, for a store-backed site, ``/go``, its -``{initial_state, current_state, state_diff}`` as the website state verifier reads it.""" +acting user in when nobody is (GET), or to sign out (DELETE); and, for a store-backed site, ``/go?sid=``, that +session's ``{initial_state, current_state, state_diff}`` as the website state verifier reads it, with the site's +``changed_sessions``, the named sessions whose state changed, which data/reset would drop; a session no request has +used yet answers the initial state, unchanged.""" from __future__ import annotations import inspect @@ -27,6 +36,7 @@ from starlette.routing import Route from ..agent_env_environment import ( + _CARD_CONFIG_ATTR, _OP_ATTR, AgentEnvEnvironment, AgentEnvStarletteApplication, @@ -39,11 +49,12 @@ ) from ..types import DataPart from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, UniverseClock, clock_patch_script, iso +from .database import ModelState, Sessions, Tables, is_hooks, metadata_of from .errors import NotFound from .routes import (ApiContext, ApiRoute, endpoint, error_response, forwarded_prefix, openapi, openapi_path, request_role, reserved_path, routes_of) -from .session import SESSION_COOKIE, SessionSigner, expires_at -from .store import Collections +from .session import SessionSigner, expires_at, session_cookie_name, sid_cookie_name +from .store import DEFAULT_SID, Collections, active_sid, session_id WEBSITE_EXTENSION_URI = "urn:agentenv:website/v1" DEFAULT_HEALTH_PATH = "/api/health" @@ -58,27 +69,36 @@ class WebsiteDeclaration(NamedTuple): """What ``@website`` declares: the site's domains, its static folder and seed data file (relative to the class's - file), its health path, and whether its HTML pages read the universe's time.""" + file), its health path, whether its HTML pages read the universe's time, and the models whose database keeps its + state instead of the default store.""" domains: tuple[str, ...] = () static: Optional[str] = None health: str = DEFAULT_HEALTH_PATH clock_patch: bool = False data: Optional[str] = None + models: Any = None def website(_cls: type | None = None, *, domains: list[str] | tuple[str, ...] = (), static: str | None = None, - health: str = DEFAULT_HEALTH_PATH, clock_patch: bool = False, data: str | None = None) -> Any: - """Declare a WebsiteEnvironment's site. ``data`` names a JSON file the default store starts with.""" + health: str = DEFAULT_HEALTH_PATH, clock_patch: bool = False, data: str | None = None, + models: Any = None) -> Any: + """Declare a WebsiteEnvironment's site. ``data`` names a JSON file the default store starts with; ``models`` + keeps the site's state in a database instead, an MCP server's (see ``website.database``), and the site's + data plane and ``/go`` are that database's.""" if _cls is not None: raise TypeError("use @website(...) with parentheses") if not health.startswith("/") or health == "/" or reserved_path(health): raise ValueError(f"health is a path of its own under '/', not {health!r}") + if models is not None and data is not None: + raise ValueError("data= seeds the default store; a site of models= is seeded through data/add") + if models is not None and not is_hooks(models): + metadata_of(models) def deco(cls: type) -> type: if not (isinstance(cls, type) and issubclass(cls, WebsiteEnvironment)): raise TypeError(f"@website declares a WebsiteEnvironment subclass, not {cls!r}") - setattr(cls, _WEBSITE_ATTR, WebsiteDeclaration(tuple(domains), static, health, clock_patch, data)) + setattr(cls, _WEBSITE_ATTR, WebsiteDeclaration(tuple(domains), static, health, clock_patch, data, models)) _check_health(cls) return cls @@ -99,6 +119,17 @@ class WebsiteEnvironment(AgentEnvEnvironment): app: ClassVar[Any] = None asgi: Any = None + db: Any = None + _store: Collections + + @property + def store(self) -> Collections: + """The state store of the request's session: the default session's outside a request.""" + return self._store.session(active_sid.get()) + + @store.setter + def store(self, value: Collections) -> None: + self._store = value def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) @@ -116,7 +147,12 @@ def __init__(self) -> None: self.store.load(json.loads(self._site_path(declaration.data).read_text(encoding="utf-8"))) self.store.mark_initial() self.clock = UniverseClock() - self._signer = SessionSigner() + self._signer = SessionSigner(cookie_name=session_cookie_name(self.environment_name())) + models = declaration.models + self._tables = (None if models is None or is_hooks(models) + else Tables(metadata_of(models), lambda: self.database_url)) + self._models = None if models is None else ModelState(models if self._tables is None else self._tables) + self.db = Sessions(self._tables) if self._tables is not None else None @classmethod def declaration(cls) -> WebsiteDeclaration: @@ -126,6 +162,17 @@ def declaration(cls) -> WebsiteDeclaration: def routes(cls) -> list[ApiRoute]: return routes_of(cls) + def environment_name(self) -> str: + """The site's name, as its card names it: ``ENVIRONMENT_NAME``, else the card's declared name, else the + class's.""" + declared = (getattr(self, _CARD_CONFIG_ATTR, None) or {}).get("name") + return os.environ.get("ENVIRONMENT_NAME") or declared or type(self).__name__ + + @property + def session_cookie(self) -> str: + """The name of the site's session cookie, ``agentenv_session_``.""" + return self._session_signer().cookie_name + @property def database_url(self) -> str | None: """Where the site keeps its task state: its own schema of the env's database.""" @@ -147,12 +194,15 @@ def timezone(self) -> str | None: return self._clock().timezone def acting_user(self) -> str | None: - """Who a browser with no session is signed in as: the store's ``current_user`` unless a site says else.""" + """Who a browser with no session is signed in as: the store's ``current_user``, or the one a models site's data + named, unless a site says else.""" + if getattr(self, "_models", None) is not None: + return self._models.current_user return self.store.current_user if hasattr(self, "store") else None def context(self, request: Request) -> ApiContext: """The ``ApiContext`` of ``request``, for a mounted ``app``'s routes as for ``@api`` ones.""" - session = self._session_signer().decode(request.cookies.get(SESSION_COOKIE)) + session = self._session_signer().read(request.cookies) user = session["user"] if session is not None else self.acting_user() return ApiContext(request, user, request_role(request), session, self._session_signer()) @@ -164,7 +214,8 @@ def create_app(self) -> Starlette: api_routes = self.routes() taken = {openapi_path(r.path) for r in api_routes} root = self._site_path(declaration.static) if declaration.static is not None else None - asgi = Starlette(middleware=[Middleware(_Forwarded), Middleware(_Universe, env=self)]) + asgi = Starlette(middleware=[Middleware(_Forwarded), Middleware(_Universe, env=self), + Middleware(_Session, cookie=sid_cookie_name(self.environment_name()))]) AgentEnvStarletteApplication(self._build_card(), self).add_routes_to_app(asgi) asgi.router.routes.append(Route(declaration.health, self._health, methods=["GET"])) if SESSION_PATH not in taken: @@ -194,7 +245,7 @@ def extension_params(self, uri: str) -> dict: if uri == WEBSITE_EXTENSION_URI: declaration = self.declaration() return {"domains": list(declaration.domains), "health": declaration.health, - "routes": [r.advertised() for r in self.routes()], "session_cookie": SESSION_COOKIE, + "routes": [r.advertised() for r in self.routes()], "session_cookie": self.session_cookie, "session_path": SESSION_PATH} return super().extension_params(uri) @@ -228,16 +279,20 @@ async def _website_session(self, user: Optional[str] = None) -> dict: @reset_data async def _store_reset(self) -> None: - self.store.reset() + if self._models is not None: + return await self._models.reset() + self._store.reset() @add_data async def _store_add(self, parts: list) -> None: - self.store.load_parts(parts) - self.store.mark_initial() + if self._models is not None: + return await self._models.load_parts(parts) + self._store.load_parts(parts) + self._store.mark_initial() @get_data async def _store_get(self) -> list: - return [DataPart(data=self.store.export())] + return [DataPart(data=await self._models.export() if self._models is not None else self._store.export())] async def _not_ready(self) -> list[str]: return [check.__name__ for check, _marker in _marked_members(self, _READY_ATTR) if not await _ready(check)] @@ -258,7 +313,9 @@ async def _session(self, request: Request) -> Response: "signed_in": context.user is not None})) async def _state(self, request: Request) -> Response: - return JSONResponse(self.store.state()) + if self._models is not None: + return JSONResponse(await self._models.state()) + return JSONResponse({**self.store.state(), "changed_sessions": self._store.changed_sessions()}) def _clock(self) -> UniverseClock: if not hasattr(self, "clock"): @@ -327,6 +384,34 @@ def _first(value: bytes | None) -> str: return value.decode("latin-1").split(",")[0].strip() if value else "" +class _Session: + """Runs the request in its session (``active_sid``): its ``sid`` query parameter's, else its ``cookie``'s; an + answer to a request naming a session other than its cookie's sets the cookie, so a page opened at ``?sid=`` keeps + its session for the requests it makes after.""" + + def __init__(self, app: Any, cookie: str) -> None: + self.app, self.cookie = app, cookie + + async def __call__(self, scope: dict, receive: Any, send: Any) -> None: + if scope["type"] != "http": + return await self.app(scope, receive, send) + request = Request(scope) + named, kept = request.query_params.get("sid"), request.cookies.get(self.cookie) + sid = session_id(named if named is not None else kept) + + async def remembered(message: dict) -> None: + if message["type"] == "http.response.start" and named is not None and session_id(kept) != sid: + MutableHeaders(scope=message).append( + "set-cookie", f"{self.cookie}={sid or DEFAULT_SID}; Path=/; HttpOnly; SameSite=Lax") + await send(message) + + token = active_sid.set(sid) + try: + await self.app(scope, receive, remembered) + finally: + active_sid.reset(token) + + class _Universe: """Stamps ``X-Universe-Now`` on every answer, and with ``clock_patch`` puts the clock script in its HTML.""" diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py b/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py index 66d018e7..0aee0cf6 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/routes.py @@ -26,7 +26,7 @@ from ..tool_context import DEFAULT_ROLE, caller_from_request from ..types import RPC_PATH, WELL_KNOWN_PATH from .errors import ApiError, BadRequest, Forbidden -from .session import SESSION_COOKIE, SessionSigner +from .session import SessionSigner logger = logging.getLogger(__name__) @@ -154,7 +154,7 @@ def _set_session(self, user: str | None) -> None: def apply(self, response: Response) -> Response: for value, expires in self._set_cookie: at = datetime.datetime.fromtimestamp(expires, datetime.timezone.utc) - response.set_cookie(SESSION_COOKIE, value, expires=at, path="/", httponly=True, samesite="lax") + response.set_cookie(self._signer.cookie_name, value, expires=at, path="/", httponly=True, samesite="lax") return response diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/session.py b/packages/agentenv-protocol/src/agentenv_protocol/website/session.py index 8ee9b8e7..75de0a6f 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/session.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/session.py @@ -1,6 +1,8 @@ """A site's signed session cookie: who the browser is signed in as, or that it signed out, signed with HMAC-SHA256 under ``WEBSITE_SESSION_SECRET``, or a secret of the process's own when that isn't set. The cookie names no domain, -so it holds under whatever host the site is reached at.""" +so it holds under whatever host the site is reached at, and its name is the site's own, ``agentenv_session_``, so +two sites a browser reaches under one gateway host (``/website/a/``, ``/website/b/``) keep a session each; a site still +reads the shared ``agentenv_session`` of older sites, for one release.""" from __future__ import annotations import base64 @@ -10,18 +12,35 @@ import hmac import json import os +import re import secrets import time SESSION_COOKIE = "agentenv_session" +SID_COOKIE = "agentenv_sid" SECRET_ENV = "WEBSITE_SESSION_SECRET" SESSION_TTL_S = 7 * 24 * 3600 +_NOT_TOKEN = re.compile(r"[^A-Za-z0-9_.-]") + + +def session_cookie_name(environment_name: str | None) -> str: + """The session cookie of the site named ``environment_name``: ``agentenv_session_``, each character a cookie + name can't hold made ``_``; the shared ``agentenv_session`` for a site with no name.""" + return f"{SESSION_COOKIE}_{_NOT_TOKEN.sub('_', environment_name)}" if environment_name else SESSION_COOKIE + + +def sid_cookie_name(environment_name: str | None) -> str: + """The cookie in which the site named ``environment_name`` keeps the store session its browser last named: + ``agentenv_sid_``, made as ``session_cookie_name`` makes its name, ``agentenv_sid`` for a site with no name. + Its prefix is no session cookie's, so no site's store-selection cookie is another site's session cookie.""" + return f"{SID_COOKIE}_{_NOT_TOKEN.sub('_', environment_name)}" if environment_name else SID_COOKIE class SessionSigner: - def __init__(self, secret: str | bytes | None = None) -> None: + def __init__(self, secret: str | bytes | None = None, cookie_name: str = SESSION_COOKIE) -> None: secret = secret if secret is not None else os.environ.get(SECRET_ENV) self._key = (secret.encode() if isinstance(secret, str) else secret) or secrets.token_bytes(32) + self.cookie_name = cookie_name def encode(self, user: str | None, ttl_s: int = SESSION_TTL_S) -> tuple[str, int]: """The cookie value for ``user`` signed in, or signed out when None, and when it expires, in epoch @@ -44,11 +63,18 @@ def decode(self, value: str | None) -> dict | None: return None return {"user": data.get("u")} + def read(self, cookies: dict) -> dict | None: + """The session of a request's ``cookies``: its own cookie's, else the shared legacy cookie's.""" + session = self.decode(cookies.get(self.cookie_name)) + if session is None and self.cookie_name != SESSION_COOKIE: + session = self.decode(cookies.get(SESSION_COOKIE)) + return session + def cookie(self, user: str | None, ttl_s: int = SESSION_TTL_S) -> dict: """The cookie a browser sets to be signed in as ``user``, as a browser automation adds one; its domain is left to whoever adds it.""" value, expires = self.encode(user, ttl_s) - return {"name": SESSION_COOKIE, "value": value, "path": "/", "expires": expires, "httpOnly": True, + return {"name": self.cookie_name, "value": value, "path": "/", "expires": expires, "httpOnly": True, "sameSite": "Lax"} def _sign(self, payload: str) -> str: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/store.py b/packages/agentenv-protocol/src/agentenv_protocol/website/store.py index ce21ad84..63c316a7 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/store.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/store.py @@ -1,12 +1,19 @@ """The default state store of a simple site: named collections of JSON objects, each with an ``id``. Its data is a JSON object of collections by name, beside which a string ``current_user`` names the user a browser starts signed in as. It remembers the state it was last loaded with, so a verifier can read the initial state, the current one and the -difference between them.""" +difference between them. + +A store also keeps sessions, as CUA-Gym sites do, so browsers that each name a session id (``?sid=``) never see one +another's changes: ``session(sid)`` is a store of its own, started on first use as a copy of this store's initial +state, and the session ``default``, or none, is this store itself. ``reset`` drops every session; a session started +before a later ``load`` keeps the state it was started with.""" from __future__ import annotations import base64 +import contextvars import copy import json +import re from pathlib import Path from typing import Any, Iterable from urllib.parse import unquote, urlparse @@ -14,6 +21,17 @@ from ..types import DataPart, FilePart, Part, TextPart CURRENT_USER = "current_user" +DEFAULT_SID = "default" +SID_MAX = 128 +_NOT_SID = re.compile(r"[^A-Za-z0-9_-]") +active_sid: contextvars.ContextVar[str | None] = contextvars.ContextVar("agentenv_website_sid", default=None) + + +def session_id(value: str | None) -> str | None: + """The session id ``value`` names, as CUA-Gym sites read ``sid``: its letters, digits, ``_`` and ``-``, at most + ``SID_MAX`` of them; None, the default session, for one that is empty or ``default``.""" + sid = _NOT_SID.sub("", value or "")[:SID_MAX] + return None if sid in ("", DEFAULT_SID) else sid class Collections: @@ -23,6 +41,7 @@ def __init__(self, data: dict | None = None) -> None: self._collections: dict[str, list[dict]] = {} self.current_user: str | None = None self.initial: dict = {} + self._sessions: dict[str, Collections] = {} if data: self.load(data) self.mark_initial() @@ -30,6 +49,24 @@ def __init__(self, data: dict | None = None) -> None: def __getitem__(self, name: str) -> list[dict]: return self._collections.setdefault(name, []) + def session(self, sid: str | None) -> Collections: + """The store of session ``sid``, started as a copy of this one's initial state on first use; this store + itself for the default session.""" + sid = session_id(sid) + if sid is None: + return self + if sid not in self._sessions: + self._sessions[sid] = Collections(self.initial) + return self._sessions[sid] + + def sessions(self) -> list[str]: + """The ids of the sessions started since the store was last reset, the default one aside.""" + return list(self._sessions) + + def changed_sessions(self) -> list[str]: + """The ids of the sessions, the default one aside, whose state differs from the one they started with.""" + return [sid for sid, store in self._sessions.items() if store.diff()] + def names(self) -> list[str]: return list(self._collections) @@ -73,12 +110,13 @@ def load(self, data: dict) -> None: def load_parts(self, parts: Iterable[Part]) -> None: for part in parts: - self.load(_part_data(part)) + self.load(part_data(part)) def reset(self) -> None: self._collections.clear() self.current_user = None self.initial = {} + self._sessions.clear() def export(self) -> dict: data: dict = copy.deepcopy(self._collections) @@ -90,34 +128,45 @@ def mark_initial(self) -> None: self.initial = self.export() def diff(self) -> dict: - """Each collection that changed since the initial state, as its ``added``, ``removed`` and ``changed`` - objects, matched by ``id``; a changed one as its ``id``, ``before`` and ``after``.""" - current = self.export() - changes: dict = {} - for name in dict.fromkeys([*self.initial, *current]): - if name == CURRENT_USER: - continue - before = {_key(d): d for d in self.initial.get(name, [])} - after = {_key(d): d for d in current.get(name, [])} - entry = { - "added": [d for k, d in after.items() if k not in before], - "removed": [d for k, d in before.items() if k not in after], - "changed": [{"id": d.get("id"), "before": before[k], "after": d} - for k, d in after.items() if k in before and before[k] != d], - } - if any(entry.values()): - changes[name] = entry - return changes + return state_diff(self.initial, self.export()) def state(self) -> dict: - return {"initial_state": copy.deepcopy(self.initial), "current_state": self.export(), "state_diff": self.diff()} + return verifier_state(self.initial, self.export()) + + +def state_diff(initial: dict, current: dict, key: Any = None) -> dict: + """Each collection that changed from ``initial`` to ``current``, as its ``added``, ``removed`` and ``changed`` + objects, matched by ``key(name, doc)``, their ``id`` by default; a changed one as its ``id``, ``before`` and + ``after``.""" + key = key or (lambda name, doc: _key(doc)) + changes: dict = {} + for name in dict.fromkeys([*initial, *current]): + if name == CURRENT_USER: + continue + before = {key(name, d): d for d in initial.get(name, [])} + after = {key(name, d): d for d in current.get(name, [])} + entry = { + "added": [d for k, d in after.items() if k not in before], + "removed": [d for k, d in before.items() if k not in after], + "changed": [{"id": d.get("id"), "before": before[k], "after": d} + for k, d in after.items() if k in before and before[k] != d], + } + if any(entry.values()): + changes[name] = entry + return changes + + +def verifier_state(initial: dict, current: dict, key: Any = None) -> dict: + """What ``/go`` answers: ``{initial_state, current_state, state_diff}``.""" + return {"initial_state": copy.deepcopy(initial), "current_state": current, + "state_diff": state_diff(initial, current, key)} def _key(doc: dict) -> str: return json.dumps(doc.get("id")) if doc.get("id") is not None else json.dumps(doc, sort_keys=True) -def _part_data(part: Part) -> dict: +def part_data(part: Part) -> dict: """A part's JSON: a data part's data, a text part's text, or a file part's bytes or ``file://`` file.""" if isinstance(part, DataPart): return part.data diff --git a/packages/agentenv-protocol/tests/test_website_conformance.py b/packages/agentenv-protocol/tests/test_website_conformance.py index 14c789fd..7dad9f21 100644 --- a/packages/agentenv-protocol/tests/test_website_conformance.py +++ b/packages/agentenv-protocol/tests/test_website_conformance.py @@ -6,6 +6,7 @@ import datetime from pathlib import Path +import httpx import pytest from starlette.applications import Starlette from starlette.requests import Request @@ -17,7 +18,7 @@ from agentenv_protocol.website import conformance from agentenv_protocol.website.clock import UNIVERSE_NOW_HEADER, iso -CHECKS = ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"] +CHECKS = ["card", "any_host", "health", "universe_now", "reserved_paths", "data_plane", "clock", "forwarded", "cookies"] def statuses(results: list[conformance.Result]) -> dict[str, str]: @@ -121,10 +122,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("/items", items), Route("/items/", items_slash), Route("/.well-known/agent-env.json", card), Route("/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 @@ -137,7 +142,7 @@ def test_a_handwritten_server_meeting_the_contract_passes() -> None: def test_a_server_breaking_the_contract_fails_those_checks() -> None: results = statuses(conformance.check("http://testserver", client=TestClient(_handwritten(leak_host=True, domain_cookie=True, clock=False)))) - assert results["forwarded"] == results["cookies"] == results["clock"] == "failed" + assert results["forwarded"] == results["cookies"] == results["clock"] == "warning" assert results["card"] == results["health"] == results["data_plane"] == "passed" @@ -146,13 +151,13 @@ def test_the_forwarded_check_follows_no_redirect_and_reads_its_location(sdk_site assert forwarded.status == "passed" and "/api/notes/" in forwarded.detail leaked = {r.name: r for r in conformance.check("http://testserver", client=TestClient(_handwritten(leak_redirect=True)))}["forwarded"] - assert leaked.status == "failed" and "Location of /items/" in leaked.detail + assert leaked.status == "warning" and "Location of /items/" in leaked.detail def test_universe_now_is_checked_on_the_answers_of_every_check() -> None: result = {r.name: r for r in conformance.check("http://testserver", client=TestClient(_handwritten(stamp_rpc=False)))}["universe_now"] - assert result.status == "failed" and "/agentenv" in result.detail + assert result.status == "warning" and "/agentenv" in result.detail def test_a_reset_that_clears_nothing_fails_the_data_plane_and_leaves_the_data() -> None: @@ -189,22 +194,85 @@ def test_an_sdk_site_keeps_its_initial_state_and_one_changed_since_skips_the_rou assert sdk_site.get("/go").json() == changed -def test_a_server_with_no_card_health_or_header_fails_them() -> None: +def test_a_named_session_with_changes_skips_the_round_trip_and_keeps_its_state( + notes: WebsiteEnvironment, sdk_site: TestClient) -> None: + sdk_site.get("/api/notes", params={"sid": "task-1"}) + assert {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["data_plane"].status == "passed" + notes.store.session("task-2")["notes"].append({"id": 2, "text": "draft"}) + result = {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["data_plane"] + assert result.status == "skipped" and "named sessions" in result.detail + assert sdk_site.get("/go", params={"sid": "task-2"}).json()["state_diff"]["notes"]["added"] == [ + {"id": 2, "text": "draft"}] + + +def test_a_server_with_no_card_health_or_header_fails_the_contract_and_warns_of_the_rest() -> None: bare = Starlette(routes=[Route("/", lambda request: JSONResponse({"not": "html"}))]) - results = statuses(conformance.check("http://testserver", client=TestClient(bare))) - assert results["card"] == results["any_host"] == results["health"] == results["universe_now"] == "failed" - assert results["data_plane"] == results["clock"] == "failed" - assert results["cookies"] == "skipped" + results = conformance.check("http://testserver", client=TestClient(bare)) + assert statuses(results) == {"card": "warning", "any_host": "failed", "health": "failed", "universe_now": "warning", + "reserved_paths": "passed", "data_plane": "skipped", "clock": "warning", + "forwarded": "passed", "cookies": "skipped"} + assert [r.name for r in results if r.required] == ["any_host", "health", "reserved_paths", "data_plane"] + + +def test_a_bare_server_meeting_only_the_contract_passes_with_warnings() -> None: + async def page(request: Request) -> Response: + return HTMLResponse("hi") + + bare = Starlette(routes=[Route("/", page), Route("/api/health", lambda request: JSONResponse({"ok": True}))]) + results = conformance.check("http://testserver", client=TestClient(bare)) + assert not any(r.fails() for r in results) and any(r.fails(strict=True) for r in results) + assert {r.name for r in results if r.status == "warning"} == {"card", "universe_now", "clock"} + + +def test_a_spa_fallback_that_serves_the_reserved_paths_fails() -> None: + async def page(request: Request) -> Response: + return HTMLResponse("app") + + spa = Starlette(routes=[Route("/{path:path}", page)]) + reserved = {r.name: r for r in conformance.check("http://testserver", client=TestClient(spa))}["reserved_paths"] + assert reserved.status == "failed" and reserved.detail.startswith("/agentenv answered a page") + + +@pytest.mark.parametrize("exact", ["/agentenv", "/.well-known/agent-env.json"]) +def test_a_page_at_a_reserved_path_itself_fails(exact: str) -> None: + async def page(request: Request) -> Response: + return HTMLResponse("app") + + server = Starlette(routes=[Route("/", page), Route(exact, page)]) + reserved = {r.name: r for r in conformance.check("http://testserver", client=TestClient(server))}["reserved_paths"] + assert reserved.status == "failed" and reserved.detail.startswith(f"{exact} answered a page") def test_main_prints_each_check_and_exits_by_outcome(monkeypatch, capsys) -> None: - monkeypatch.setattr(conformance, "check", lambda url, **options: [conformance.Result("card", "passed"), - conformance.Result("clock", "failed", "late")]) - assert conformance.main(["http://site"]) == 1 + results = [conformance.Result("health", "passed"), conformance.Result("clock", "warning", "late", required=False)] + monkeypatch.setattr(conformance, "check", lambda url, **options: results) + assert conformance.main(["http://site"]) == 0 printed = capsys.readouterr().out - assert "PASS card" in printed and "FAIL clock: late" in printed + assert "PASS health (required)" in printed and "WARN clock (recommended): late" in printed + assert "1 passed, 0 failed, 1 warning(s), 0 skipped" in printed + assert conformance.main(["http://site", "--strict"]) == 1 + results.append(conformance.Result("any_host", "failed", "json")) + assert conformance.main(["http://site"]) == 1 + assert "FAIL any_host (required): json" in capsys.readouterr().out def test_main_exits_2_when_the_server_cannot_be_reached(capsys) -> None: assert conformance.main(["http://127.0.0.1:9", "--timeout", "2"]) == 2 assert "couldn't reach" in capsys.readouterr().err + + +def test_the_cookie_check_holds_the_session_path_to_the_cookie_the_card_names(sdk_site: TestClient) -> None: + card = sdk_site.get("/.well-known/agent-env.json").json() + for extension in card["capabilities"]["extensions"]: + if extension["uri"] == "urn:agentenv:website/v1": + extension["params"]["session_cookie"] = "agentenv_session_other" + real = sdk_site.get + + def get(url, **kwargs): + if url.endswith("/.well-known/agent-env.json"): + return httpx.Response(200, json=card, request=httpx.Request("GET", url)) + return real(url, **kwargs) + + sdk_site.get = get + cookies = {r.name: r for r in conformance.check("http://testserver", client=sdk_site)}["cookies"] + assert cookies.status == "warning" and "not the card's agentenv_session_other" in cookies.detail diff --git a/packages/agentenv-protocol/tests/test_website_database.py b/packages/agentenv-protocol/tests/test_website_database.py new file mode 100644 index 00000000..21e70abe --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_database.py @@ -0,0 +1,196 @@ +"""A site of ``@website(models=...)``: its state is a database's, SQLAlchemy models' tables on DATABASE_URL (sqlite +here) or an object's own load/export/reset, which its data plane and /go read and write, and its routes reach +through ``self.db``.""" + +from __future__ import annotations + +import datetime +import sys +import types +from pathlib import Path + +import pytest +from starlette.testclient import TestClient + +from agentenv_protocol.website import ApiContext, WebsiteEnvironment, api, conformance, website + +sqlalchemy = pytest.importorskip("sqlalchemy") +from sqlalchemy import DateTime, ForeignKey, Integer, String, select # noqa: E402 +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column # noqa: E402 + +RPC = "/agentenv" + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String) + + +class Ticket(Base): + __tablename__ = "tickets" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner: Mapped[str] = mapped_column(ForeignKey("users.id")) + title: Mapped[str] = mapped_column(String) + opened_at: Mapped[datetime.datetime] = mapped_column(DateTime) + + +SEED = {"current_user": "ann", "users": [{"id": "ann", "name": "Ann"}], + "tickets": [{"id": 1, "owner": "ann", "title": "printer", "opened_at": "2031-01-02T03:04:05"}]} + + +def rpc(client: TestClient, method: str, params: dict | None = None) -> dict: + body = client.post(RPC, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}).json() + assert "error" not in body, body + return body["result"] + + +@pytest.fixture +def dist(tmp_path: Path) -> Path: + (tmp_path / "index.html").write_text("desk") + return tmp_path + + +@pytest.fixture +def database(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str: + url = f"sqlite:///{tmp_path / 'service.db'}" + monkeypatch.setenv("DATABASE_URL", url) + return url + + +def helpdesk(dist: Path, models: object) -> type[WebsiteEnvironment]: + @website(domains=["desk.example"], static=str(dist), models=models) + class Helpdesk(WebsiteEnvironment): + @api.get("/api/tickets") + def tickets(self, ctx: ApiContext) -> list: + with self.db() as session: + return [{"id": t.id, "title": t.title} for t in session.scalars( + select(Ticket).where(Ticket.owner == ctx.user).order_by(Ticket.id))] + + @api.post("/api/tickets", status=201) + def open_ticket(self, body: dict, ctx: ApiContext) -> dict: + with self.db.begin() as session: + ticket = Ticket(owner=ctx.user, title=body["title"], opened_at=self.now().replace(tzinfo=None)) + session.add(ticket) + session.flush() + return {"id": ticket.id} + + return Helpdesk + + +def test_the_data_plane_loads_exports_and_resets_the_models_tables(dist: Path, database: str) -> None: + env = helpdesk(dist, Base)() + client = TestClient(env.create_app()) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": SEED}]}) + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + assert client.get("/api/session").json()["user"] == "ann" + assert client.get("/api/tickets").json() == [{"id": 1, "title": "printer"}] + rpc(client, "data/reset") + assert rpc(client, "data/get")["parts"][0]["data"] == {"users": [], "tickets": []} + + +def test_a_row_the_gui_adds_is_the_mcp_servers_and_go_reports_it(dist: Path, database: str) -> None: + env = helpdesk(dist, Base)() + client = TestClient(env.create_app()) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": SEED}]}) + assert client.post("/api/tickets", json={"title": "badge"}).json() == {"id": 2} + server = sqlalchemy.create_engine(database) + with server.connect() as connection: + assert connection.execute(select(Ticket.title).order_by(Ticket.id)).scalars().all() == ["printer", "badge"] + connection.execute(sqlalchemy.update(Ticket).where(Ticket.id == 1).values(title="printer jam")) + connection.commit() + state = client.get("/go", params={"sid": "default"}).json() + assert state["initial_state"] == SEED + diff = state["state_diff"]["tickets"] + assert [t["title"] for t in diff["added"]] == ["badge"] and diff["removed"] == [] + assert diff["changed"][0]["before"]["title"] == "printer" and diff["changed"][0]["after"]["title"] == "printer jam" + assert client.get("/go", params={"sid": "other"}).json() == state + + +def test_a_models_site_passes_the_conformance_check(dist: Path, database: str) -> None: + client = TestClient(helpdesk(dist, Base)().create_app()) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": SEED}]}) + results = conformance.check("http://testserver", client=client) + assert all(r.status == "passed" for r in results), results + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + + +def test_models_may_be_a_metadata_or_a_module_and_refuse_anything_else(dist: Path, database: str) -> None: + module = types.ModuleType("helpdesk_models") + module.Base, module.Ticket = Base, Ticket + for models in (Base.metadata, module): + assert helpdesk(dist, models)()._tables.metadata is Base.metadata + with pytest.raises(TypeError, match="models="): + helpdesk(dist, object()) + with pytest.raises(ValueError, match="data/add"): + website(models=Base, data="seed.json") + + +def test_unknown_tables_and_a_missing_database_url_are_refused(dist: Path, monkeypatch: pytest.MonkeyPatch, + database: str) -> None: + client = TestClient(helpdesk(dist, Base)().create_app()) + body = client.post(RPC, json={"jsonrpc": "2.0", "id": 1, "method": "data/add", + "params": {"parts": [{"kind": "data", "data": {"orders": []}}]}}).json() + assert "error" in body + monkeypatch.delenv("DATABASE_URL") + with pytest.raises(RuntimeError, match="DATABASE_URL"): + helpdesk(dist, Base)().db() + + +def test_a_database_url_whose_driver_isnt_installed_names_it(dist: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "psycopg2", None) + monkeypatch.setitem(sys.modules, "psycopg", None) + monkeypatch.setenv("DATABASE_URL", "postgresql://desk@localhost/service") + with pytest.raises(ImportError, match=r"postgresql:// needs the database driver psycopg2?, "): + helpdesk(dist, Base)().db() + + +def test_an_object_with_its_own_hooks_is_the_sites_data(dist: Path) -> None: + class Rows: + def __init__(self) -> None: + self.rows: list = [] + + def load(self, data: dict) -> None: + self.rows += data["rows"] + + async def export(self) -> dict: + return {"rows": list(self.rows)} + + def reset(self) -> None: + self.rows.clear() + + rows = Rows() + env = helpdesk(dist, rows)() + client = TestClient(env.create_app()) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": {"current_user": "bo", "rows": [{"id": 1}]}}]}) + rows.rows.append({"id": 2}) + assert rpc(client, "data/get")["parts"][0]["data"] == {"current_user": "bo", "rows": [{"id": 1}, {"id": 2}]} + assert client.get("/go").json()["state_diff"] == {"rows": {"added": [{"id": 2}], "removed": [], "changed": []}} + assert env.acting_user() == "bo" + assert env.db is None + + +def test_editing_a_row_a_hook_still_owns_leaves_the_initial_state(dist: Path) -> None: + class Rows: + def __init__(self) -> None: + self.rows: list = [] + + def load(self, data: dict) -> None: + self.rows += data["rows"] + + def export(self) -> dict: + return {"rows": list(self.rows)} + + def reset(self) -> None: + self.rows.clear() + + rows = Rows() + client = TestClient(helpdesk(dist, rows)().create_app()) + rpc(client, "data/add", {"parts": [{"kind": "data", "data": {"rows": [{"id": 1, "status": "open"}]}}]}) + rows.rows[0]["status"] = "closed" + assert client.get("/go").json()["state_diff"] == {"rows": {"added": [], "removed": [], "changed": [ + {"id": 1, "before": {"id": 1, "status": "open"}, "after": {"id": 1, "status": "closed"}}]}} diff --git a/packages/agentenv-protocol/tests/test_website_environment.py b/packages/agentenv-protocol/tests/test_website_environment.py index a1629f88..c867d051 100644 --- a/packages/agentenv-protocol/tests/test_website_environment.py +++ b/packages/agentenv-protocol/tests/test_website_environment.py @@ -35,6 +35,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: @@ -223,6 +224,39 @@ def test_go_reports_initial_current_and_diff_state(client: TestClient) -> None: "changed": []}} +def test_each_sid_keeps_a_state_of_its_own_started_from_the_initial_state(client: TestClient, + shop: WebsiteEnvironment) -> None: + client.post("/api/orders", json={"sku": "mug"}) + assert TestClient(shop.asgi).get("/go", params={"sid": "task-7"}).json() == { + "initial_state": SEED, "current_state": SEED, "state_diff": {}, "changed_sessions": []} + page = client.get("/", params={"sid": "task-7"}) + assert page.headers["set-cookie"].startswith("agentenv_sid_Shop=task-7;") + assert "set-cookie" not in client.get("/", params={"sid": "task-7"}).headers + client.post("/api/orders", json={"sku": "pen"}) + client.post("/api/orders", params={"sid": "other"}, json={"sku": "cup"}) + added = {sid: [o.get("sku") for d in [client.get("/go", params={"sid": sid}).json()["state_diff"]] + for o in d.get("orders", {}).get("added", [])] for sid in ("default", "task-7", "other")} + assert added == {"default": ["mug"], "task-7": ["pen"], "other": ["cup"]} + assert client.get("/go").json()["state_diff"]["orders"]["added"][0]["sku"] == "cup" + client.get("/", params={"sid": "default"}) + assert client.get("/go").json()["state_diff"]["orders"]["added"][0]["sku"] == "mug" + assert rpc(client, "data/get")["parts"][0]["data"]["orders"][-1]["sku"] == "mug" + assert sorted(shop._store.sessions()) == ["other", "task-7"] + assert sorted(client.get("/go").json()["changed_sessions"]) == ["other", "task-7"] + rpc(client, "data/reset") + assert shop._store.sessions() == [] and client.get("/go", params={"sid": "task-7"}).json()["current_state"] == {} + + +def test_a_session_id_is_sanitised_as_cua_gym_sites_do() -> None: + from agentenv_protocol.website.store import session_id + + assert [session_id(v) for v in (None, "", "default", "../a b", "x" * 200)] == [None, None, None, "ab", "x" * 128] + store = Collections({"items": [{"id": 1}]}) + store.session("s")["items"].append({"id": 2}) + assert store.session("default") is store and store.export() == {"items": [{"id": 1}]} + assert store.session("s").diff() == {"items": {"added": [{"id": 2}], "removed": [], "changed": []}} + + def test_broad_routes_leave_the_built_in_ones_answering_their_paths(dist: Path) -> None: class Broad(site(dist)): @api.get("/{slug}") @@ -266,13 +300,13 @@ 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" assert client.delete("/api/session").json() == {"user": None, "role": "default", "signed_in": False} assert client.get("/api/session").json()["user"] is None - client.cookies.set(SESSION_COOKIE, "forged.signature") + client.cookies.set(COOKIE, "forged.signature") assert client.get("/api/session").json()["user"] == "alice" @@ -290,11 +324,45 @@ def test_the_session_operation_mints_cookies_for_a_user_or_signed_out(shop: Webs minted = client.post("/agentenv/ext/website/session", json={"user": "bob"}).json() assert minted["user"] == "bob" and minted["expires_at"].endswith("Z") (cookie,) = minted["cookies"] - assert cookie["name"] == SESSION_COOKIE and "domain" not in cookie and cookie["path"] == "/" - browser = TestClient(shop.asgi, cookies={SESSION_COOKIE: cookie["value"]}) + assert cookie["name"] == COOKIE and "domain" not in cookie and cookie["path"] == "/" + browser = TestClient(shop.asgi, cookies={COOKIE: cookie["value"]}) assert browser.get("/api/session").json()["user"] == "bob" signed_out = client.post("/agentenv/ext/website/session", json={}).json()["cookies"][0]["value"] - assert TestClient(shop.asgi, cookies={SESSION_COOKIE: signed_out}).get("/api/session").json()["user"] is None + assert TestClient(shop.asgi, cookies={COOKIE: signed_out}).get("/api/session").json()["user"] is None + + +def test_each_site_keeps_its_own_session_cookie_and_reads_the_legacy_one(dist: Path, monkeypatch) -> None: + monkeypatch.setenv("ENVIRONMENT_NAME", "shop a/1") + first = site(dist)() + monkeypatch.setenv("ENVIRONMENT_NAME", "shop-b") + second = site(dist)() + for env in (first, second): + env.store.load(SEED) + assert (first.session_cookie, second.session_cookie) == ("agentenv_session_shop_a_1", "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()) == {"agentenv_session_shop_a_1", "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"] == "agentenv_session_shop_a_1" + legacy = first._session_signer().encode("bob")[0] + assert TestClient(first.asgi, cookies={SESSION_COOKIE: legacy}).get("/api/orders").json() == [{"id": 1, "user": "bob"}] + + +def test_no_site_s_session_choice_is_another_site_s_sign_in(dist: Path, monkeypatch) -> None: + monkeypatch.setenv("ENVIRONMENT_NAME", "notes") + notes = site(dist)() + monkeypatch.setenv("ENVIRONMENT_NAME", "notes_sid") + notes_sid = site(dist)() + browser = TestClient(notes.create_app()) + browser.post("/api/orders", params={"sid": "task-1"}, json={"sku": "mug"}) + other = TestClient(notes_sid.create_app(), cookies=browser.cookies) + other.post("/api/sign-in", json={"user": "bob"}) + back = TestClient(notes.asgi, cookies=other.cookies) + assert [o["sku"] for o in back.get("/go").json()["current_state"]["orders"]] == ["mug"] + assert TestClient(notes_sid.asgi, cookies=back.cookies).get("/api/session").json()["user"] == "bob" def test_every_answer_carries_the_universe_time(client: TestClient) -> None: @@ -308,13 +376,16 @@ def test_every_answer_carries_the_universe_time(client: TestClient) -> None: class _Clock: """A gateway-shaped /clock/time whose time a test sets.""" - def __init__(self, virtual: str) -> None: - self.virtual = virtual + def __init__(self, virtual: str, rate: object = None) -> None: + self.virtual, self.rate = virtual, rate outer = self class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: - body = json.dumps({"virtual_time": outer.virtual}).encode() + answer = {"virtual_time": outer.virtual} + if outer.rate is not None: + answer["virtual_seconds_per_real_second"] = outer.rate + body = json.dumps(answer).encode() self.send_response(200) self.send_header("Content-Length", str(len(body))) self.end_headers() @@ -473,8 +544,8 @@ async def _clock_rates(monkeypatch) -> None: real, virtual = [0.0], [datetime.datetime(2031, 1, 1, tzinfo=datetime.timezone.utc)] - async def read(url: str) -> datetime.datetime: - return virtual[0] + async def read(url: str) -> tuple[datetime.datetime, None]: + return virtual[0], None monkeypatch.setattr(clock_module.time, "monotonic", lambda: real[0]) monkeypatch.setattr(clock_module, "_read", read) @@ -502,6 +573,39 @@ def tick(seconds: float, virtual_seconds: float) -> None: assert clock._rate == 100.0 and clock.now() == virtual[0] +def test_the_clock_extrapolates_at_the_rate_the_clock_answers_with(monkeypatch) -> None: + asyncio.run(_answered_rates(monkeypatch)) + + +async def _answered_rates(monkeypatch) -> None: + from agentenv_protocol.website import UniverseClock + + gateway = _Clock("2031-01-01T00:00:00Z", rate=60) + try: + assert await clock_module._read(gateway.url) == (parse_time("2031-01-01T00:00:00Z"), 60.0) + for unusable in ("fast", True, -1, float("inf")): + gateway.rate = unusable + assert (await clock_module._read(gateway.url))[1] is None + finally: + gateway.close() + real, answers = [0.0], [(parse_time("2031-01-01T00:00:00Z"), 60.0)] + + async def read(url: str) -> tuple[datetime.datetime, float | None]: + return answers[0] + + monkeypatch.setattr(clock_module.time, "monotonic", lambda: real[0]) + monkeypatch.setattr(clock_module, "_read", read) + clock = UniverseClock() + await clock.sync("http://gateway/clock/time") + real[0] += 0.5 + assert clock._rate == 60.0 and clock.now() == answers[0][0] + datetime.timedelta(seconds=30) + real[0] += 0.5 + answers[0] = (parse_time("2031-02-01T00:00:00Z"), 0.0) + await clock.refresh() + real[0] += 10 + assert clock._rate == 0.0 and clock.now() == answers[0][0] + + def test_a_refresh_of_the_old_clock_never_overwrites_a_sync_to_a_new_one(monkeypatch) -> None: asyncio.run(_refresh_during_sync(monkeypatch)) @@ -512,12 +616,12 @@ async def _refresh_during_sync(monkeypatch) -> None: old, new = (datetime.datetime(year, 1, 1, tzinfo=datetime.timezone.utc) for year in (2031, 2040)) release, held = asyncio.Event(), [] - async def read(url: str) -> datetime.datetime: + async def read(url: str) -> tuple[datetime.datetime, None]: if url == "http://old/clock/time": if held: await release.wait() - return old - return new + return old, None + return new, None monkeypatch.setattr(clock_module, "_read", read) monkeypatch.setattr(clock_module, "REFRESH_S", 0.0) diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index e02147b1..a41d4a76 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -18,7 +18,7 @@ from starlette.responses import PlainTextResponse from starlette.testclient import TestClient -from agentenv_protocol.website import (ROLE_KEY_ENV, ROLE_KEY_HEADER, SESSION_COOKIE, UNIVERSE_NOW_HEADER, WebsiteEnvironment, +from agentenv_protocol.website import (ROLE_KEY_ENV, ROLE_KEY_HEADER, UNIVERSE_NOW_HEADER, WebsiteEnvironment, conformance, website) NOTES_DIR = Path(__file__).resolve().parents[1] / "examples" / "website" / "notes" @@ -104,7 +104,8 @@ def gateway_clock(): def test_the_example_passes_every_conformance_check(client: TestClient) -> None: results = conformance.check("http://testserver", client=client) assert {r.name: r.status for r in results} == dict.fromkeys( - ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"], "passed"), results + ["card", "any_host", "health", "universe_now", "reserved_paths", "data_plane", "clock", "forwarded", + "cookies"], "passed"), results def test_the_card_declares_the_site_its_health_path_and_its_routes_by_name(client: TestClient) -> None: @@ -121,7 +122,7 @@ def test_the_card_declares_the_site_its_health_path_and_its_routes_by_name(clien def test_a_browser_is_signed_in_as_the_acting_user_and_can_sign_in_as_another(client: TestClient) -> None: session = client.get("/api/session") assert session.json() == {"user": "ann", "role": "default", "signed_in": True} - assert SESSION_COOKIE in session.cookies + assert "agentenv_session_notes" in session.cookies assert [n["title"] for n in client.get("/api/notes").json()] == ["Buy milk", "Book dentist"] assert client.post("/api/sign-in", json={"user": "ben"}).json() == {"user": "ben"} assert client.get("/api/session").json()["user"] == "ben" @@ -196,6 +197,15 @@ def test_the_seed_loads_exports_and_resets_through_the_data_plane(client: TestCl assert state["state_diff"]["notes"]["changed"][0]["after"]["done"] is True + +def test_a_page_opened_at_a_sid_keeps_that_sessions_notes_apart(client: TestClient, env: WebsiteEnvironment) -> None: + other = TestClient(env.asgi) + other.get("/", params={"sid": "task-1"}) + created = other.post("/api/notes", json={"title": "only in task-1"}).json() + assert client.get("/go", params={"sid": "task-1"}).json()["state_diff"]["notes"]["added"] == [created] + assert client.get("/go", params={"sid": "default"}).json()["state_diff"] == {} + assert all(note["title"] != "only in task-1" for note in client.get("/api/notes").json()) + GATEWAY_PREFIX = "/website/notes" diff --git a/src/agent_env/env/gateway/clock.py b/src/agent_env/env/gateway/clock.py index cceb1436..dda1dd7e 100644 --- a/src/agent_env/env/gateway/clock.py +++ b/src/agent_env/env/gateway/clock.py @@ -217,11 +217,13 @@ def at_elapsed(self, elapsed: float) -> datetime | None: return _advance(self._t0, elapsed - self._lived + self._ahead) def read(self) -> dict: - """The frozen consumer contract: {"virtual_time": ""} — pure read, never advances.""" - current = self.now() - if current is None: - raise ClockError("clock is not armed") - return {"virtual_time": _iso(current)} + """The frozen consumer contract: {"virtual_time": ""} — pure read, never advances — plus the rate it + runs at, "virtual_seconds_per_real_second", so a reader extrapolates between reads without guessing it.""" + with self._lock: + if not self._armed or self._t0 is None: + raise ClockError("clock is not armed") + current = _advance(self._t0, (self._now() - self._anchor) * self._rate + self._ahead) + return {"virtual_time": _iso(current), "virtual_seconds_per_real_second": self._rate} def state(self) -> dict: with self._lock: diff --git a/tst/unit/env/gateway/clock_advance_test.py b/tst/unit/env/gateway/clock_advance_test.py index 0a76031f..53bdbfba 100644 --- a/tst/unit/env/gateway/clock_advance_test.py +++ b/tst/unit/env/gateway/clock_advance_test.py @@ -165,3 +165,22 @@ def test_route_refuses_a_backwards_advance(gateway): gateway._clock.set_time("2026-01-04T08:00:00Z", rate=0) resp = _put(gateway, "/clock/advance", {"virtual_time": T0}) assert resp.status_code == 400 and "only moves forward" in resp.json()["error"] + + +def _get(gw: Gateway, path: str) -> httpx.Response: + async def _do() -> httpx.Response: + transport = httpx.ASGITransport(app=gw._mcp.streamable_http_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://gateway") as client: + return await client.get(path) + + return asyncio.run(_do()) + + +def test_clock_time_answers_the_rate_beside_the_time(gateway): + assert _get(gateway, "/clock/time").status_code == 404 + gateway._clock.set_time(T0, rate=60) + gateway._clock._now.tick(2) + assert _get(gateway, "/clock/time").json() == {"virtual_time": "2026-01-01T00:02:00Z", + "virtual_seconds_per_real_second": 60.0} + gateway._clock.advance("2026-01-02T00:00:00Z") + assert _get(gateway, "/clock/time").json()["virtual_seconds_per_real_second"] == 60.0 diff --git a/uv.lock b/uv.lock index 7b050c38..5450b8de 100644 --- a/uv.lock +++ b/uv.lock @@ -288,6 +288,9 @@ computer = [ { name = "pillow" }, { name = "uvicorn" }, ] +db = [ + { name = "sqlalchemy" }, +] dev = [ { name = "a2a-sdk", extra = ["http-server"] }, { name = "av", version = "18.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, @@ -299,6 +302,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "python-multipart" }, { name = "regex" }, + { name = "sqlalchemy" }, ] [package.metadata] @@ -322,12 +326,14 @@ requires-dist = [ { name = "python-multipart", marker = "extra == 'dev'", specifier = ">=0.0.13" }, { name = "regex", marker = "extra == 'agent'", specifier = ">=2023.12.25" }, { name = "regex", marker = "extra == 'dev'", specifier = ">=2023.12.25" }, + { name = "sqlalchemy", marker = "extra == 'db'", specifier = ">=2.0" }, + { name = "sqlalchemy", marker = "extra == 'dev'", specifier = ">=2.0" }, { name = "starlette", specifier = ">=0.37" }, { name = "uvicorn", marker = "extra == 'agent'", specifier = ">=0.30" }, { name = "uvicorn", marker = "extra == 'app'", specifier = ">=0.30" }, { name = "uvicorn", marker = "extra == 'computer'", specifier = ">=0.30" }, ] -provides-extras = ["agent", "app", "computer", "dev"] +provides-extras = ["agent", "app", "computer", "db", "dev"] [[package]] name = "aiofile" @@ -6509,7 +6515,7 @@ name = "sqlalchemy" version = "2.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.13.14'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/36/dc/9addbf490110b5727380059ee542ad8353bdcee45a37ba6a5e0976966ae0/sqlalchemy-2.1.6.tar.gz", hash = "sha256:499eae5c9051efe96cb9a73ec2fbde8ffa4186f102b9094ebab8fcc9d3b99f52", size = 10589700, upload-time = "2026-10-21T16:25:06.497Z" } wheels = [