Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
24226a9
feat(website): each site's session cookie is its own, agentenv_sessio…
williamloo-scale Nov 2, 2026
518def7
feat(website): /go?sid= answers that session's state, each sid a stor…
williamloo-scale Nov 2, 2026
6d8b3a0
feat(clock): /clock/time answers its rate, which a website follows in…
williamloo-scale Nov 2, 2026
5788d92
feat(website): conformance splits the contract's required checks from…
williamloo-scale Nov 2, 2026
58fd231
feat(website): @website(models=...) keeps a site's state in an MCP se…
williamloo-scale Nov 2, 2026
283bdf4
Merge wloo/website-gateway-auth into wloo/website-sdk-followups
williamloo-scale Nov 2, 2026
211f3a5
Merge wloo/website-gateway-auth into wloo/website-sdk-followups
williamloo-scale Nov 2, 2026
29bad0b
Merge wloo/website-gateway-auth into wloo/website-sdk-followups
williamloo-scale Nov 2, 2026
ac9aac5
Merge wloo/website-gateway-auth into wloo/website-sdk-followups
williamloo-scale Nov 3, 2026
be069f9
fix(website): the reserved-paths check reads /agentenv and the card p…
williamloo-scale Nov 3, 2026
6ef3bd9
fix(website): a DATABASE_URL whose driver isn't installed names the d…
williamloo-scale Nov 3, 2026
882ce10
Merge wloo/website-gateway-auth into wloo/website-sdk-followups
williamloo-scale Nov 3, 2026
1acd9bd
fix(website): a models site's initial state survives edits to rows it…
williamloo-scale Nov 3, 2026
f3ab19d
Merge wloo/website-gateway-auth into wloo/website-sdk-followups
williamloo-scale Nov 3, 2026
46b08d0
fix(website): a site's store-selection cookie is never another site's…
williamloo-scale Nov 3, 2026
129df30
fix(website): the conformance round trip never drops a named session'…
williamloo-scale Nov 3, 2026
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
12 changes: 8 additions & 4 deletions packages/agentenv-protocol/examples/website/notes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<session>` keeps a state of its own, started
from the seed, which `/go?sid=<session>` 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
Expand Down Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions packages/agentenv-protocol/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ computer = [
"Pillow>=10.4",
"uvicorn>=0.30",
]
# A website whose state is an MCP server's database: @website(models=<SQLAlchemy models>) and self.db.
db = [
"sqlalchemy>=2.0",
]
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 +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"
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 @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down
Loading
Loading