Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/agentenv-protocol/examples/website/notes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ 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
Expand Down Expand Up @@ -66,11 +67,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.

Expand Down
7 changes: 7 additions & 0 deletions packages/agentenv-protocol/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ computer = [
"Pillow>=10.4",
"uvicorn>=0.30",
]
# A website whose state is an MCP server's database: @website(models=<SQLAlchemy models>) and self.db, on a
# postgresql:// DATABASE_URL through psycopg 3, SQLAlchemy 2.1's driver for it.
db = [
"sqlalchemy>=2.1",
"psycopg[binary]>=3.1",
]
Comment thread
williamloo-scale marked this conversation as resolved.
Resolved
dev = [
"a2a-sdk[http-server]>=0.3.26,<0.4",
"python-multipart>=0.0.13",
Expand All @@ -59,6 +65,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.1", # the db extra's models= site, tested on sqlite
]
[project.urls]
Homepage = "https://www.agentenvframework.com"
Expand Down
Original file line number Diff line number Diff line change
@@ -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": <RFC3339>}``,
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
Expand All @@ -11,6 +12,7 @@
import datetime
import json
import logging
import math
import time

import httpx
Expand Down Expand Up @@ -60,8 +62,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
Expand All @@ -76,12 +78,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) <= RATE_AGREEMENT * max(measured, self._measured, 1.0):
self._rate = measured
Expand All @@ -94,11 +98,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:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in:
``python -m agentenv_protocol.website.conformance <url>`` 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``, 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.
"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in: ``python -m
agentenv_protocol.website.conformance <url>`` 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``, 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 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, 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.
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."""
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
Expand All @@ -36,21 +41,30 @@
from .environment import DEFAULT_HEALTH_PATH
from .readiness import WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness

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]:
Expand Down Expand Up @@ -84,20 +98,27 @@ def run(self) -> list[Result]:
card = response.json() if response.status_code == 200 else {}
except ValueError:
card = {}
card = card if isinstance(card, dict) else {}
self.rpc_url = _rpc_url(self.base_url, card)
site = (find_extension(card, WEBSITE_EXTENSION_URI) or {}).get("params") or {}
health = site.get("health") if isinstance(site.get("health"), str) else DEFAULT_HEALTH_PATH
steps = (("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))
Comment thread
williamloo-scale marked this conversation as resolved.
Resolved
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")
Expand All @@ -109,7 +130,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"
Expand Down Expand Up @@ -151,6 +172,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.rpc_url, json={"jsonrpc": "2.0", "id": method,
"method": method, "params": params or {}}))
Expand All @@ -163,7 +192,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)}"
exported = self._rpc(METHOD_GET).get("parts") or []
Expand Down Expand Up @@ -224,14 +256,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"


Expand Down Expand Up @@ -286,7 +322,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)))
Expand All @@ -306,7 +342,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:
Expand All @@ -315,17 +351,20 @@ 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)
except httpx.TransportError as e:
print(f"couldn't reach {args.url}: {type(e).__name__}: {e}", file=sys.stderr)
return 2
for result in results:
print(f"{_MARK[result.status]} {result.name}{': ' + result.detail if result.detail else ''}")
failed = sum(not r.passed for r in results)
print(f"{args.url}: {len(results) - failed} passed 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__":
Expand Down
Loading
Loading