Skip to content
Merged
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
14 changes: 9 additions & 5 deletions packages/agentenv-protocol/examples/website/notes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ them. The page shows the universe's time.
sends `AgentEnv-Role: admin` with `AgentEnv-Role-Key` equal to the container's `WEBSITE_ROLE_KEY`; any other caller
gets 403. The gateway passes on whatever role a browser sends, so without the key a page could claim `admin`; with no
`WEBSITE_ROLE_KEY` set, every caller is `default` and the route is closed.
- **Users.** `ApiContext.user` is the user in the signed session cookie. With no cookie, it is the acting user (the
data's `current_user`, which `acting_user()` returns), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded
user. A user's notes are invisible to every other user, and another user's note answers 404.
- **Users.** `ApiContext.user` is the user in the signed session cookie, `agentenv_session_notes` (the site's own, so
it doesn't collide with another site's under the same gateway host; the card names it). With no cookie, it is the
acting user (the data's `current_user`, which `acting_user()` returns), and `GET /api/session` signs that user in.
`POST /api/sign-in` switches to another seeded user. A user's notes are invisible to every other user, and another
user's note answers 404.
- **State.** The site keeps its users and notes itself, starting from `data/seed.json`, and serves them through its
data hooks: `data/add` adds a part's records by id (a data part, or a file part by bytes or `file://`), `data/get`
exports them, and `data/reset` empties them. `data/get` is how agentenv reads what a task left the site with.
Expand Down Expand Up @@ -65,11 +67,13 @@ the test above runs this repo's.
```bash
docker build -t notes-site .
docker run -d --name notes-site -p 18080:8000 notes-site
python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal
python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal --strict
curl -s http://localhost:18080/api/notes
```

Use `--clock-host` so the container can reach the checker's clock. The clock check leaves the server following a
Use `--clock-host` so the container can reach the checker's clock. `--strict` fails on a recommended check's warning
too (the card, `X-Universe-Now`, the clock, forwarded headers, cookies); without it only the contract's required checks
fail the run, and Notes passes every check either way. The clock check leaves the server following a
clock that is gone once the check ends: later timestamps start from 2031 until the server is synced again, so restart
the container before using it.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in:
``python -m agentenv_protocol.website.conformance <url>`` prints each check and exits 1 when one failed (a warning
doesn't), 2 when the server couldn't be reached. Everything goes over HTTP: the card, ``/`` under a made-up host, readiness by the card's
``website/v1`` ``ready``, whose answer must be a ``WebsiteReadiness`` (by the declared health path, deprecated, for a
card without it), ``X-Universe-Now`` on every answer the other checks got, a data-plane round trip at the card's ``url``
(data/get, data/reset, data/get, which no longer returns the exported data, data/add of what the first data/get
returned, data/get again), ``clock/v1``'s
``sync_time`` against a clock the checker serves, and the forwarded headers wherever an answer shows them, on ``/`` and
on a GET route with its trailing slash toggled, which a server may redirect, and the cookies those answers set:
none tied to a domain, and a warning for one whose lifetime is an absolute ``Expires`` with no ``Max-Age``.
"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in: ``python -m
agentenv_protocol.website.conformance <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 agentenv, the gateway and a browser rely on of a website env's server: a
card declaring the site's domains, readiness and routes; ``/`` serving HTML under a made-up host; the site ready, by
the card's ``website/v1`` ``ready``, whose answer must be a ``WebsiteReadiness`` (by the declared health path,
deprecated, for a card without it); the data plane's and card's paths not the site's pages; and the data plane the
card advertises, whose 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:
``X-Universe-Now`` on every answer the other checks got, ``clock/v1``'s ``sync_time`` against a clock the checker serves, the forwarded headers
wherever an answer shows them (on ``/`` and on a GET route with its trailing slash toggled, which a server may
redirect), and the cookies those answers set: none tied to a domain, the session path's the one the card names, and
none whose lifetime is an absolute ``Expires`` with no ``Max-Age``. A failed one is ``warned``.

Every request goes to the server under check: a card's path or URL naming another scheme, host or port fails the check
that would have used it, with nothing sent there. The round trip leaves the server's data as it found it, adding the
Expand Down Expand Up @@ -44,14 +49,22 @@

@dataclasses.dataclass(frozen=True)
class Result:
"""One check's outcome; ``required`` for a check of the contract, whose failure is ``failed``, and not for a
recommended one, whose failure is ``warned``."""

name: str
status: Status
detail: str = ""
required: bool = True

@property
def passed(self) -> bool:
return self.status != "failed"

def fails(self, strict: bool = False) -> bool:
"""Whether the result fails the run: a failed check, or with ``strict`` a warned one too."""
return self.status == "failed" or (strict and self.status == "warned")


def check(base_url: str, *, client: httpx.Client | None = None, clock_host: str = "127.0.0.1",
timeout: float = 30) -> list[Result]:
Expand Down Expand Up @@ -92,19 +105,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)),
("clock", lambda: self._clock(card)), ("forwarded", lambda: self._forwarded(site)),
("cookies", lambda: self._cookies(site)))
outcomes = {name: _outcome(step) for name, step in steps if name != "universe_now"}
# (name, required, check): a recommended check's failure is warned.
steps = (("card", True, lambda: self._card(response, card, site)), ("any_host", True, self._any_host),
("health", True, lambda: self._health(card, health)), ("universe_now", False, self._universe_now),
("reserved_paths", True, self._reserved_paths), ("data_plane", True, lambda: self._data_plane(card)),
("clock", False, lambda: self._clock(card)), ("forwarded", False, lambda: self._forwarded(site)),
("cookies", False, lambda: self._cookies(site)))
outcomes = {name: _outcome(step) for name, _required, step in steps if name != "universe_now"}
outcomes["universe_now"] = _outcome(self._universe_now)
return [self._card(response, card, site), *(Result(name, *outcomes[name]) for name, _step in steps)]
results = []
for name, required, _step in steps:
status, detail = outcomes[name]
results.append(Result(name, "warned" if status == "failed" and not required else status, detail, required))
return results

