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
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
77 changes: 77 additions & 0 deletions packages/agentenv-protocol/examples/website/replay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# 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 as they were captured, for any host,
with only their same-site links rewritten to stay on the replay. 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, with the
stylesheets, images and fonts a captured stylesheet's `@import` and `url()` name. 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.
- In HTML pages (`href`, `src`, `srcset`, `action`, `formaction`, `poster`, `style` and `<style>`) and stylesheets
(`url()`, `@import`), a URL on a captured host is made root-relative, and a root-relative URL is led by the prefix
the replay is served under (`X-Forwarded-Prefix`, such as `/website/<name>`). Script bodies aren't changed.
- `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 through `data/get`, which is how a run's sign-ups are read, and `data/reset` empties it. 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's ``data/get`` exports them."""
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 @@ -6,17 +6,22 @@
is ready is its ``WebsiteReadiness`` answer to ``ready``, which ``website_readiness`` reads of one site and
``env_readiness`` of every site of an env. What a task left a site with is its data plane's ``data/get``. 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,
SESSION_PATH,
WebsiteDeclaration,
WebsiteEnvironment,
host_problem,
ready_check,
session,
website,
)
from .errors import ApiError, BadRequest, Conflict, Forbidden, NotFound, Unauthorized
Expand All @@ -27,7 +32,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,96 @@
"""``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; a wildcard
domain as ``@website`` declares it, ``*.example.com``, covers ``example.com`` and every name under it, since a
cookie for ``example.com`` is sent to those names too."""
host = domain.lstrip(".").lower()
bases = (d.lower().removeprefix("*.").lstrip(".") for d in domains)
return any(host == d or host.endswith("." + d) for d in bases)


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