Repository navigation
feat(website): WebsiteEnvironment serves a site on the website/v1 contract, and a conformance check tests any server (FD-4683) - #428
Conversation
…tract, and a conformance check tests any server (FD-4683) A site declares itself with @website(domains, static, health) and its routes with @api, which bind path, query and body from type hints and take an ApiContext with the signed-in user, role and public URL. It serves its built pages, its health path, the data plane and a card that declares website/v1 with its routes, an OpenAPI document and a session operation. self.now() reads the universe clock, and each response carries X-Universe-Now. A site on the built-in Collections store loads, exports and resets its state and serves /go without data hooks. python -m agentenv_protocol.website.conformance URL checks a running server over HTTP alone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…E_KEY (FD-4683) The gateway forwards the role a browser sends on /website/<name>/, so a page or a fetch could claim any role and call a roles= route. ctx.role is now the AgentEnv-Role a caller names only when it also sends AgentEnv-Role-Key equal to the site's WEBSITE_ROLE_KEY, compared in constant time; every other caller, and every caller of a site with no key set, is default. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ruff's F401 failed the unit job's lint step on this branch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…4683) main's sync_env_clock (#442) tells every synced server the task's timezone and fails the step on one whose sync_time can't take it; websites are synced servers here, so WebsiteEnvironment.sync_time takes an optional timezone, advertised on the card. The clock keeps it and the site reads it as self.timezone; now() stays UTC. A later sync naming none drops it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…(FD-4683) WebsiteEnvironment serves website/v1's ready (GET): ready, and the problems, the names of the @ready_check methods not yet true, as computer-app/v1's ready does of an app. A deploy reads it through the card; the health path still answers for a reader that hasn't moved. Conformance's health check reads ready when the card advertises it, and the health path, noted as deprecated, when it doesn't. A framework test keeps the URI the deploy reads the one the SDK serves. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…on, and nested models are components (FD-4683) An Optional body takes JSON null and a union of models takes any of them; _body_type only decides where a parameter comes from. The OpenAPI document puts every model its bodies and answers name, nested ones too, in components.schemas, so their $refs resolve. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…hat would match them (FD-4683)
The built-in routes are registered before the site's own, so a route such as /{slug} or /api/{name} no longer
swallows them; an @api route at their very path still replaces them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… new one (FD-4683) sync() reads under the lock refresh() holds, so it waits for a refresh in flight and that refresh's read of the old clock can't land after the new clock's or measure a rate across the two. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ructed (FD-4683) clock_patch put a class in Date's place, and calling Date() without new threw. It is now a function that returns the universe's time as a string when called and constructs through Reflect.construct, keeping Date.now at the universe's time, Date.parse, Date.UTC, instanceof Date and subclasses. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…cks reset clears, and checks every answer's time (FD-4683) The data-plane round trip now fails a reset after which data/get still returns the exported data, adds the export back when a step after the reset fails, and is skipped for a server whose /go shows changes since its initial state, which data/add would fold into the baseline. universe_now runs after the other checks, so it reads X-Universe-Now on the data-plane, clock, forwarded and session answers too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in #428's review fixes beside this branch's: the clock's sync reads under the refresh lock and keeps the answered rate; conformance checks X-Universe-Now last, on every answer, at the recommended level, with the reserved-path probes counted; the handwritten test server stamps its 404s and 405s like every other answer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… 2xx JSON-RPC result (FD-4683)
_rpc took any JSON object without an error member as success, so a server
answering {} (even with a 500) to every data request passed data_plane.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d bodies as validated (FD-4683) Response schemas were built in validation mode, so a returned model with a computed field or a type-changing serializer was published with the wrong shape. Models the modes see alike keep one shared component. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| if _adapter(returns) is not None: | ||
| media = {} | ||
| operation["responses"][str(route.status)]["content"] = {"application/json": media} | ||
| schemas.append((media, returns, "serialization")) |
There was a problem hiding this comment.
Bodyless responses promise JSON
openapi() advertises a JSON response body for a typed handler even when its declared status is 204 or 304. But endpoint() drops that body. A delete route returning a model with status=204 therefore publishes a response that clients cannot receive. Check _has_body(route.status) before adding response content.
| if _adapter(returns) is not None: | |
| media = {} | |
| operation["responses"][str(route.status)]["content"] = {"application/json": media} | |
| schemas.append((media, returns, "serialization")) | |
| if _has_body(route.status) and _adapter(returns) is not None: | |
| media = {} | |
| operation["responses"][str(route.status)]["content"] = {"application/json": media} | |
| schemas.append((media, returns, "serialization")) |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agentenv-protocol/src/agentenv_protocol/website/routes.py
Line: 337-340
Comment:
**Bodyless responses promise JSON**
`openapi()` advertises a JSON response body for a typed handler even when its declared status is `204` or `304`. But `endpoint()` drops that body. A delete route returning a model with `status=204` therefore publishes a response that clients cannot receive. Check `_has_body(route.status)` before adding response content.
```suggestion
if _has_body(route.status) and _adapter(returns) is not None:
media = {}
operation["responses"][str(route.status)]["content"] = {"application/json": media}
schemas.append((media, returns, "serialization"))
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| asgi.router.routes.extend(Route(r.path, endpoint(getattr(self, r.fn.__name__), r, self.context), | ||
| methods=[r.method]) for r in api_routes) | ||
| asgi.router.default = _Fallback(root, type(self).app) |
There was a problem hiding this comment.
Reserved paths serve site pages
A broad route such as @api.get('/{rest:path}') catches /agentenv/anything before _Fallback can reject it. Registered control routes remain protected, but unknown control paths become site pages instead of errors. Reject reserved request paths before calling site handlers, not only in the fallback.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agentenv-protocol/src/agentenv_protocol/website/environment.py
Line: 171-173
Comment:
**Reserved paths serve site pages**
A broad route such as `@api.get('/{rest:path}')` catches `/agentenv/anything` before `_Fallback` can reject it. Registered control routes remain protected, but unknown control paths become site pages instead of errors. Reject reserved request paths before calling site handlers, not only in the fallback.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Stacked on #427.
agentenv_protocol.website: the easy path to a synthetic website that follows website/v1 (urn:agentenv:website/v1).@api.get/post/put/patch/delete(path, name=, roles=, status=): path, query and body are bound from type hints, errors are exceptions mapped to statuses, and an OpenAPI 3.1 document is generated.ApiContext: the signed-in user (else the acting user, so the agent starts signed in), the caller's role, and the public URL fromX-Forwarded-Host/-Proto.AgentEnv-Rolea browser sends, so a site believes a non-defaultrole only when the request also carriesAgentEnv-Role-Keyequal to the site'sWEBSITE_ROLE_KEY(compared in constant time). Every other caller, and every caller of a site with no key set, isdefault, soroles=routes are closed until a key is set.index.htmlfallback, the health path with@ready_check, the data plane and a card declaring website/v1 with its routes andopenapi/sessionoperations./agentenv…routes are matched before the site's own.self.now()reads the universe clock (clock/v1sync_time); every response carriesX-Universe-Now;clock_patch=Truepatches the page'sDate.sync_timealso takes main's optionaltimezone(feat(sync_env_clock): an optional timezone reads virtual_time in the zone and tells every synced server (FD-5003) #442), which the site reads asself.timezone;now()stays UTC.Collectionsstore: load, export, reset, initial/current/diff state and/gowith no data hooks written.python -m agentenv_protocol.website.conformance URLchecks any server over HTTP: card, any host name, health, universe time, data plane, clock, forwarded headers, cookies.Known gaps
Each is fixed later in the stack, as noted.
WEBSITE_ROLE_KEYon a deployed site yet, and the gateway still trustsAgentEnv-Roleon MCP calls, non-SDK sites and website trigger attribution. A per-deploy key in the gateway is the full fix (feat(gateway): a keyed deploy's role key, forwarded headers, and triggers by route (FD-4683) #443, opt-in withkeyed_roles).X-Forwarded-*, so behind it sites must use relative URLs (feat(gateway): a keyed deploy's role key, forwarded headers, and triggers by route (FD-4683) #443).X-Universe-Nowand a filled-in card, stricter than a non-SDK server may be (feat(website): a session cookie per site, /go?sid=, the clock's rate, conformance levels and models= (FD-4683) #444 splits required from recommended)./goignoressid(feat(website): a session cookie per site, /go?sid=, the clock's rate, conformance levels and models= (FD-4683) #444).Test
Unit suite and protocol tests: 12,659 passed, lint clean.
packages/agentenv-protocol/tests: 946 passed; the website tests (59, with the Notes ones) pass after the role-key fix. Deployed locally behind the gateway, a spoofedAgentEnv-Role: adminnow gets 403 on both/website/and/svc/(200 before).🤖 Generated with Claude Code


Confidence Score: 5/5
The PR appears safe to merge with two non-blocking contract issues.Fix with agent prompt
Summary
Adds
WebsiteEnvironment, typed API routes, signed sessions, a collection store, universe time, and an HTTP conformance checker.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD Request[HTTP request] --> Middleware[Forwarded address and universe time] Middleware --> Control[Registered control routes] Control --> Builtins[Health, session and state routes] Builtins --> API[Site API routes] API --> Fallback[Static files, mounted app or page fallback]Reviews (4) · Last reviewed commit: "Merge wloo/website-harness-gaps into wlo..." · Reviewed by Greptile