def _card(self, response: httpx.Response, card: dict, site: dict) -> Result:
def _card(self, response: httpx.Response, card: dict, site: dict) -> tuple[Status, str]:
if response.status_code != 200:
return Result("card", "failed", f"{WELL_KNOWN_PATH} answered {response.status_code}")
return "failed", f"{WELL_KNOWN_PATH} answered {response.status_code}"
problems = []
if not site:
problems.append(f"declares no {WEBSITE_EXTENSION_URI} with params")
Expand All @@ -117,7 +137,7 @@ def _card(self, response: httpx.Response, card: dict, site: dict) -> Result:
if not isinstance(routes, list) or not all(isinstance(r, dict) and {"method", "path", "name"} <= set(r)
for r in routes):
problems.append("its params' routes aren't a list of {method, path, name}")
return Result("card", "failed" if problems else "passed", "; ".join(problems))
return ("failed" if problems else "passed"), "; ".join(problems)

def _any_host(self) -> tuple[Status, str]:
host = f"conformance-{uuid.uuid4().hex[:8]}.invalid"
Expand Down Expand Up @@ -159,6 +179,15 @@ def _universe_now(self) -> tuple[Status, str]:
parse_time(response.headers[UNIVERSE_NOW_HEADER])
return "passed", f"on all {len(self.responses)} answers"

def _reserved_paths(self) -> tuple[Status, str]:
paths = [RPC_PATH, WELL_KNOWN_PATH, f"{RPC_PATH}/conformance-{uuid.uuid4().hex[:8]}", f"{RPC_PATH}/",
f"{WELL_KNOWN_PATH}/"]
for path in paths:
response = self.get(path)
if response.is_success and response.headers.get("content-type", "").startswith("text/html"):
return "failed", f"{path} answered a page, but the gateway keeps {RPC_PATH} and {WELL_KNOWN_PATH}"
return "passed", f"no page at {', '.join(paths)}"

def _rpc(self, method: str, params: dict | None = None) -> dict:
response = self._seen(self.client.post(self.rpc_url, json={"jsonrpc": "2.0", "id": method,
"method": method, "params": params or {}}))
Expand All @@ -171,7 +200,8 @@ 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 missing:
return "failed", f"the card's operations lack {', '.join(missing)}"
url = card.get("url")
Expand Down Expand Up @@ -238,14 +268,19 @@ def _forwarded(self, site: dict) -> tuple[Status, str]:
return "passed", f"no answer to {', '.join(paths)} named the internal Host"

