Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
280862a
feat(website): live sites with @session, and browser/v1's cookies (FD…
williamloo-scale Nov 2, 2026
c267f23
feat(website): capture a site to WARC and serve it as ReplayWebsite (…
williamloo-scale Nov 2, 2026
14e895d
feat(website): the Playwright MCP browser serves browser/v1's cookies…
williamloo-scale Nov 2, 2026
72293be
feat(website): sign the browser in to live sites, mark live runs not …
williamloo-scale Nov 2, 2026
30e2ac2
fix(website): the merged website env docstring, and @website's declar…
williamloo-scale Nov 2, 2026
9f0d275
fix(website): a card says signs_in only for a live site, so a synthet…
williamloo-scale Nov 2, 2026
987704f
Merge wloo/website-authoring-cli into wloo/website-live-replay
williamloo-scale Nov 2, 2026
ce194e6
Merge wloo/website-authoring-cli into wloo/website-live-replay
williamloo-scale Nov 2, 2026
0c0d164
test(website): a website browser's browser/v1 is the one whose cookie…
williamloo-scale Nov 2, 2026
c555d97
Merge wloo/website-authoring-cli into wloo/website-live-replay
williamloo-scale Nov 2, 2026
877f06d
Merge wloo/website-authoring-cli into wloo/website-live-replay
williamloo-scale Nov 2, 2026
5db1e34
Merge wloo/website-authoring-cli into wloo/website-live-replay
williamloo-scale Nov 3, 2026
7d091de
fix(website): a website setting is one quoted environment entry of it…
williamloo-scale Nov 3, 2026
f77fd85
fix(website): a replay's built-in routes and data follow the capture …
williamloo-scale Nov 3, 2026
1f2fbe6
fix(website): a computer deployed after a live site is checked agains…
williamloo-scale Nov 3, 2026
1837172
Merge wloo/website-authoring-cli into wloo/website-live-replay
williamloo-scale Nov 3, 2026
4a249ec
Merge wloo/website-authoring-cli into wloo/website-live-replay
williamloo-scale Nov 3, 2026
21209ea
fix(website): a replay behind the gateway's prefix serves its capture…
williamloo-scale Nov 3, 2026
cd70e07
fix(website): the logged compose masks every website setting (FD-4683)
williamloo-scale Nov 3, 2026
a3f2044
fix(website): a replay loads a capture from the signed https URL a pl…
williamloo-scale Nov 3, 2026
14571e2
Merge wloo/website-authoring-cli into wloo/website-live-replay
williamloo-scale Nov 3, 2026
5e9c264
fix(website): a replay behind the gateway's prefix keeps its redirect…
williamloo-scale Nov 3, 2026
8ecf20d
fix(website): browser/v1 cookie changes run one at a time (FD-4683)
williamloo-scale Nov 3, 2026
b592968
Merge wloo/website-authoring-cli into wloo/website-live-replay
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
14 changes: 14 additions & 0 deletions packages/agentenv-protocol/examples/website/replay/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ARG REPLAY=packages/agentenv-protocol/examples/website/replay

FROM mirror.gcr.io/library/python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf
ARG REPLAY
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY packages/agentenv-protocol /tmp/agentenv-protocol
RUN pip install --no-cache-dir /tmp/agentenv-protocol "uvicorn>=0.30" && rm -rf /tmp/agentenv-protocol
COPY ${REPLAY}/site.py ${REPLAY}/capture.warc.gz /app/
ENV PORT=8000
EXPOSE 8000
RUN useradd --create-home --uid 10001 appuser
USER appuser
CMD ["python", "site.py"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**/node_modules
**/dist
**/__pycache__
**/.pytest_cache
**/.venv
.git
73 changes: 73 additions & 0 deletions packages/agentenv-protocol/examples/website/replay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Widget Docs: a captured site, replayed

Widget Docs is a small docs site captured to a WARC file and served by
`agentenv_protocol.website.replay.ReplayWebsite`. The captured pages are served exactly as they were captured, for any
host. The newsletter sign-up, which a capture can't answer, is written as `@api` routes on top.

| File | What it is |
| --- | --- |
| `source/` | The site that was captured: a home page, two guide pages, a stylesheet and a script. |
| `capture.warc.gz` | The capture of `source/` at `--depth 1`: `/`, `/guide/install.html`, `/guide/usage.html?utm_source=home`, `/style.css` and `/app.js`. `/guide/advanced.html` is two links from home, so it isn't in the capture. |
| `Dockerfile`, `env.toml` | The server image, built from the repo root as the Notes example's is, and the website env as a bundle entry. |
| `site.py` | The server: `WidgetDocs(ReplayWebsite)` with `capture = "capture.warc.gz"`, plus `POST /api/subscribe` and `GET /api/subscriptions`. |

## Capture a site

```bash
python -m agentenv_protocol.website.replay capture https://docs.example.com/ --out capture.warc.gz --depth 2
```

The tool needs only this package and `httpx`. It fetches each start URL. It then fetches the same-site pages those URLs
link to, up to `--depth` links away, and the assets (stylesheets, scripts, images) each captured page loads. Every
answer goes into a WARC 1.1 file, gzipped one record at a time, which pywb and warcio also read. Notes:

- Only GETs are captured.
- Redirects are kept as they were answered, and their targets are captured too.
- Bodies are stored decoded, so the stored headers leave out `Content-Encoding` and `Transfer-Encoding`.
- A host is the same site as a start URL when it is that URL's host (without `www.`) or a subdomain of it.
- Request records name only the method, the target and `Host`, so no credential the capturing client sent ends up in
the file.
- `--max-requests` (500 by default) caps a capture, and `--no-assets` leaves out what pages load.

To capture `source/` again:

```bash
python -m http.server -d source 8765 &
python -m agentenv_protocol.website.replay capture http://127.0.0.1:8765/ --out capture.warc.gz --depth 1
```

## How a request is matched

A request is matched by its method (HEAD as GET), its path and its query, never by its host or headers. The query is
normalised before matching:

- Its parameters are sorted.
- Blank parameters are kept.
- The `utm_*`, `fbclid`, `gclid`, `msclkid` and `_` parameters are dropped. Set `ignore_query` on the class to change
this list.

Each path segment is percent-decoded and then encoded again, so `%7E` and `~` match. A request the capture doesn't
hold gets a 404. If a capture holds one request twice, the first answer is served.

When the capture's answers are served:

- `Set-Cookie` loses its `Domain` attribute, so the cookie works on the replay's own host.
- A `Location` that points at a captured host is made relative.
- `Strict-Transport-Security` and `Alt-Svc` are dropped.

## Synthetic routes on top

`@api` routes win over the capture. Here `POST /api/subscribe` keeps sign-ups in the default store. The data plane
exports that store, `data/reset` empties it, and `/go` shows a run's changes for the website state verifier. The
capture itself isn't task state. A `data/add` file part named `*.warc` or `*.warc.gz` (or typed `application/warc`)
replaces the declared capture until the next `data/reset`. This lets a large capture be loaded at deploy time instead
of being built into the image.

## Run and test it

```bash
PYTHONPATH=<repo>/packages/agentenv-protocol/src python site.py # serves on PORT, 8000 by default
python -m agentenv_protocol.website.replay serve capture.warc.gz --domain docs.widgets.test # a bare replay
python -m pytest packages/agentenv-protocol/tests/test_website_replay.py
docker build -f packages/agentenv-protocol/examples/website/replay/Dockerfile -t widget-docs . # from the repo root
```
Binary file not shown.
3 changes: 3 additions & 0 deletions packages/agentenv-protocol/examples/website/replay/env.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
type = "website"
environment_name = "widget-docs"
backend_image = { dockerfile = "Dockerfile" }
38 changes: 38 additions & 0 deletions packages/agentenv-protocol/examples/website/replay/site.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Widget Docs, replayed: the pages of a captured docs site, served as they were captured for any host, with the
newsletter sign-up the capture can't answer written as ``@api`` routes on top. The sign-ups are the site's state: the
data plane exports them, and ``/go`` shows what a run changed."""
from __future__ import annotations

from pydantic import BaseModel

from agentenv_protocol import environment_card
from agentenv_protocol.website import BadRequest, Conflict, api, website
from agentenv_protocol.website.replay import ReplayWebsite


class Subscription(BaseModel):
email: str


@environment_card(name="widget-docs")
@website(domains=["docs.widgets.test"])
class WidgetDocs(ReplayWebsite):
capture = "capture.warc.gz"

@api.post("/api/subscribe", status=201)
def subscribe(self, body: Subscription) -> dict:
"""Sign an email address up for the newsletter."""
if "@" not in body.email:
raise BadRequest("that is not an email address")
if self.store.find("subscriptions", email=body.email):
raise Conflict(f"{body.email} is subscribed already")
return self.store.insert("subscriptions", {"email": body.email, "at": self.now().isoformat()})

@api.get("/api/subscriptions")
def subscriptions(self) -> list[dict]:
"""Everyone signed up."""
return self.store["subscriptions"]


if __name__ == "__main__":
WidgetDocs().serve()
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
document.getElementById("subscribe").addEventListener("submit", async (event) => {
event.preventDefault();
const email = new FormData(event.target).get("email");
const answer = await fetch("api/subscribe", {
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({email}),
});
document.getElementById("subscribed").textContent = answer.ok ? `Subscribed ${email}` : "Try again";
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Advanced</title></head>
<body><h1>Advanced</h1><p>Two links from the home page, so a capture at --depth 1 leaves it out.</p></body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Install</title><link rel="stylesheet" href="../style.css"></head>
<body>
<h1>Install</h1>
<pre>pip install widgets</pre>
<p><a href="usage.html">Next: usage</a> &middot; <a href="../">Home</a></p>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Usage</title><link rel="stylesheet" href="../style.css"></head>
<body>
<h1>Usage</h1>
<pre>import widgets; widgets.spin()</pre>
<p><a href="advanced.html">Advanced</a> &middot; <a href="../">Home</a></p>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Widget Docs</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Widget Docs</h1>
<p>Everything about widgets.</p>
<ul>
<li><a href="guide/install.html">Install</a></li>
<li><a href="guide/usage.html?utm_source=home">Usage</a></li>
<li><a href="https://elsewhere.example/">Another site</a></li>
</ul>
<form id="subscribe">
<input name="email" type="email" placeholder="you@example.com">
<button>Subscribe</button>
</form>
<p id="subscribed"></p>
<script src="app.js"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
body { font-family: sans-serif; max-width: 40rem; margin: 2rem auto; }
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
package, and uvicorn to serve. The class is optional: a server in any language
that meets the contract works, which ``python -m agentenv_protocol.website.conformance <url>`` checks. A site's pages
can call its API with ``client/site-client.js``, a dependency-free ES module (with its ``.d.ts``) to copy into them:
``api()``, ``universeNow()`` from ``X-Universe-Now``, ``session()``, ``signIn()`` and ``signOut()``."""
``api()``, ``universeNow()`` from ``X-Universe-Now``, ``session()``, ``signIn()`` and ``signOut()``. A live site,
``@website(domains=[...], live=True, allow_hosts=[...])``, serves none of its pages and signs a browser in with
``@session``; ``agentenv_protocol.website.replay`` captures a real site and serves the capture as ``ReplayWebsite``."""

from __future__ import annotations

from .browser import BROWSER_EXTENSION_URI, CLEAR_COOKIES, SET_COOKIES, Cookie
from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, UniverseClock
from .environment import (
DEFAULT_HEALTH_PATH,
Expand All @@ -16,7 +19,9 @@
WEBSITE_EXTENSION_URI,
WebsiteDeclaration,
WebsiteEnvironment,
host_problem,
ready_check,
session,
website,
)
from .errors import ApiError, BadRequest, Conflict, Forbidden, NotFound, Unauthorized
Expand All @@ -26,7 +31,8 @@
from .store import Collections

__all__ = [
"WebsiteEnvironment", "WebsiteDeclaration", "website", "api", "ApiContext", "ApiRoute", "ready_check",
"WebsiteEnvironment", "WebsiteDeclaration", "website", "api", "ApiContext", "ApiRoute", "ready_check", "session",
"host_problem", "Cookie", "BROWSER_EXTENSION_URI", "SET_COOKIES", "CLEAR_COOKIES",
"ApiError", "BadRequest", "Unauthorized", "Forbidden", "NotFound", "Conflict",
"Collections", "UniverseClock", "SessionSigner", "reserved_path",
"WebsiteSpec", "SpecError", "NameTable", "load_spec", "from_card", "from_environment", "name_table",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""``urn:agentenv:browser/v1``: what a browser env offers agentenv's steps and graders, never its agent. ``cookies/set``
adds cookies to the browser, so its pages start signed in to a site, and ``cookies/clear`` removes them, all of them or
those of some domains. A cookie is ``{name, value, domain, path, secure, httpOnly, expires, sameSite}``, ``expires``
in epoch seconds and absent for a cookie that lasts the browser's session, as a site's ``session`` returns it.

A cookie's value is a credential: ``redacted`` is what a log or a record keeps of one, and the errors here name a
cookie by its position and field, never by what it holds."""
from __future__ import annotations

from typing import Any, Iterable, Literal, Optional

from pydantic import BaseModel, ConfigDict, Field, ValidationError

BROWSER_EXTENSION_URI = "urn:agentenv:browser/v1"
SET_COOKIES = "cookies/set"
CLEAR_COOKIES = "cookies/clear"


class Cookie(BaseModel):
"""One cookie as a browser adds it, for ``domain`` and ``path``; a leading dot on ``domain`` covers its
subdomains too."""

model_config = ConfigDict(extra="forbid")

name: str = Field(min_length=1)
value: str
domain: str = Field(min_length=1)
path: str = "/"
secure: bool = False
httpOnly: bool = False
expires: Optional[float] = None
sameSite: Optional[Literal["Strict", "Lax", "None"]] = None

def key(self) -> tuple[str, str, str]:
"""What a browser keeps one cookie under: a later one with the same key replaces it."""
return self.name, self.domain.lower(), self.path

def matches(self, domains: Iterable[str]) -> bool:
"""Whether the cookie is for one of ``domains`` or a subdomain of one."""
return domain_matches(self.domain, domains)


def domain_matches(domain: str, domains: Iterable[str]) -> bool:
"""Whether a cookie's ``domain`` is one of ``domains`` or a subdomain of one, leading dots aside."""
host = domain.lstrip(".").lower()
return any(host == d or host.endswith("." + d) for d in (d.lstrip(".").lower() for d in domains))


def cookies(raw: Any, default_domain: Optional[str] = None) -> list[Cookie]:
"""``raw``, a list of cookie objects, as Cookies; one without a domain takes ``default_domain``. Raises ValueError
naming each problem by the cookie's index and field, never by a value."""
if not isinstance(raw, list):
raise ValueError("cookies is a list of cookie objects")
parsed, problems = [], []
for i, item in enumerate(raw):
if not isinstance(item, dict):
problems.append(f"cookies[{i}] is not an object")
continue
if not item.get("domain") and default_domain:
item = {**item, "domain": default_domain}
try:
parsed.append(Cookie.model_validate(item))
except ValidationError as e:
problems += [f"cookies[{i}].{'.'.join(map(str, err['loc'])) or 'cookie'}: {err['msg']}" for err in e.errors()]
if problems:
raise ValueError("; ".join(problems))
return parsed


def redacted(given: Iterable[Cookie]) -> list[dict]:
"""What a record keeps of each cookie: everything but its value."""
return [c.model_dump(exclude={"value"}, exclude_none=True) for c in given]


def storage_state(given: Iterable[Cookie]) -> list[dict]:
"""The cookies as a Playwright storage state file lists them: ``expires`` -1 for a session cookie."""
return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path,
"expires": c.expires if c.expires is not None else -1, "httpOnly": c.httpOnly, "secure": c.secure,
"sameSite": c.sameSite or "Lax"} for c in given]


def devtools(given: Iterable[Cookie]) -> list[dict]:
"""The cookies as Chrome DevTools' ``Storage.setCookies`` takes them."""
out = []
for c in given:
param = {"name": c.name, "value": c.value, "domain": c.domain, "path": c.path, "secure": c.secure,
"httpOnly": c.httpOnly}
if c.expires is not None:
param["expires"] = c.expires
if c.sameSite is not None:
param["sameSite"] = c.sameSite
out.append(param)
return out
Loading
Loading