def _cookies(self, site: dict) -> tuple[Status, str]:
session = []
if isinstance(site.get("session_path"), str):
self.get(site["session_path"])
session = self.get(site["session_path"]).headers.get_list("set-cookie")
cookies = [c for r in self.responses for c in r.headers.get_list("set-cookie")]
if not cookies:
return "skipped", "no answer set a cookie"
scoped = sorted({c.split("=", 1)[0] for c in cookies if "domain" in _attributes(c)})
if scoped:
return "failed", f"cookies tied to a domain: {', '.join(scoped)}"
named = site.get("session_cookie")
if isinstance(named, str) and session and named not in {c.split("=", 1)[0].strip() for c in session}:
return "failed", (f"{site['session_path']} set {', '.join(c.split('=', 1)[0] for c in session)}, not the "
f"card's {named}")
absolute = sorted({c.split("=", 1)[0] for c in cookies if "expires" in _attributes(c) and "max-age" not in _attributes(c)})
if absolute:
return "warned", (f"cookies with an absolute Expires and no Max-Age, which a browser on another clock "
Expand Down Expand Up @@ -341,17 +376,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, warned or skipped, {failed} failed")
return 1 if failed else 0
kind = "required" if result.required else "recommended"
print(f"{_MARK[result.status]} {result.name} ({kind}){': ' + result.detail if result.detail else ''}")
counts = {status: sum(r.status == status for r in results) for status in _MARK}
print(f"{args.url}: {counts['passed']} passed, {counts['failed']} failed, {counts['warned']} warned, "
f"{counts['skipped']} skipped{' (strict)' if args.strict else ''}")
return 1 if any(r.fails(args.strict) for r in results) else 0


if __name__ == "__main__":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
from .readiness import SITE_READY_TIMEOUT_S, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness
from .routes import (ApiContext, ApiRoute, endpoint, error_response, forwarded_prefix, openapi, openapi_path, request_role,
reserved_path, routes_of)
from .session import SESSION_COOKIE, SessionSigner
from .session import SessionSigner, session_cookie_name

SESSION_PATH = "/api/session"
CHECK_TIMEOUT_S = 10.0
Expand Down Expand Up @@ -114,7 +114,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None:

def __init__(self) -> None:
self.clock = UniverseClock()
self._signer = SessionSigner()
self._signer = SessionSigner(cookie_name=session_cookie_name(self._build_card().name))
self._started = time.monotonic()
self._checking: dict[str, asyncio.Future] = {}

Expand All @@ -126,6 +126,11 @@ def declaration(cls) -> WebsiteDeclaration:
def routes(cls) -> list[ApiRoute]:
return routes_of(cls)

@property
def session_cookie(self) -> str:
"""The name of the site's session cookie, ``agentenv_session_<environment_name>``."""
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."""
Expand All @@ -148,7 +153,7 @@ def acting_user(self) -> str | None:

def context(self, request: Request) -> ApiContext:
"""The ``ApiContext`` of ``request``, for a mounted ``app``'s routes as for ``@api`` ones."""
session = self._session_signer().decode(request.cookies.get(SESSION_COOKIE))
session = self._session_signer().read(request.cookies)
user = session["user"] if session is not None else self.acting_user()
return ApiContext(request, user, request_role(request), session, self._session_signer())

Expand Down Expand Up @@ -186,7 +191,7 @@ def extension_params(self, uri: str) -> dict:
if uri == WEBSITE_EXTENSION_URI:
declaration = self.declaration()
return {"domains": list(declaration.domains),
"routes": [r.advertised() for r in self.routes()], "session_cookie": SESSION_COOKIE,
"routes": [r.advertised() for r in self.routes()], "session_cookie": self.session_cookie,
"session_path": SESSION_PATH}
return super().extension_params(uri)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from ..tool_context import DEFAULT_ROLE, caller_from_request
from ..types import RPC_PATH, WELL_KNOWN_PATH
from .errors import ApiError, BadRequest, Forbidden
from .session import SESSION_COOKIE, SESSION_TTL_S, SessionSigner
from .session import SESSION_TTL_S, SessionSigner

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -154,7 +154,8 @@ def _set_session(self, user: str | None) -> None:

def apply(self, response: Response) -> Response:
for value in self._set_cookie:
response.set_cookie(SESSION_COOKIE, value, max_age=SESSION_TTL_S, path="/", httponly=True, samesite="lax")
response.set_cookie(self._signer.cookie_name, value, max_age=SESSION_TTL_S, path="/", httponly=True,
samesite="lax")
return response


Expand Down
Loading
Loading