From 8dfabcbb1e27ca00c5a33e19abf67a0de5e8c376 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 13:34:08 -0800 Subject: [PATCH 1/8] feat(website): live sites, browser/v1 sign-in and WARC replay (FD-4683) This PR's change, rebased as one commit onto #446. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/replay/Dockerfile | 14 + .../website/replay/Dockerfile.dockerignore | 6 + .../examples/website/replay/README.md | 77 ++ .../examples/website/replay/capture.warc.gz | Bin 0 -> 4147 bytes .../examples/website/replay/env.toml | 3 + .../examples/website/replay/site.py | 38 + .../examples/website/replay/source/app.js | 8 + .../website/replay/source/guide/advanced.html | 5 + .../website/replay/source/guide/install.html | 9 + .../website/replay/source/guide/usage.html | 9 + .../examples/website/replay/source/index.html | 23 + .../examples/website/replay/source/style.css | 1 + .../src/agentenv_protocol/website/__init__.py | 10 +- .../src/agentenv_protocol/website/browser.py | 93 +++ .../agentenv_protocol/website/environment.py | 106 ++- .../src/agentenv_protocol/website/replay.py | 696 ++++++++++++++++++ .../tests/test_website_live.py | 190 +++++ .../tests/test_website_replay.py | 490 ++++++++++++ .../env/envs/playwright_mcp/entrypoint.sh | 3 + .../playwright_mcp/recorder-requirements.txt | 4 +- .../env/envs/playwright_mcp/recorder.py | 121 ++- src/agent_env/env/envs/website.py | 45 +- .../env_providers/env_gateway_provider.py | 41 +- src/agent_env/task/episode/rules.py | 2 +- src/agent_env/task_step/registry.py | 2 + .../task_step/task_steps/deploy_env.py | 4 +- .../task_step/task_steps/reset_env.py | 6 +- .../task_step/task_steps/sign_in_browser.py | 284 +++++++ .../env/envs/test_playwright_mcp_cookies.py | 159 ++++ .../env/envs/test_playwright_mcp_recorder.py | 5 +- .../env/envs/test_website_env_settings.py | 94 +++ tst/unit/task_step/sign_in_browser_test.py | 246 +++++++ 32 files changed, 2765 insertions(+), 29 deletions(-) create mode 100644 packages/agentenv-protocol/examples/website/replay/Dockerfile create mode 100644 packages/agentenv-protocol/examples/website/replay/Dockerfile.dockerignore create mode 100644 packages/agentenv-protocol/examples/website/replay/README.md create mode 100644 packages/agentenv-protocol/examples/website/replay/capture.warc.gz create mode 100644 packages/agentenv-protocol/examples/website/replay/env.toml create mode 100644 packages/agentenv-protocol/examples/website/replay/site.py create mode 100644 packages/agentenv-protocol/examples/website/replay/source/app.js create mode 100644 packages/agentenv-protocol/examples/website/replay/source/guide/advanced.html create mode 100644 packages/agentenv-protocol/examples/website/replay/source/guide/install.html create mode 100644 packages/agentenv-protocol/examples/website/replay/source/guide/usage.html create mode 100644 packages/agentenv-protocol/examples/website/replay/source/index.html create mode 100644 packages/agentenv-protocol/examples/website/replay/source/style.css create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/browser.py create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/website/replay.py create mode 100644 packages/agentenv-protocol/tests/test_website_live.py create mode 100644 packages/agentenv-protocol/tests/test_website_replay.py create mode 100644 src/agent_env/task_step/task_steps/sign_in_browser.py create mode 100644 tst/unit/env/envs/test_playwright_mcp_cookies.py create mode 100644 tst/unit/env/envs/test_website_env_settings.py create mode 100644 tst/unit/task_step/sign_in_browser_test.py diff --git a/packages/agentenv-protocol/examples/website/replay/Dockerfile b/packages/agentenv-protocol/examples/website/replay/Dockerfile new file mode 100644 index 000000000..c641d704b --- /dev/null +++ b/packages/agentenv-protocol/examples/website/replay/Dockerfile @@ -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"] diff --git a/packages/agentenv-protocol/examples/website/replay/Dockerfile.dockerignore b/packages/agentenv-protocol/examples/website/replay/Dockerfile.dockerignore new file mode 100644 index 000000000..10ae9270c --- /dev/null +++ b/packages/agentenv-protocol/examples/website/replay/Dockerfile.dockerignore @@ -0,0 +1,6 @@ +**/node_modules +**/dist +**/__pycache__ +**/.pytest_cache +**/.venv +.git diff --git a/packages/agentenv-protocol/examples/website/replay/README.md b/packages/agentenv-protocol/examples/website/replay/README.md new file mode 100644 index 000000000..f41fd1bb0 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/replay/README.md @@ -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 `') + headers = [("Content-Type", "text/html"), ("Content-Security-Policy", "style-src 'sha256-abc'"), + ("Content-Security-Policy-Report-Only", "default-src https://shop.example"), ("X-Kept", "1")] + site = TestClient(Shop(warc(("https://shop.example/", 200, headers, page))).create_app()) + served = site.get("/", headers={"X-Forwarded-Prefix": "/website/shop"}) + assert "content-security-policy" not in served.headers + assert "content-security-policy-report-only" not in served.headers + assert served.headers["x-kept"] == "1" + assert served.content == b'' + + +def test_a_replay_keeps_a_pages_and_a_stylesheets_same_site_links_on_the_replay(): + page = ('' + "" + "ahome" + 'awayctop' + '/not-a-link' + '
' + '
é
').encode() + css = b"@import '/t.css'; a{background:url(https://shop.example/bg.png)} b{src:url(f.woff2)}" + site = TestClient(Shop(warc(("https://shop.example/", 200, [("Content-Type", "text/html; charset=utf-8")], page), + ("https://shop.example/style.css", 200, [("Content-Type", "text/css")], css), + ("https://shop.example/app.js", 200, [("Content-Type", "text/javascript")], + b"fetch('/api/x')"))).create_app()) + behind = {"X-Forwarded-Prefix": "/website/shop"} + served = site.get("/", headers=behind) + assert served.content == page.replace(b'"/style.css"', b'"/website/shop/style.css"').replace( + b'url("/b.png")', b'url("/website/shop/b.png")').replace( + b"'https://shop.example/a?x=1&y=2'", b"'/website/shop/a?x=1&y=2'").replace( + b"href=//shop.example>", b"href=/website/shop/>").replace(b"src=/i.png", b"src=/website/shop/i.png").replace( + b'"/i.png 1x, https://shop.example/i2.png 2x"', b'"/website/shop/i.png 1x, /website/shop/i2.png 2x"').replace( + b'"/search"', b'"/website/shop/search"').replace(b'"/go2"', b'"/website/shop/go2"').replace( + b"url('/d.png')", b"url('/website/shop/d.png')") + assert b"var u = '/api/x'; location.href = \"https://shop.example/js\"" in served.content + assert served.headers["content-length"] == str(len(served.content)) + assert site.head("/", headers=behind).headers["content-length"] == str(len(served.content)) + assert site.get("/style.css", headers=behind).content == ( + b"@import '/website/shop/t.css'; a{background:url(/website/shop/bg.png)} b{src:url(f.woff2)}") + assert site.get("/app.js", headers=behind).content == b"fetch('/api/x')" + + direct = site.get("/").content + assert b"'/a?x=1&y=2'" in direct and b'"/style.css"' in direct and b"https://elsewhere.example/z" in direct diff --git a/src/agent_env/env/envs/playwright_mcp/entrypoint.sh b/src/agent_env/env/envs/playwright_mcp/entrypoint.sh index 337885849..68a602103 100644 --- a/src/agent_env/env/envs/playwright_mcp/entrypoint.sh +++ b/src/agent_env/env/envs/playwright_mcp/entrypoint.sh @@ -7,6 +7,8 @@ set -euo pipefail # The recorder serves the browser's card on RECORDER_PORT, restarted whenever it exits; the MCP server starts once # it listens, so the card is there by the time the server is healthy, or after RECORDER_WAIT_S without it. export RECORDER_PORT="${RECORDER_PORT:-18766}" +export PLAYWRIGHT_STORAGE_STATE="${PLAYWRIGHT_STORAGE_STATE:-/app/storage-state.json}" +[ -f "${PLAYWRIGHT_STORAGE_STATE}" ] || (umask 077 && echo '{"cookies": [], "origins": []}' > "${PLAYWRIGHT_STORAGE_STATE}") (while true; do /opt/recorder/bin/python /app/recorder.py || true; sleep 1; done) & for _ in $(seq 1 "$(( ${RECORDER_WAIT_S:-20} * 10 ))"); do (exec 3<>"/dev/tcp/127.0.0.1/${RECORDER_PORT}") 2>/dev/null && break @@ -17,6 +19,7 @@ exec npx @playwright/mcp@${PLAYWRIGHT_MCP_VERSION} \ --browser chromium \ --headless \ --isolated \ + --storage-state "${PLAYWRIGHT_STORAGE_STATE}" \ --no-sandbox \ --host "${MCP_HOST:-0.0.0.0}" \ --port "${MCP_PORT:-18765}" \ diff --git a/src/agent_env/env/envs/playwright_mcp/recorder-requirements.txt b/src/agent_env/env/envs/playwright_mcp/recorder-requirements.txt index 57ae4a2e3..4dc5eef92 100644 --- a/src/agent_env/env/envs/playwright_mcp/recorder-requirements.txt +++ b/src/agent_env/env/envs/playwright_mcp/recorder-requirements.txt @@ -1,4 +1,4 @@ -# The recorder installs the newest agentenv-framework-protocol at or above this floor, the first with computer-recording/v1; +# The recorder installs the newest agentenv-framework-protocol at or above this floor, the first with browser/v1's cookies; # raise it when the recorder needs a newer protocol. -agentenv-framework-protocol[computer]>=0.1.382 +agentenv-framework-protocol[computer]>=0.1.405 websockets==15.0.1 diff --git a/src/agent_env/env/envs/playwright_mcp/recorder.py b/src/agent_env/env/envs/playwright_mcp/recorder.py index c82dbedf2..e289d8a12 100644 --- a/src/agent_env/env/envs/playwright_mcp/recorder.py +++ b/src/agent_env/env/envs/playwright_mcp/recorder.py @@ -2,7 +2,12 @@ screencast frames, on a card and port of its own beside the MCP server. It is idle until a recording starts, which finds the browser through the remote debugging port Playwright opens for an ``--isolated`` Chromium and follows the page the agent is on. It runs standalone in the browser's image, so it imports only -``agentenv-framework-protocol[computer]`` and, to talk to the browser, ``websockets``.""" +``agentenv-framework-protocol[computer]`` and, to talk to the browser, ``websockets``. + +The same card serves browser/v1's ``cookies/set`` and ``cookies/clear``, so a step can start the browser signed in to +a site. Playwright MCP starts each browser context it opens from the storage state file ``--storage-state`` names +(``PLAYWRIGHT_STORAGE_STATE``), and launches its browser only at the agent's first tool call, so the cookies go into +that file, and through DevTools into every context of a browser already running.""" from __future__ import annotations import asyncio @@ -30,6 +35,10 @@ ) from agentenv_protocol.computer.card import COMPUTER_RECORDING from agentenv_protocol.computer.recording import RecordedFrame, RecordHints, Recording, Recordings, recording_params +from agentenv_protocol.errors import AgentEnvError +from agentenv_protocol.website.browser import (BROWSER_EXTENSION_URI, CLEAR_COOKIES, SET_COOKIES, Cookie, + devtools, domain_matches, storage_state) +from agentenv_protocol.website.browser import cookies as parse_cookies logger = logging.getLogger("playwright-mcp-recorder") @@ -43,6 +52,7 @@ #: How long one DevTools command may take. CALL_S = 10.0 JPEG_QUALITY = 80 +DEFAULT_STORAGE_STATE = "/app/storage-state.json" class CdpError(Exception): @@ -222,16 +232,106 @@ async def _show(self, cdp: Cdp, target: Optional[str]) -> None: self._session = session +class BrowserCookies: + """browser/v1's cookies: kept in the storage state file each new browser context starts from, and set in, or + cleared from, every context of the browser that is running, if one is. One change runs at a time, its file + write and DevTools calls together, so a concurrent set or clear never restores what another removed.""" + + def __init__(self, path: Path, find: Callable[[], Optional[int]], + connect: Callable[[int], AsyncContextManager[Cdp]]) -> None: + self.path, self._find, self._connect = path, find, connect + self._lock = asyncio.Lock() + + async def set(self, given: list[Cookie]) -> dict: + async with self._lock: + return await self._set(given) + + async def clear(self, domains: Optional[list[str]]) -> dict: + async with self._lock: + return await self._clear(domains) + + async def _set(self, given: list[Cookie]) -> dict: + kept = {c.key(): c for c in self._read()} + kept.update({c.key(): c for c in given}) + self._write(list(kept.values())) + + async def add(cdp: Cdp, context: dict) -> None: + await cdp.call("Storage.setCookies", {"cookies": devtools(given), **context}) + + return {"set": len(given), "open_browser": await self._each_context(add)} + + async def _clear(self, domains: Optional[list[str]]) -> dict: + before = self._read() + kept = [c for c in before if domains is not None and not c.matches(domains)] + self._write(kept) + + async def remove(cdp: Cdp, context: dict) -> None: + current = [] if domains is None else (await cdp.call("Storage.getCookies", context)).get("cookies") or [] + await cdp.call("Storage.clearCookies", context) + others = [_settable(c) for c in current if not domain_matches(c.get("domain", ""), domains or [])] + if others: + await cdp.call("Storage.setCookies", {"cookies": others, **context}) + + return {"cleared": len(before) - len(kept), "open_browser": await self._each_context(remove)} + + async def _each_context(self, act: Callable[[Cdp, dict], Any]) -> bool: + """``act`` on the default context and each other one of the running browser; False when none runs. An error + names the DevTools call's kind only, since its message may repeat a cookie.""" + port = await asyncio.to_thread(self._find) + if port is None: + return False + try: + async with self._connect(port) as cdp: + contexts = (await cdp.call("Target.getBrowserContexts")).get("browserContextIds") or [] + for context in [{}, *({"browserContextId": c} for c in contexts)]: + await act(cdp, context) + except (OSError, CdpError, httpx.HTTPError, KeyError, ValueError, asyncio.TimeoutError) as e: + raise AgentEnvError("browser_unreachable", f"the running browser didn't take the change " + f"({type(e).__name__}); the next context it opens starts with it", applied="unknown", + status=502) from None + return True + + def _read(self) -> list[Cookie]: + try: + state = json.loads(self.path.read_text(encoding="utf-8")) + except FileNotFoundError: + return [] + return [Cookie.model_validate({**c, "expires": None if c.get("expires", -1) in (-1, None) else c["expires"]}) + for c in state.get("cookies") or []] + + def _write(self, kept: list[Cookie]) -> None: + try: + origins = json.loads(self.path.read_text(encoding="utf-8")).get("origins") or [] + except (FileNotFoundError, ValueError): + origins = [] + staged = self.path.with_name(self.path.name + ".tmp") + staged.write_text(json.dumps({"cookies": storage_state(kept), "origins": origins}), encoding="utf-8") + os.chmod(staged, 0o600) + os.replace(staged, self.path) + + +def _settable(cookie: dict) -> dict: + """A cookie DevTools listed, as ``Storage.setCookies`` takes it back.""" + param = {k: cookie[k] for k in ("name", "value", "domain", "path", "secure", "httpOnly", "sameSite") if k in cookie} + if not cookie.get("session", False) and cookie.get("expires", -1) not in (-1, None): + param["expires"] = cookie["expires"] + return param + + @environment_card(name="playwright-mcp") class BrowserRecorder(AgentEnvEnvironment): - """computer-recording/v1 of the Playwright MCP browser's page; the browser's tools stay Playwright MCP's.""" + """computer-recording/v1 of the Playwright MCP browser's page, and browser/v1's cookies; the browser's tools stay + Playwright MCP's.""" recording = Recording() def __init__(self, find: Callable[[], Optional[int]] = find_browser, - connect: Callable[[int], AsyncContextManager[Cdp]] = connect_cdp) -> None: + connect: Callable[[int], AsyncContextManager[Cdp]] = connect_cdp, + storage_state_path: Optional[Path] = None) -> None: self._find, self._connect = find, connect self.recordings = Recordings(_Host(self)) + path = storage_state_path or Path(os.environ.get("PLAYWRIGHT_STORAGE_STATE", DEFAULT_STORAGE_STATE)) + self.cookies = BrowserCookies(path, find, connect) def extension_params(self, uri: str) -> dict: if uri == COMPUTER_RECORDING: @@ -250,6 +350,21 @@ async def _recording_start(self, request_id: str, scope: str = "display", window async def _recording_end(self, recording_id: str, upload: Optional[dict] = None, reason: str = "end") -> dict: return await self.recordings.end(recording_id, upload, reason) + @extension(BROWSER_EXTENSION_URI, op=SET_COOKIES, + description="The browser's cookies, for agentenv's steps, never its agent: cookies/set adds cookies " + "(name, value, domain, path, secure, httpOnly, expires, sameSite) to every context, open " + "and to come, and cookies/clear removes those of some domains, or all of them.") + async def _cookies_set(self, cookies: list[dict]) -> dict: + try: + given = parse_cookies(cookies) + except ValueError as e: + raise AgentEnvError("invalid_params", str(e)) from None + return await self.cookies.set(given) + + @extension(BROWSER_EXTENSION_URI, op=CLEAR_COOKIES) + async def _cookies_clear(self, domains: Optional[list[str]] = None) -> dict: + return await self.cookies.clear(domains) + async def frames(self, hints: RecordHints) -> AsyncIterator[RecordedFrame]: """The page at up to ``fps``: a frame each time it changes, and the last one again after ``HOLD_S``.""" screencast = Screencast(hints.max_edge, self._find, self._connect) diff --git a/src/agent_env/env/envs/website.py b/src/agent_env/env/envs/website.py index 0cc8ce7d1..c70a5e206 100644 --- a/src/agent_env/env/envs/website.py +++ b/src/agent_env/env/envs/website.py @@ -3,6 +3,10 @@ env.toml may carry the site's website spec, ``spec = "website.toml"`` or an inline ``[spec]`` table, whose site name, health path and domains the env takes. +Its ``settings`` are environment variables of the server's container, a live site's credentials among them: a setting +whose name reads as a secret's (a token, a password, ...) is stored as a ``secret:KEY`` or ``env:NAME`` reference, +never its value, and resolved from the deploying user's config only when the container is written. + A deploy reads whether the site is ready from its env card, the protocol layer: ``website/v1``'s ``ready``, as ``computer-app/v1``'s says of an app. ``health_path`` (env.toml's ``health``) is read only for a site whose card has no ``ready``, or that serves no card, and is deprecated.""" @@ -46,6 +50,9 @@ DEFAULT_HEALTH_PATH = AGENT_ENV_WEBSITE_HEALTH_PATH _HEALTH_PATH = re.compile(r"/[A-Za-z0-9._~/-]*") +_SETTING_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +RESERVED_SETTINGS = frozenset({"DATABASE_URL", "SERVICE_NAME", "ENVIRONMENT_NAME", "PORT", "PYTHONUNBUFFERED", + "WEBSITE_ROLE_KEY"}) class WebsiteEnv(Env): @@ -55,7 +62,8 @@ class WebsiteEnv(Env): EntityRef.artifact("frontend_image", artifact_type="docker_image", optional=True)) toml_keys: ClassVar[dict[str, type]] = {"backend_image": object, "frontend_image": object, "environment_name": str, - "health": str, "env_provider_type": str, "spec": object} + "health": str, "env_provider_type": str, "spec": object, + "settings": dict} def __init__( self, @@ -70,6 +78,7 @@ def __init__( metadata: Optional[dict[str, str]] = None, env_provider_type: str = "gateway", domains: tuple[str, ...] | list[str] = (), + settings: Optional[dict[str, str]] = None, ): super().__init__(id, version, metadata=metadata) if not environment_name: @@ -85,6 +94,9 @@ def __init__( raise ValueError(f"website env {id!r}: {problem}") self.health_path = health_path self.domains = tuple(domains) + if problems := settings_problems(settings or {}): + raise ValueError(f"website env {id!r}: {'; '.join(problems)}") + self.settings = dict(settings or {}) self.environment_name = environment_name self.env_provider_type = env_provider_type self._sandbox = None @@ -117,6 +129,8 @@ def to_dict(self) -> dict: base["service_name"] = self.environment_name base["environment_name"] = self.environment_name # dual-write with service_name during the rename base["env_provider_type"] = self.env_provider_type + if self.settings: + base["settings"] = dict(self.settings) return base @classmethod @@ -134,6 +148,7 @@ def from_dict(cls, data: dict) -> WebsiteEnv: metadata=data.get("metadata", {}), env_provider_type=data.get("env_provider_type", "gateway"), domains=data.get("domains", ()), + settings=data.get("settings"), ) @classmethod @@ -157,7 +172,7 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> WebsiteEnv: environment_name=fields["environment_name"], health_path=fields.get("health", DEFAULT_HEALTH_PATH), env_provider_type=fields["env_provider_type"], - domains=fields.get("domains", ())) + domains=fields.get("domains", ()), settings=fields.get("settings")) def to_toml(self, out: ExportContext) -> dict: """The env.toml of a folder this env's images are built from, which ``from_toml`` reads back as it.""" @@ -169,6 +184,8 @@ def to_toml(self, out: ExportContext) -> dict: toml["health"] = self.health_path if self.domains: toml["spec"] = {"site": {"domains": list(self.domains)}} + if self.settings: + toml["settings"] = dict(self.settings) return {**toml, **self.provider_toml(out, self.env_provider_type)} @classmethod @@ -185,6 +202,9 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: health = data.get("health", DEFAULT_HEALTH_PATH) if isinstance(health, str) and (problem := health_path_problem(health)): ctx.refuse([ctx.config_problem(problem)]) + settings = data.get("settings") + if isinstance(settings, dict) and (problems := settings_problems(settings)): + ctx.refuse([ctx.config_problem(problem) for problem in problems]) fields = ctx.accept_env(data, cls, named_by="backend_image") if spec is not None and spec.site.domains: fields["domains"] = spec.site.domains @@ -435,6 +455,27 @@ def website_spec_of(data: dict, folder: Path) -> tuple[Optional[WebsiteSpec], li return spec, problems +def settings_problems(settings: dict) -> list[str]: + """Why ``settings`` can't be a website env's, each problem naming the setting and never its value: a name is an + environment variable's the gateway doesn't set itself, a value a string, and a secret's a bare ``secret:KEY`` or + ``env:NAME`` reference.""" + from agent_env.config.describe import is_secret_key + from agent_env.config.loader import ENV_REF_PREFIX, SECRET_REF_PREFIX + + problems = [] + for name, value in settings.items(): + if not isinstance(name, str) or not _SETTING_NAME.fullmatch(name) or name in RESERVED_SETTINGS: + problems.append(f"settings.{name}: a setting is an environment variable's name the gateway doesn't set " + f"itself ({', '.join(sorted(RESERVED_SETTINGS))} are its own)") + elif not isinstance(value, str): + problems.append(f"settings.{name} is a string, not a {type(value).__name__}") + elif is_secret_key(name) and not (value.startswith((SECRET_REF_PREFIX, ENV_REF_PREFIX)) and "?" not in value + and value not in (SECRET_REF_PREFIX, ENV_REF_PREFIX)): + problems.append(f"settings.{name} is a secret, so it takes a {SECRET_REF_PREFIX}KEY or {ENV_REF_PREFIX}NAME " + "reference with no ?default, not its value") + return problems + + def health_path_problem(health_path: str) -> str | None: """Why ``health_path`` can't be where a site answers once it's usable, or None: it's a path of plain characters, since the gateway writes it into a health check.""" diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index 319329d45..79aacc7fd 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -127,12 +127,22 @@ class DeployedGateway: class WebsiteConfig: """Configuration for a website in a gateway deployment: its server image, which serves the site's pages and API and says when it's ready by its card's ``website/v1`` ``ready`` (``health_path``, deprecated, for a site whose card - has none), and a deprecated separate frontend image for a site built on one.""" + has none), a deprecated separate frontend image for a site built on one, and the env's ``settings`` as stored, + secrets as their references, which only the compose file holds resolved.""" backend_image: str frontend_image: str | None environment_name: str health_path: str = AGENT_ENV_WEBSITE_HEALTH_PATH + settings: dict[str, str] | None = None + + def env_vars(self) -> dict[str, str]: + """The settings with each ``secret:`` / ``env:`` reference resolved from the deploying user's config.""" + if not self.settings: + return {} + from agent_env.config import get_config + resolve = get_config().resolve_reference + return {name: resolve(value) for name, value in self.settings.items()} @dataclass @@ -544,6 +554,7 @@ def create_docker_compose( f" - PORT={AGENT_ENV_WEBSITE_BACKEND_PORT}", " - PYTHONUNBUFFERED=1", *([f" - {WEBSITE_ROLE_KEY_ENV}={role_key}"] if role_key else []), + *(f" - {compose_env_entry(name, value)}" for name, value in wc.env_vars().items()), " healthcheck:", f' test: ["CMD-SHELL", "curl -sf -o /dev/null http://localhost:{AGENT_ENV_WEBSITE_BACKEND_PORT}{WELL_KNOWN_PATH}' f' || curl -sf -o /dev/null http://localhost:{AGENT_ENV_WEBSITE_BACKEND_PORT}{wc.health_path}"]', @@ -1526,12 +1537,30 @@ class _GatewayTopology: names_gateway_on_vm: bool # a MultiEnv's record names its gateway sandbox even on a VM +def compose_env_entry(name: str, value: str) -> str: + """``name=value`` as one quoted YAML scalar of a compose ``environment:`` list, so the value stays that variable's + whatever it holds: a newline, quote or ``: `` can't start more of the service, and each ``$`` is ``$$``, which + compose doesn't interpolate. The scalar is plain ASCII, any other character written as its escape.""" + escaped = [] + for char in f"{name}={value}".replace("$", "$$"): + if char in '"\\': + escaped.append("\\" + char) + elif " " <= char <= "~": + escaped.append(char) + elif ord(char) <= 0xFFFF: + escaped.append(f"\\u{ord(char):04x}") + else: + escaped.append(f"\\U{ord(char):08x}") + return '"' + "".join(escaped) + '"' + + def website_config(env: WebsiteEnv) -> WebsiteConfig: """A website env as the gateway runs it: its server image, any deprecated frontend image, and its health path.""" frontend = env.frontend_docker_image_artifact return WebsiteConfig(backend_image=env.server_docker_image_artifact.image_name, frontend_image=frontend.image_name if frontend else None, - environment_name=env.environment_name, health_path=env.health_path) + environment_name=env.environment_name, health_path=env.health_path, + settings=dict(env.settings) or None) def _gateway_topology(env: Env) -> _GatewayTopology: @@ -1594,6 +1623,7 @@ def _compose_name_problems(mcp_servers: list[MCPServerConfig], website_configs: # rendered compose is logged. Matches the key to the left of the first '=' on an # ` - KEY=VALUE` env line. _SECRET_ENV_KEY_RE = re.compile(r"^(\s*-\s*)([A-Za-z0-9_]*(?:SECRET|PASSWORD|TOKEN|API_KEY|ROLE_KEY)[A-Za-z0-9_]*)=.*$") +_SETTING_ENV_RE = re.compile(r'^(\s*-\s*")([A-Za-z_][A-Za-z0-9_]*)=.*"$') def _compose_ps_rows(text: str) -> list[dict]: @@ -1624,9 +1654,12 @@ def _why_down(row: dict) -> str | None: def _redact_compose_secrets(compose_content: str) -> str: - """Mask secret-valued env lines so the logged compose never carries a live secret.""" + """Mask secret-valued env lines so the logged compose never carries a live secret, and every website setting's + quoted ``"NAME=VALUE"`` line as ``compose_env_entry`` writes it, whatever its name: its value may be a credential + a ``secret:`` or ``env:`` reference resolved.""" return "\n".join( - _SECRET_ENV_KEY_RE.sub(r"\1\2=***", line) for line in compose_content.splitlines() + _SETTING_ENV_RE.sub(r'\1\2=***"', _SECRET_ENV_KEY_RE.sub(r"\1\2=***", line)) + for line in compose_content.splitlines() ) diff --git a/src/agent_env/task/episode/rules.py b/src/agent_env/task/episode/rules.py index d7409bb28..fc5a77536 100644 --- a/src/agent_env/task/episode/rules.py +++ b/src/agent_env/task/episode/rules.py @@ -151,7 +151,7 @@ def _rubrics_verifier(step: RubricsVerifierTaskStep, outside: Outside) -> list[s "aggregate_verifiers", "apply_server_config", "build_mcp_cli", "combine_universe_verdicts", "configure_scheduled_checks", "deploy_env", "deploy_sandbox", "end_computer_recording", "start_computer_recording", "env_outcome_verifier", "modify_env_tool_access", "reset_env", "review", - "run_container_unit_tests_verifier", "run_docker_container", "snapshot_env", "snapshot_computer_env", "sync_env_clock", + "run_container_unit_tests_verifier", "run_docker_container", "sign_in_browser", "snapshot_env", "snapshot_computer_env", "sync_env_clock", "validation_gate_aggregator", "verify_env_card", "verify_env_core_protocol", "verify_mcp_tool_schema", "verify_spec_conformance", "verify_universe_load_export_roundtrip", # They read only what an outside turn leaves: its reply. diff --git a/src/agent_env/task_step/registry.py b/src/agent_env/task_step/registry.py index d343df2c2..0c8ebd7a5 100644 --- a/src/agent_env/task_step/registry.py +++ b/src/agent_env/task_step/registry.py @@ -47,6 +47,7 @@ def _builtin_registry() -> dict[str, type["TaskStep"]]: from agent_env.task_step.task_steps.register_agent_triggers import RegisterAgentTriggersStep from agent_env.task_step.task_steps.sync_env_clock import SyncEnvClockTaskStep from agent_env.task_step.task_steps.configure_scheduled_checks import ConfigureScheduledChecksTaskStep + from agent_env.task_step.task_steps.sign_in_browser import SignInBrowserTaskStep from agent_env.task_step.task_steps.install_agent import InstallAgentTaskStep from agent_env.task_step.task_steps.verifiers.rubrics_verifier import RubricsVerifierTaskStep from agent_env.task_step.task_steps.verifiers.run_container_unit_tests_verifier import RunContainerUnitTestsVerifierTaskStep @@ -104,6 +105,7 @@ def _builtin_registry() -> dict[str, type["TaskStep"]]: _get_type(RegisterAgentTriggersStep): RegisterAgentTriggersStep, _get_type(SyncEnvClockTaskStep): SyncEnvClockTaskStep, _get_type(ConfigureScheduledChecksTaskStep): ConfigureScheduledChecksTaskStep, + _get_type(SignInBrowserTaskStep): SignInBrowserTaskStep, _get_type(InstallAgentTaskStep): InstallAgentTaskStep, _get_type(RubricsVerifierTaskStep): RubricsVerifierTaskStep, _get_type(RunContainerUnitTestsVerifierTaskStep): RunContainerUnitTestsVerifierTaskStep, diff --git a/src/agent_env/task_step/task_steps/deploy_env.py b/src/agent_env/task_step/task_steps/deploy_env.py index 55d605b0a..4a39c4b39 100644 --- a/src/agent_env/task_step/task_steps/deploy_env.py +++ b/src/agent_env/task_step/task_steps/deploy_env.py @@ -200,7 +200,9 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: deployed_env = await env.deploy(**deploy_kwargs) # create_instance runs inside env.deploy(), so an annotation set after it # returns has to be persisted explicitly to reach the instance record. - annotations = {"deploy_step_id": self.id} + from agent_env.task_step.task_steps.sign_in_browser import mark_live + + annotations = {"deploy_step_id": self.id, **mark_live(context, deployed_env)} deployed_env.metadata = {**(deployed_env.metadata or {}), **annotations} if deployed_env.instance_id: update_env_instance_metadata(deployed_env.instance_id, annotations) diff --git a/src/agent_env/task_step/task_steps/reset_env.py b/src/agent_env/task_step/task_steps/reset_env.py index 88059fdde..7ff94195c 100644 --- a/src/agent_env/task_step/task_steps/reset_env.py +++ b/src/agent_env/task_step/task_steps/reset_env.py @@ -3,7 +3,8 @@ Env-agnostic: looks up the deployed env in context and calls its ``reset()``. Best-effort by default — a reset failure (including an env that doesn't support it) is logged and the run continues. Place right before prompt_agent in a -warm-reuse task. +warm-reuse task. After a reset, the browser is signed in again to the live websites +a sign_in_browser step of the run signed it in to. """ from __future__ import annotations @@ -61,6 +62,9 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: env = Env.get(deployed.env_id, deployed.env_version) await env.reset(deployed) logger.info("reset_env: reset env '%s'", self.env_id) + from agent_env.task_step.task_steps.sign_in_browser import sign_in_again + + await sign_in_again(context, self.env_id) except NotImplementedError: # Env doesn't support reset — not an error. logger.info("reset_env: env '%s' does not support reset; skipping", self.env_id) diff --git a/src/agent_env/task_step/task_steps/sign_in_browser.py b/src/agent_env/task_step/task_steps/sign_in_browser.py new file mode 100644 index 000000000..05cd4e8b2 --- /dev/null +++ b/src/agent_env/task_step/task_steps/sign_in_browser.py @@ -0,0 +1,284 @@ +"""Live websites in a run: marking a run that uses one, and signing the browser in to them. + +A website child env whose card's website/v1 says ``live`` is a real site. ``mark_live``, which deploy_env runs on every +env it deploys, records each one under ``context.metadata["live_websites"]``, sets ``context.metadata["reproducible"]`` +to False, and marks the deploy record ``live``. It also checks the site's domains and ``allow_hosts`` against the network +allowlist of each computer deployed in the run. An allowlist is fixed when its sandbox starts, so a host one leaves out +is recorded under ``unreachable`` and warned about, not added; a run whose sandboxes restrict no egress records +``network: "unrestricted"``, since nothing there enforces ``allow_hosts`` either. + +``sign_in_browser`` calls each live site's website/v1 ``session`` and hands the cookies straight to the browser's +browser/v1 ``cookies/set``, each called on the child env's own card as the env's card nests it, so a site sharing its +name with an MCP server, or with another child, is still the one called. The cookies never enter the context, a log or +the step's record, which keeps only each cookie's name, domain, path and expiry. reset_env signs the browser in again after a reset, as the last sign-in did. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, ClassVar, Optional + +import httpx +from agentenv_protocol import client as protocol_v1 +from agentenv_protocol.website import BROWSER_EXTENSION_URI, CLEAR_COOKIES, SET_COOKIES, WEBSITE_EXTENSION_URI +from agentenv_protocol.website.browser import cookies as parse_cookies +from agentenv_protocol.website.browser import redacted + +from agent_env.entity.refs import EntityRef +from agent_env.env.env import EnvCapabilityUnsupported +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_step import TaskStep, TaskStepDependency + +logger = logging.getLogger(__name__) + +LIVE_KEY = "live_websites" +REPRODUCIBLE_KEY = "reproducible" +SIGN_INS_KEY = "browser_sign_ins" + + +@dataclass(frozen=True) +class LiveSite: + """A live website child env, as its card declares it; ``child`` is that child env, which its session is called on, + and None for a site read back from a record.""" + + name: str + domains: tuple[str, ...] + allow_hosts: tuple[str, ...] + signs_in: bool + child: Optional[protocol_v1.ChildEnv] = field(default=None, compare=False, repr=False) + + def hosts(self) -> list[str]: + return [*self.domains, *(h for h in self.allow_hosts if h not in self.domains)] + + def record(self) -> dict: + return {"name": self.name, "domains": list(self.domains), "allow_hosts": list(self.allow_hosts), + "signs_in": self.signs_in} + + +def _children(card: dict) -> list[protocol_v1.ChildEnv]: + """``card``'s child envs, or ``card`` itself, at the env's own address, for a leaf card.""" + if isinstance(card.get("children_environments"), list): + return protocol_v1.children(card) + return [protocol_v1.ChildEnv(name=card.get("name"), url=None, card=card)] + + +def live_sites(card: Optional[dict]) -> list[LiveSite]: + """The live websites among ``card``'s child envs, or ``card`` itself for a leaf card.""" + sites = [] + for child in _children(card or {}): + params = (child.find_extension(WEBSITE_EXTENSION_URI) or {}).get("params") or {} + if params.get("live") is True and isinstance(child.name, str): + sites.append(LiveSite(child.name, tuple(params.get("domains") or ()), + tuple(params.get("allow_hosts") or ()), + params.get("signs_in") is not False + and protocol_v1.find_extension_method(child.card, WEBSITE_EXTENSION_URI, + "session") is not None, child)) + return sites + + +def browsers(card: Optional[dict]) -> list[protocol_v1.ChildEnv]: + """``card``'s child envs that serve browser/v1's cookies/set.""" + return [child for child in _children(card or {}) if isinstance(child.name, str) + and protocol_v1.find_extension_method(child.card, BROWSER_EXTENSION_URI, SET_COOKIES) is not None] + + +async def _invoke(deployed: Any, child: protocol_v1.ChildEnv, uri: str, method: str, params: dict, + timeout: int) -> Any: + """Call ``method`` of the extension at ``uri`` as ``child``'s own card advertises it, at ``deployed``'s address + with its control headers, which a lookup of the child by its name could confuse with another of that name.""" + if not deployed.environment_url or protocol_v1.find_extension_method(child.card, uri, method) is None: + raise EnvCapabilityUnsupported(uri, method, deployed.env_id, deployed.instance_id, child.name) + return await protocol_v1.invoke_extension(deployed.environment_url, child.card, uri, params, timeout, + method=method, headers=deployed.control_headers) + + +def _allows(entries: list[str], host: str) -> bool: + host = host.lower().rstrip(".") + for entry in (e.lower().rstrip(".") for e in entries): + if entry == host or (entry.startswith("*.") and host.endswith(entry[1:])): + return True + return False + + +def network_check(context: TaskStepContext, sites: list[LiveSite], deployed: Any = None) -> dict: + """How the run's computers' network allowlists treat the live ``sites``' hosts: each restricting computer's + hosts it leaves out, or ``unrestricted`` when no computer in the run restricts egress. ``deployed``, an env being + deployed that the context doesn't hold yet, is one of the run's.""" + from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy + + hosts = list(dict.fromkeys(h for site in sites for h in site.hosts())) + unreachable: dict[str, list[str]] = {} + restricted = False + envs = [*context.deployed_envs, *([deployed] if deployed is not None and deployed not in context.deployed_envs + else [])] + for env in envs: + network = ((env.metadata or {}).get("computer") or {}).get("network") + if not network: + continue + policy = NetworkPolicy.from_dict(network) + if not policy.restricts_egress: + continue + restricted = True + if missing := [h for h in hosts if not _allows(list(policy.allow_hosts), h)]: + unreachable[env.env_id] = missing + if not restricted: + return {"network": "unrestricted"} + return {"network": "allowlist", "unreachable": unreachable} + + +def mark_live(context: TaskStepContext, deployed: Any) -> dict: + """Record ``deployed``'s live websites in ``context`` and return what its deploy record gains: ``live`` and + ``reproducible`` when it has one, nothing when it has none. The network check of every live website recorded so + far is made again with ``deployed`` among the run's computers, so a computer deployed after a site is checked + too.""" + sites = live_sites(deployed.environment_card) + if sites: + context.metadata.setdefault(LIVE_KEY, {})[deployed.env_id] = {"sites": [s.record() for s in sites]} + context.metadata[REPRODUCIBLE_KEY] = False + for env_id, entry in (context.metadata.get(LIVE_KEY) or {}).items(): + before = entry.pop("unreachable", None) or {} + entry_sites = sites if env_id == deployed.env_id else [ + LiveSite(r["name"], tuple(r["domains"]), tuple(r["allow_hosts"]), r["signs_in"]) for r in entry["sites"]] + network = network_check(context, entry_sites, deployed) + entry.update(network) + for computer, missing in (network.get("unreachable") or {}).items(): + if before.get(computer) != missing: + logger.warning("live websites of env %s: env %s's network allowlist leaves out %s, which it can't add " + "once its sandbox runs, so those pages won't load there", env_id, computer, missing) + if env_id == deployed.env_id and network["network"] == "unrestricted": + logger.info("live websites of env %s (%s): no sandbox in this run restricts egress, so allow_hosts is " + "recorded, not enforced", env_id, [s.name for s in sites]) + return {"live": True, REPRODUCIBLE_KEY: False} if sites else {} + + +async def sign_in(context: TaskStepContext, deployed: Any, *, users: Optional[dict[str, str]] = None, + sites: Optional[list[str]] = None, browser: Optional[str] = None, clear: bool = True, + timeout: int = 30) -> dict: + """Sign ``deployed``'s browser in to its live websites (those named in ``sites``, else every one with a session), + each as ``users[name]`` or the site's own choice; with ``clear``, a site's old cookies go first. Returns the record + of what was set, cookies by everything but their values.""" + card = deployed.environment_card or {} + live = {site.name: site for site in live_sites(card)} + unknown = sorted(set(sites or []) - set(live)) + if unknown: + raise RuntimeError(f"env {deployed.env_id!r} has no live website named {unknown}; its live ones are " + f"{sorted(live) or 'none'}") + chosen = [live[name] for name in sites] if sites else [s for s in live.values() if s.signs_in] + offered = [child for child in browsers(card) if browser is None or child.name == browser] + if chosen and not offered: + named = f"named {browser!r} " if browser is not None else "" + raise RuntimeError(f"env {deployed.env_id!r} has live websites with a session ({[s.name for s in chosen]}) " + f"but no child env {named}serves {BROWSER_EXTENSION_URI} {SET_COOKIES} to sign a browser in") + if chosen and browser is not None and len(offered) > 1: + raise RuntimeError(f"env {deployed.env_id!r} has {len(offered)} browsers named {browser!r} " + f"({', '.join(str(c.url) for c in offered)}), so the name can't say which to sign in") + target = offered[0] if chosen else None + browser = target.name if target is not None else browser + record: dict = {"env_id": deployed.env_id, "browser": browser, "sites": []} + for site in chosen: + user = (users or {}).get(site.name) + try: + session = await _invoke(deployed, site.child, WEBSITE_EXTENSION_URI, "session", {"user": user}, timeout) + except Exception as e: + raise RuntimeError(f"live website {site.name!r}: its session failed ({_why(e)})") from None + try: + given = parse_cookies(session.get("cookies") if isinstance(session, dict) else None, + default_domain=site.domains[0] if site.domains else None) + except ValueError as e: + raise RuntimeError(f"live website {site.name!r}: its session returned {e}") from None + try: + if clear and site.domains: + await _invoke(deployed, target, BROWSER_EXTENSION_URI, CLEAR_COOKIES, {"domains": list(site.domains)}, + timeout) + await _invoke(deployed, target, BROWSER_EXTENSION_URI, SET_COOKIES, + {"cookies": [c.model_dump(exclude_none=True) for c in given]}, timeout) + except Exception as e: + raise RuntimeError(f"browser {browser!r}: setting {site.name!r}'s cookies failed ({_why(e)})") from None + record["sites"].append({"name": site.name, "user": user, "expires_at": session.get("expires_at"), + "cookies": redacted(given)}) + logger.info("sign_in_browser: %s signed in to %s as %s (%s)", browser, site.name, user or "its own choice", + [c.name for c in given]) + return record + + +def _why(error: Exception) -> str: + """An error as a record keeps it: a call's status and error code, or the error's kind, never a message that may + repeat a cookie.""" + if isinstance(error, httpx.HTTPStatusError): + code = getattr(error, "code", None) + return f"HTTP {error.response.status_code}{f' {code}' if code else ''}" + if isinstance(error, EnvCapabilityUnsupported): + return str(error) + return type(error).__name__ + + +async def sign_in_again(context: TaskStepContext, env_id: str) -> None: + """After a reset of ``env_id``, sign its browser in again as each sign-in of this run did; never raises.""" + deployed = next((d for d in context.deployed_envs if d.env_id == env_id), None) + for step_id, entry in list((context.metadata.get(SIGN_INS_KEY) or {}).items()): + if deployed is None or entry.get("env_id") != env_id or not entry.get("sites"): + continue + users = {s["name"]: s["user"] for s in entry["sites"] if s.get("user")} + try: + context.metadata[SIGN_INS_KEY][step_id] = await sign_in( + context, deployed, users=users, sites=[s["name"] for s in entry["sites"]], browser=entry.get("browser")) + except Exception as e: + logger.warning("reset_env: signing env %s's browser in again failed: %s", env_id, e) + entry["error"] = str(e)[:500] + + +class SignInBrowserTaskStep(TaskStep): + """Sign a deployed env's browser in to its live websites before the agent starts. ``users`` maps a site's name to + the user to sign in as; ``sites`` limits it to some live sites; ``browser`` names the child env serving browser/v1 + when there are several.""" + + type: ClassVar[str] = "sign_in_browser" + entity_refs = (EntityRef.env("env_id"),) + + def __init__(self, id: str, version: Optional[int], env_id: str, users: Optional[dict[str, str]] = None, + sites: Optional[list[str]] = None, browser: Optional[str] = None, + depends_on: Optional[list[TaskStepDependency]] = None, fail_task_on_error: bool = True, + timeout_seconds: int = 30): + super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error) + if not env_id: + raise ValueError("sign_in_browser needs the env_id of the env whose browser it signs in") + if users is not None and not (isinstance(users, dict) and all( + isinstance(k, str) and isinstance(v, str) for k, v in users.items())): + raise ValueError("sign_in_browser's users maps a site's name to a user's") + self.env_id, self.users, self.sites, self.browser = env_id, users, sites, browser + self.timeout_seconds = timeout_seconds + + def to_dict(self) -> dict: + data = {**super().to_dict(), "env_id": self.env_id, "timeout_seconds": self.timeout_seconds} + for key in ("users", "sites", "browser"): + if getattr(self, key) is not None: + data[key] = getattr(self, key) + return data + + @classmethod + def from_dict(cls, data: dict) -> SignInBrowserTaskStep: + return cls(**cls._base_from_dict(data), env_id=data["env_id"], users=data.get("users"), + sites=data.get("sites"), browser=data.get("browser"), + timeout_seconds=data.get("timeout_seconds", 30)) + + async def execute(self, context: TaskStepContext) -> TaskStepContext: + deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None) + if deployed is None: + raise RuntimeError(f"Env '{self.env_id}' not found in context.deployed_envs") + if live_sites(deployed.environment_card) and self.env_id not in (context.metadata.get(LIVE_KEY) or {}): + mark_live(context, deployed) + try: + record = await sign_in(context, deployed, users=self.users, sites=self.sites, browser=self.browser, + timeout=self.timeout_seconds) + except Exception as e: + context.metadata.setdefault(SIGN_INS_KEY, {})[self.id] = {"env_id": self.env_id, "error": str(e)[:500]} + logger.warning("sign_in_browser %s: %s", self.id, e) + if self.fail_task_on_error: + raise + return context + context.metadata.setdefault(SIGN_INS_KEY, {})[self.id] = record + if not record["sites"]: + logger.info("sign_in_browser %s: env %s has no live website with a session; nothing to sign in to", + self.id, self.env_id) + return context diff --git a/tst/unit/env/envs/test_playwright_mcp_cookies.py b/tst/unit/env/envs/test_playwright_mcp_cookies.py new file mode 100644 index 000000000..22de103bf --- /dev/null +++ b/tst/unit/env/envs/test_playwright_mcp_cookies.py @@ -0,0 +1,159 @@ +"""browser/v1's cookies in the Playwright MCP browser: cookies/set keeps the cookies in the storage state file every +context Playwright MCP opens starts from and sets them in each context of a running browser, cookies/clear removes them +by domain or all of them, and neither answer, nor an error, repeats a cookie's value.""" + +from __future__ import annotations + +import asyncio +import json +import os +import re +import stat +from pathlib import Path + +import httpx +import pytest + +pytest.importorskip("av") + +from agentenv_protocol import client as protocol_client # noqa: E402 +from agentenv_protocol.website.browser import BROWSER_EXTENSION_URI # noqa: E402 + +from agent_env.env.envs import playwright_mcp # noqa: E402 +from agent_env.env.envs.playwright_mcp import recorder # noqa: E402 +from agent_env.env.gateway.constants import GATEWAY_EXTENSIONS # noqa: E402 +from agent_env.env.gateway.rest_proxy import rewrite_child_card # noqa: E402 + +SET, CLEAR = "/agentenv/ext/browser/cookies/set", "/agentenv/ext/browser/cookies/clear" +SID = {"name": "sid", "value": "s3cret", "domain": "github.com", "secure": True, "httpOnly": True, + "expires": 1_900_000_000} +OTHER = {"name": "pref", "value": "dark", "domain": ".example.com"} + + +class _DevTools: + """A browser's DevTools with a default context and two of Playwright's, each holding its cookies.""" + + def __init__(self, refuse: bool = False) -> None: + self.calls: list[tuple[str, dict]] = [] + self.jars: dict = {None: [], "ctx-1": [], "ctx-2": []} + self.refuse = refuse + + async def call(self, method, params=None, session=None): + await asyncio.sleep(0) + params = dict(params or {}) + self.calls.append((method, params)) + jar = self.jars[params.pop("browserContextId", None)] + if method == "Target.getBrowserContexts": + return {"browserContextIds": ["ctx-1", "ctx-2"]} + if self.refuse: + raise recorder.CdpError("Invalid cookie fields: s3cret") + if method == "Storage.setCookies": + jar += [{**c, "session": "expires" not in c, "size": 1} for c in params["cookies"]] + elif method == "Storage.clearCookies": + jar.clear() + elif method == "Storage.getCookies": + return {"cookies": list(jar)} + return {} + + +def _recorder(tmp_path: Path, devtools: _DevTools | None) -> recorder.BrowserRecorder: + class _Connection: + async def __aenter__(self): + return devtools + + async def __aexit__(self, *exc): + return False + + return recorder.BrowserRecorder(find=lambda: None if devtools is None else 9222, + connect=lambda port: _Connection(), + storage_state_path=tmp_path / "storage-state.json") + + +async def _post(app, path: str, body: dict) -> httpx.Response: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://recorder") as http: + return await http.post(path, json=body) + + +@pytest.mark.asyncio +async def test_cookies_go_into_the_storage_state_file_before_the_browser_runs(tmp_path): + app = recorder.create_app(_recorder(tmp_path, None)) + answer = await _post(app, SET, {"cookies": [SID, OTHER]}) + assert answer.json() == {"set": 2, "open_browser": False} and "s3cret" not in answer.text + path = tmp_path / "storage-state.json" + state = json.loads(path.read_text()) + assert state["origins"] == [] + assert state["cookies"] == [ + {"name": "sid", "value": "s3cret", "domain": "github.com", "path": "/", "expires": 1_900_000_000, + "httpOnly": True, "secure": True, "sameSite": "Lax"}, + {"name": "pref", "value": "dark", "domain": ".example.com", "path": "/", "expires": -1, "httpOnly": False, + "secure": False, "sameSite": "Lax"}] + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + + await _post(app, SET, {"cookies": [{**SID, "value": "newer"}]}) + assert [c["value"] for c in json.loads(path.read_text())["cookies"]] == ["newer", "dark"] + + +@pytest.mark.asyncio +async def test_cookies_are_set_in_every_context_of_a_running_browser(tmp_path): + devtools = _DevTools() + app = recorder.create_app(_recorder(tmp_path, devtools)) + assert (await _post(app, SET, {"cookies": [SID]})).json() == {"set": 1, "open_browser": True} + for jar in devtools.jars.values(): + assert jar == [{"name": "sid", "value": "s3cret", "domain": "github.com", "path": "/", "secure": True, + "httpOnly": True, "expires": 1_900_000_000, "session": False, "size": 1}] + + +@pytest.mark.asyncio +async def test_clearing_a_domain_keeps_the_other_cookies_and_clearing_all_keeps_none(tmp_path): + devtools = _DevTools() + app = recorder.create_app(_recorder(tmp_path, devtools)) + await _post(app, SET, {"cookies": [SID, OTHER]}) + assert (await _post(app, CLEAR, {"domains": ["github.com"]})).json() == {"cleared": 1, "open_browser": True} + assert [c["name"] for c in json.loads((tmp_path / "storage-state.json").read_text())["cookies"]] == ["pref"] + for jar in devtools.jars.values(): + assert [(c["name"], c.get("expires")) for c in jar] == [("pref", None)] + assert (await _post(app, CLEAR, {})).json() == {"cleared": 1, "open_browser": True} + assert json.loads((tmp_path / "storage-state.json").read_text())["cookies"] == [] + assert all(jar == [] for jar in devtools.jars.values()) + + +@pytest.mark.asyncio +async def test_concurrent_changes_never_restore_what_another_removed(tmp_path): + devtools = _DevTools() + app = recorder.create_app(_recorder(tmp_path, devtools)) + await _post(app, SET, {"cookies": [SID, OTHER]}) + await asyncio.gather(_post(app, CLEAR, {"domains": ["github.com"]}), _post(app, CLEAR, {"domains": ["example.com"]})) + assert json.loads((tmp_path / "storage-state.json").read_text())["cookies"] == [] + assert all(jar == [] for jar in devtools.jars.values()) + + +@pytest.mark.asyncio +async def test_a_bad_cookie_or_a_browser_that_refuses_answers_without_the_value(tmp_path): + app = recorder.create_app(_recorder(tmp_path, _DevTools(refuse=True))) + bad = await _post(app, SET, {"cookies": [{"name": "sid", "value": "s3cret"}]}) + assert bad.status_code == 400 and "cookies[0].domain" in bad.text and "s3cret" not in bad.text + refused = await _post(app, SET, {"cookies": [SID]}) + assert refused.status_code == 502 and refused.json()["error"]["code"] == "browser_unreachable" + assert "s3cret" not in refused.text + assert json.loads((tmp_path / "storage-state.json").read_text())["cookies"][0]["name"] == "sid" + + +@pytest.mark.asyncio +async def test_the_gateway_nests_browser_v1_under_the_browsers_child_env(tmp_path): + app = recorder.create_app(_recorder(tmp_path, None)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://recorder") as http: + served = (await http.get("/.well-known/agent-env.json")).json() + composed = {"name": "env", "capabilities": {"extensions": GATEWAY_EXTENSIONS}, + "children_environments": [rewrite_child_card("mcp-playwright-mcp", served, GATEWAY_EXTENSIONS)]} + child = protocol_client.find_child(composed, playwright_mcp.PLAYWRIGHT_MCP_ENVIRONMENT_NAME) + methods = protocol_client.find_extension(child, BROWSER_EXTENSION_URI)["params"]["methods"] + assert {op: m["endpoint"] for op, m in methods.items()} == { + "cookies/set": "/svc/mcp-playwright-mcp/agentenv/ext/browser/cookies/set", + "cookies/clear": "/svc/mcp-playwright-mcp/agentenv/ext/browser/cookies/clear"} + + +def test_playwright_mcp_starts_each_context_from_the_file_the_recorder_writes(): + entrypoint = (Path(playwright_mcp.__file__).parent / "entrypoint.sh").read_text() + default = re.search(r'PLAYWRIGHT_STORAGE_STATE="\$\{PLAYWRIGHT_STORAGE_STATE:-([^}]+)\}"', entrypoint).group(1) + assert default == recorder.DEFAULT_STORAGE_STATE + assert '--storage-state "${PLAYWRIGHT_STORAGE_STATE}"' in entrypoint diff --git a/tst/unit/env/envs/test_playwright_mcp_recorder.py b/tst/unit/env/envs/test_playwright_mcp_recorder.py index 207c0d45f..7c70c9d98 100644 --- a/tst/unit/env/envs/test_playwright_mcp_recorder.py +++ b/tst/unit/env/envs/test_playwright_mcp_recorder.py @@ -196,8 +196,9 @@ async def test_a_recording_through_the_card_is_an_mp4_of_the_page(home): manifest = (await http.post(END, json={"recording_id": started["recording_id"]})).json() assert card["name"] == "playwright-mcp" and card["capabilities"]["operations"] == [] - assert [e["uri"] for e in card["capabilities"]["extensions"]] == [COMPUTER_RECORDING] - params = card["capabilities"]["extensions"][0]["params"] + extensions = {e["uri"]: e for e in card["capabilities"]["extensions"]} + assert set(extensions) == {COMPUTER_RECORDING, "urn:agentenv:browser/v1"} + params = extensions[COMPUTER_RECORDING]["params"] assert params["cursor"] is False and params["source"] == "stream" and set(params["methods"]) == {"start", "end"} assert (manifest["ended_by"], manifest["cursor"], manifest["source"]) == ("end", False, "stream") (segment,) = manifest["segments"] diff --git a/tst/unit/env/envs/test_website_env_settings.py b/tst/unit/env/envs/test_website_env_settings.py new file mode 100644 index 000000000..18e7c6584 --- /dev/null +++ b/tst/unit/env/envs/test_website_env_settings.py @@ -0,0 +1,94 @@ +"""A website env's settings: environment variables of its server's container, a live site's credentials among them. +A secret is stored as its reference, never its value, and resolved from the deploying user's config only into the +compose file the gateway runs.""" + +import pytest +import yaml + +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.env import Env +from agent_env.env.envs.service_db import ServiceDBConfig +from agent_env.env.envs.website import WebsiteEnv, settings_problems +from agent_env.providers.env_providers.env_gateway_provider import ( + EnvironmentGatewayProvider, + _redact_compose_secrets, + website_config, +) +from agent_env.providers.env_state import LocalPostgresStateProvider + + +def _image(id): + return get_artifact_store().put_document(DockerImageArtifact( + id=id, description="", image_name=f"registry.example/{id}@sha256:" + "a" * 64)) + + +def test_a_secret_setting_is_stored_as_its_reference(local_stores): + WebsiteEnv.put(id="github-site", server_docker_image_artifact=_image("github-server"), environment_name="github", + settings={"GITHUB_TOKEN": "secret:github-token", "GITHUB_ORG": "acme"}) + stored = Env.get("github-site") + assert stored.settings == {"GITHUB_TOKEN": "secret:github-token", "GITHUB_ORG": "acme"} + assert stored.to_dict()["settings"] == stored.settings + assert "settings" not in WebsiteEnv.put(id="plain", server_docker_image_artifact=_image("plain-server"), + environment_name="plain").to_dict() + + +@pytest.mark.parametrize("settings, problem", [ + ({"GITHUB_TOKEN": "ghp_the_value_itself"}, "settings.GITHUB_TOKEN is a secret"), + ({"API_PASSWORD": "secret:pw?fallback"}, "with no ?default"), + ({"DATABASE_URL": "postgres://elsewhere"}, "the gateway doesn't set itself"), + ({"not a name": "x"}, "an environment variable's name"), + ({"RETRIES": 3}, "settings.RETRIES is a string, not a int"), +]) +def test_a_setting_that_isnt_one_is_refused_without_its_value(settings, problem): + (found,) = settings_problems(settings) + assert problem in found + assert "ghp_the_value_itself" not in found and "fallback" not in found + with pytest.raises(ValueError, match="website env 'x'"): + WebsiteEnv(id="x", version=None, environment_name="x", settings=settings) + + +def test_the_compose_file_holds_the_resolved_settings_and_the_gateway_config_the_references(local_stores, + monkeypatch): + env = WebsiteEnv(id="github-site", version=None, server_docker_image_artifact=_image("github-server"), + environment_name="github", settings={"GITHUB_TOKEN": "env:TEST_GITHUB_TOKEN", "ORG": "acme"}) + monkeypatch.setenv("TEST_GITHUB_TOKEN", "ghp_resolved") + config = website_config(env) + assert config.settings == {"GITHUB_TOKEN": "env:TEST_GITHUB_TOKEN", "ORG": "acme"} + compose = EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=[], website_configs=[config], + state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance()) + environment = yaml.safe_load(compose)["services"]["github-website-backend"]["environment"] + assert "GITHUB_TOKEN=ghp_resolved" in environment + assert "ORG=acme" in environment + + +def test_a_setting_holding_compose_syntax_stays_one_variable(local_stores): + hostile = 'ok\n privileged: true\n volumes:\n - /:/host\n# "q" \\ $HOME ${X} \u2028 é 😀 \x85' + env = WebsiteEnv(id="site", version=None, server_docker_image_artifact=_image("site-server"), + environment_name="site", settings={"EXTRA": hostile}) + compose = EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=[], website_configs=[website_config(env)], + state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance()) + service = yaml.safe_load(compose)["services"]["site-website-backend"] + assert "privileged" not in service and "volumes" not in service + (extra,) = [entry for entry in service["environment"] if entry.startswith("EXTRA=")] + assert extra == "EXTRA=" + hostile.replace("$", "$$") + + +def test_the_logged_compose_masks_every_resolved_setting(local_stores, monkeypatch): + env = WebsiteEnv(id="github-site", version=None, server_docker_image_artifact=_image("github-server"), + environment_name="github", settings={"GITHUB_TOKEN": "env:TEST_GITHUB_TOKEN", + "WEBHOOK": "env:TEST_WEBHOOK", "ORG": 'ac"me'}) + monkeypatch.setenv("TEST_GITHUB_TOKEN", "ghp_resolved") + monkeypatch.setenv("TEST_WEBHOOK", "https://hooks.example/s3cret") + compose = EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=[], website_configs=[website_config(env)], + state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance()) + logged = _redact_compose_secrets(compose) + assert "ghp_resolved" not in logged and "s3cret" not in logged + environment = yaml.safe_load(logged)["services"]["github-website-backend"]["environment"] + assert {"GITHUB_TOKEN=***", "WEBHOOK=***", "ORG=***", "SERVICE_NAME=github"} <= set(environment) diff --git a/tst/unit/task_step/sign_in_browser_test.py b/tst/unit/task_step/sign_in_browser_test.py new file mode 100644 index 000000000..ea1f917e1 --- /dev/null +++ b/tst/unit/task_step/sign_in_browser_test.py @@ -0,0 +1,246 @@ +"""sign_in_browser and the live-site marks: a deploy with a live website is marked not reproducible with its hosts +checked against the run's network allowlists, and the step hands each live site's session cookies to the browser's +browser/v1 cookies/set, through the gateway's child routes, keeping no cookie value in the context or its logs.""" + +from __future__ import annotations + +import json +import logging +from typing import Optional + +import pytest +from agentenv_protocol import client as protocol_client +from agentenv_protocol.agent_env_environment import (AgentEnvEnvironment, AgentEnvStarletteApplication, + environment_card, extension) +from agentenv_protocol.website import BROWSER_EXTENSION_URI, WebsiteEnvironment, session, website +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.testclient import TestClient + +from agent_env.env import env as env_module +from agent_env.env.env import DeployedEnv +from agent_env.env.gateway.constants import GATEWAY_EXTENSIONS +from agent_env.env.gateway.rest_proxy import rewrite_child_card +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.registry import get_task_step_registry +from agent_env.task_step.task_steps.reset_env import ResetEnvTaskStep +from agent_env.task_step.task_steps.sign_in_browser import (LIVE_KEY, SIGN_INS_KEY, SignInBrowserTaskStep, live_sites, + mark_live) + +TOKEN = "ghp_live_secret_value" + + +@environment_card(name="github") +@website(domains=["github.com"], live=True, allow_hosts=["*.githubusercontent.com"]) +class GitHub(WebsiteEnvironment): + @session + def sign_in(self, user: Optional[str]) -> list[dict]: + return [{"name": "user_session", "value": f"{TOKEN}-{user}", "secure": True, "httpOnly": True, + "expires": 1_900_000_000}] + + +@environment_card(name="notes") +@website(domains=["notes.test"]) +class Notes(WebsiteEnvironment): + pass + + +@environment_card(name="playwright-mcp") +class Browser(AgentEnvEnvironment): + def __init__(self, refuse: bool = False) -> None: + self.calls: list[tuple[str, dict]] = [] + self.refuse = refuse + + @extension(BROWSER_EXTENSION_URI, op="cookies/set") + async def _set(self, cookies: list[dict]) -> dict: + self.calls.append(("set", {"cookies": cookies})) + if self.refuse: + from agentenv_protocol.errors import AgentEnvError + raise AgentEnvError("browser_unreachable", f"refused {cookies[0]['value']}", status=502) + return {"set": len(cookies), "open_browser": False} + + @extension(BROWSER_EXTENSION_URI, op="cookies/clear") + async def _clear(self, domains: Optional[list[str]] = None) -> dict: + self.calls.append(("clear", {"domains": domains})) + return {"cleared": 0, "open_browser": False} + + +def _card_app(env: AgentEnvEnvironment) -> Starlette: + app = Starlette() + AgentEnvStarletteApplication(env._build_card(), env).add_routes_to_app(app) + return app + + +class _Gateway: + """The gateway's child routes and composed card, over the children's own apps in process.""" + + def __init__(self, browser: Browser, *, live: bool = True) -> None: + children = {"github": GitHub().create_app() if live else None, "notes": Notes().create_app(), + "mcp-playwright-mcp": _card_app(browser)} + children = {key: app for key, app in children.items() if app is not None} + self.client = TestClient(Starlette(routes=[Mount(f"/svc/{key}", app) for key, app in children.items()])) + cards = [rewrite_child_card(key, TestClient(app).get("/.well-known/agent-env.json").json(), GATEWAY_EXTENSIONS) + for key, app in children.items()] + self.card = {"name": "env", "capabilities": {"extensions": GATEWAY_EXTENSIONS}, + "children_environments": cards} + + def deployed(self, metadata: Optional[dict] = None) -> DeployedEnv: + return DeployedEnv(env_id="e1", env_version=1, environment_card_url="http://gw/.well-known/agent-env.json", + environment_card=self.card, metadata=metadata) + + async def invoke(self, base_url, card, uri, params=None, timeout=30, verify=True, *, method=None, headers=None): + op = protocol_client.find_extension_method(card, uri, method) + response = self.client.request(op["method"], op["endpoint"], json=params or {}) + if response.status_code >= 400: + raise protocol_client.ExtensionCallError(response) + return response.json() + + +@pytest.fixture +def browser() -> Browser: + return Browser() + + +@pytest.fixture +def gateway(browser, monkeypatch) -> _Gateway: + gateway = _Gateway(browser) + monkeypatch.setattr(env_module.protocol_v1, "invoke_extension", gateway.invoke) + return gateway + + +def test_the_step_is_registered_and_round_trips(): + step = SignInBrowserTaskStep(id="s", version=1, env_id="e1", users={"github": "octocat"}, sites=["github"]) + data = step.to_dict() + assert data["type"] == "sign_in_browser" and get_task_step_registry()["sign_in_browser"] is SignInBrowserTaskStep + again = SignInBrowserTaskStep.from_dict(data) + assert (again.users, again.sites, again.browser, again.fail_task_on_error) == ({"github": "octocat"}, ["github"], + None, True) + with pytest.raises(ValueError, match="maps a site"): + SignInBrowserTaskStep(id="s", version=1, env_id="e1", users={"github": 1}) + + +def test_only_a_live_child_is_a_live_site(gateway): + (site,) = live_sites(gateway.card) + assert (site.name, site.domains, site.allow_hosts, site.signs_in) == ("github", ("github.com",), + ("*.githubusercontent.com",), True) + + +def test_a_deploy_with_a_live_site_is_marked_not_reproducible(gateway): + context = TaskStepContext() + assert mark_live(context, gateway.deployed()) == {"live": True, "reproducible": False} + assert context.metadata["reproducible"] is False + assert context.metadata[LIVE_KEY]["e1"] == { + "sites": [{"name": "github", "domains": ["github.com"], "allow_hosts": ["*.githubusercontent.com"], + "signs_in": True}], "network": "unrestricted"} + assert mark_live(TaskStepContext(), _Gateway(Browser(), live=False).deployed()) == {} + + +def test_a_computers_allowlist_that_leaves_out_a_live_host_is_recorded_and_warned(gateway, caplog): + computer = DeployedEnv(env_id="pc", env_version=1, metadata={"computer": {"network": { + "mode": "allowlist", "allow_hosts": ["github.com"]}}}) + context = TaskStepContext(deployed_envs=[computer]) + with caplog.at_level(logging.WARNING): + mark_live(context, gateway.deployed()) + assert context.metadata[LIVE_KEY]["e1"]["unreachable"] == {"pc": ["*.githubusercontent.com"]} + assert "leaves out ['*.githubusercontent.com']" in caplog.text + + +def test_a_computer_deployed_after_a_live_site_is_checked_too(gateway, caplog): + context = TaskStepContext() + site = gateway.deployed() + mark_live(context, site) + context.deployed_envs.append(site) + assert context.metadata[LIVE_KEY]["e1"]["network"] == "unrestricted" + computer = DeployedEnv(env_id="pc", env_version=1, metadata={"computer": {"network": { + "mode": "allowlist", "allow_hosts": ["github.com"]}}}) + with caplog.at_level(logging.WARNING): + assert mark_live(context, computer) == {} + assert context.metadata[LIVE_KEY]["e1"]["network"] == "allowlist" + assert context.metadata[LIVE_KEY]["e1"]["unreachable"] == {"pc": ["*.githubusercontent.com"]} + assert "env pc's network allowlist leaves out ['*.githubusercontent.com']" in caplog.text + + +@pytest.mark.asyncio +async def test_the_browser_is_signed_in_with_the_live_sites_session_and_no_value_is_kept(gateway, browser, caplog): + context = TaskStepContext(deployed_envs=[gateway.deployed()]) + step = SignInBrowserTaskStep(id="sign-in", version=1, env_id="e1", users={"github": "octocat"}) + with caplog.at_level(logging.DEBUG): + await step.execute(context) + assert browser.calls == [ + ("clear", {"domains": ["github.com"]}), + ("set", {"cookies": [{"name": "user_session", "value": f"{TOKEN}-octocat", "domain": "github.com", "path": "/", + "secure": True, "httpOnly": True, "expires": 1_900_000_000}]})] + record = context.metadata[SIGN_INS_KEY]["sign-in"] + assert record["browser"] == "playwright-mcp" and record["sites"][0]["user"] == "octocat" + assert record["sites"][0]["cookies"] == [{"name": "user_session", "domain": "github.com", "path": "/", + "secure": True, "httpOnly": True, "expires": 1_900_000_000.0}] + assert context.metadata["reproducible"] is False + assert TOKEN not in json.dumps(context.to_safe_dict(), default=str) and TOKEN not in caplog.text + + +@pytest.mark.asyncio +async def test_a_browser_that_refuses_fails_the_step_without_the_value(monkeypatch, caplog): + browser = Browser(refuse=True) + gateway = _Gateway(browser) + monkeypatch.setattr(env_module.protocol_v1, "invoke_extension", gateway.invoke) + context = TaskStepContext(deployed_envs=[gateway.deployed()]) + with pytest.raises(RuntimeError, match="HTTP 502 browser_unreachable") as e: + await SignInBrowserTaskStep(id="s", version=1, env_id="e1").execute(context) + assert TOKEN not in str(e.value) and TOKEN not in caplog.text + assert TOKEN not in json.dumps(context.metadata[SIGN_INS_KEY]) + + +@pytest.mark.asyncio +async def test_live_sites_without_a_browser_are_refused(monkeypatch, gateway): + card = {**gateway.card, "children_environments": [c for c in gateway.card["children_environments"] + if c["name"] != "playwright-mcp"]} + deployed = DeployedEnv(env_id="e1", env_version=1, environment_card_url="http://gw/.well-known/agent-env.json", + environment_card=card) + with pytest.raises(RuntimeError, match="no child env serves urn:agentenv:browser/v1"): + await SignInBrowserTaskStep(id="s", version=1, env_id="e1").execute(TaskStepContext(deployed_envs=[deployed])) + step = SignInBrowserTaskStep(id="s", version=1, env_id="e1", sites=["notes"], fail_task_on_error=False) + context = await step.execute(TaskStepContext(deployed_envs=[deployed])) + assert "no live website named ['notes']" in context.metadata[SIGN_INS_KEY]["s"]["error"] + + +@pytest.mark.asyncio +async def test_a_reset_signs_the_browser_in_again(gateway, browser, monkeypatch): + context = TaskStepContext(deployed_envs=[gateway.deployed()]) + await SignInBrowserTaskStep(id="sign-in", version=1, env_id="e1", users={"github": "octocat"}).execute(context) + browser.calls.clear() + + class _Env: + async def reset(self, deployed): + return None + + monkeypatch.setattr("agent_env.env.env.Env.get", lambda *a, **k: _Env()) + await ResetEnvTaskStep(id="reset", version=1, env_id="e1").execute(context) + assert [call for call, _ in browser.calls] == ["clear", "set"] + assert browser.calls[1][1]["cookies"][0]["value"] == f"{TOKEN}-octocat" + + +@environment_card(name="github") +class GitHubMcp(AgentEnvEnvironment): + pass + + +@environment_card(name="playwright-mcp") +class OtherPlaywright(AgentEnvEnvironment): + pass + + +@pytest.mark.asyncio +async def test_a_site_and_a_browser_sharing_a_name_with_another_child_are_each_called_on_their_own_card(gateway, + browser): + decoys = [rewrite_child_card(key, TestClient(_card_app(env)).get("/.well-known/agent-env.json").json(), + GATEWAY_EXTENSIONS) + for key, env in (("mcp-github", GitHubMcp()), ("mcp-other-playwright", OtherPlaywright()))] + card = {**gateway.card, "children_environments": [*decoys, *gateway.card["children_environments"]]} + deployed = DeployedEnv(env_id="e1", env_version=1, environment_card_url="http://gw/.well-known/agent-env.json", + environment_card=card) + context = TaskStepContext(deployed_envs=[deployed]) + await SignInBrowserTaskStep(id="s", version=1, env_id="e1", users={"github": "octocat"}, + browser="playwright-mcp").execute(context) + assert [call for call, _ in browser.calls] == ["clear", "set"] + assert browser.calls[1][1]["cookies"][0]["value"] == f"{TOKEN}-octocat" + assert context.metadata[SIGN_INS_KEY]["s"]["browser"] == "playwright-mcp" From 2db1f6b80a530a3fb63ff00ced141fcd4d8a355a Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 15:01:27 -0800 Subject: [PATCH 2/8] fix(website): sign in a browser without cookies/clear, and replay compressed and inline-styled captures (FD-4683) - sign_in skips cookies/clear on a browser that serves only cookies/set, and names its error-record limit. - An imported WARC's gzip or deflate answers are read decoded, so their pages' and stylesheets' links are rewritten. - A capture loads what a page's style values, style elements and video posters name. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/replay.py | 48 ++++++++++++++++- .../tests/test_website_replay.py | 54 +++++++++++++++++++ .../task_step/task_steps/sign_in_browser.py | 12 +++-- tst/unit/task_step/sign_in_browser_test.py | 22 ++++++++ 4 files changed, 129 insertions(+), 7 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py index 3ae4b0b30..750e7f8d3 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py @@ -40,6 +40,7 @@ import re import sys import uuid +import zlib from collections import deque from html.parser import HTMLParser from pathlib import Path @@ -206,7 +207,33 @@ def _http_response(block: bytes) -> tuple[int, tuple[tuple[str, str], ...], byte if any(n.lower() == "transfer-encoding" and "chunked" in v.lower() for n, v in headers): body = _dechunked(body) headers = tuple((n, v) for n, v in headers if n.lower() != "transfer-encoding") - return status, headers, body + return status, *_decoded(headers, body) + + +_DECODERS = {"gzip": zlib.MAX_WBITS | 16, "x-gzip": zlib.MAX_WBITS | 16, "deflate": zlib.MAX_WBITS} + + +def _decoded(headers: tuple[tuple[str, str], ...], body: bytes) -> tuple[tuple[tuple[str, str], ...], bytes]: + """``body`` with its ``Content-Encoding`` undone and that header dropped, as a capture of this module stores it, so + a page imported from another WARC tool is rewritten too; a coding it can't undo (``br``, say) leaves both as they + are, and a body that expands past ``MAX_CAPTURE_BYTES`` is refused.""" + codings = [c.strip().lower() for n, v in headers if n.lower() == "content-encoding" for c in v.split(",")] + codings = [c for c in codings if c and c != "identity"] + if not codings or any(c not in _DECODERS for c in codings): + return headers, body + for coding in reversed(codings): + inflater = zlib.decompressobj(_DECODERS[coding]) + try: + expanded = inflater.decompress(body, MAX_CAPTURE_BYTES + 1) + except zlib.error: + if coding != "deflate": + return headers, body + inflater = zlib.decompressobj(-zlib.MAX_WBITS) + expanded = inflater.decompress(body, MAX_CAPTURE_BYTES + 1) + if len(expanded) > MAX_CAPTURE_BYTES or inflater.unconsumed_tail: + raise ValueError(f"a captured answer is over {MAX_CAPTURE_BYTES} bytes decoded") + body = expanded + return tuple((n, v) for n, v in headers if n.lower() not in ("content-encoding", "content-length")), body def _dechunked(body: bytes) -> bytes: @@ -256,7 +283,8 @@ def _record(self, kind: str, url: Optional[str], content_type: str, block: bytes class _Links(HTMLParser): - """The links of an HTML page: ``pages`` its anchors and frames lead to, ``assets`` what it loads to show.""" + """The links of an HTML page: ``pages`` its anchors and frames lead to, ``assets`` what it loads to show, a + video's ``poster`` and the CSS URLs of its ``style`` values and ``' + b'
' + b''), + "/t.css": (200, "text/css", b"p{}"), + "/h.png": (200, "image/png", b"\x89PNG"), + "/d.png": (200, "image/png", b"\x89PNG"), + "/p.jpg": (200, "image/jpeg", b"\xff\xd8"), + } + + +def test_a_capture_loads_what_a_pages_style_values_style_elements_and_posters_name(): + site = _InlineStyledSite() + try: + out = io.BytesIO() + result = capture([site.url + "/"], out, depth=0) + assert {key[1] for key in Archive.read(out.getvalue()).entries} == {"/", "/t.css", "/h.png", "/d.png", "/p.jpg"} + assert result.captured == 5 and not result.failed + finally: + site.close() + + +def test_an_imported_warcs_compressed_page_is_read_decoded_so_its_links_stay_on_the_replay(tmp_path): + page = b'a' + css = b"b{background:url(/b.png)}" + records = b"" + for url, kind, body, coding in (("https://shop.example/", "text/html", gzip.compress(page), "gzip"), + ("https://shop.example/s.css", "text/css", zlib.compress(css), "deflate")): + http = (f"HTTP/1.1 200 OK\r\nContent-Type: {kind}\r\nContent-Encoding: {coding}\r\n" + f"Content-Length: {len(body)}\r\n\r\n").encode() + body + records += (f"WARC/1.0\r\nWARC-Type: response\r\nWARC-Target-URI: {url}\r\n" + f"Content-Type: application/http;msgtype=response\r\nContent-Length: {len(http)}\r\n\r\n" + ).encode() + http + b"\r\n\r\n" + (tmp_path / "other.warc").write_bytes(records) + site = TestClient(Shop(tmp_path / "other.warc").create_app()) + behind = {"X-Forwarded-Prefix": "/website/shop"} + served = site.get("/", headers=behind) + assert "content-encoding" not in served.headers + assert served.content == b'a' + assert site.get("/s.css", headers=behind).content == b"b{background:url(/website/shop/b.png)}" + + +def test_an_imported_answer_in_a_coding_the_replay_cant_undo_is_served_as_captured(): + http = b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Encoding: br\r\n\r\n\x0b\x02\x80" + record = (f"WARC/1.0\r\nWARC-Type: response\r\nWARC-Target-URI: https://shop.example/\r\n" + f"Content-Type: application/http;msgtype=response\r\nContent-Length: {len(http)}\r\n\r\n").encode() + entry = Archive.read(record + http + b"\r\n\r\n").find("GET", "/") + assert entry.body == b"\x0b\x02\x80" and ("Content-Encoding", "br") in entry.headers + + def test_a_replay_drops_a_link_tags_integrity_so_its_rewritten_stylesheet_still_loads(): page = (b'' b'') diff --git a/src/agent_env/task_step/task_steps/sign_in_browser.py b/src/agent_env/task_step/task_steps/sign_in_browser.py index 05cd4e8b2..2d0d539c8 100644 --- a/src/agent_env/task_step/task_steps/sign_in_browser.py +++ b/src/agent_env/task_step/task_steps/sign_in_browser.py @@ -35,6 +35,7 @@ LIVE_KEY = "live_websites" REPRODUCIBLE_KEY = "reproducible" SIGN_INS_KEY = "browser_sign_ins" +_ERROR_CHARS = 500 @dataclass(frozen=True) @@ -156,8 +157,8 @@ async def sign_in(context: TaskStepContext, deployed: Any, *, users: Optional[di sites: Optional[list[str]] = None, browser: Optional[str] = None, clear: bool = True, timeout: int = 30) -> dict: """Sign ``deployed``'s browser in to its live websites (those named in ``sites``, else every one with a session), - each as ``users[name]`` or the site's own choice; with ``clear``, a site's old cookies go first. Returns the record - of what was set, cookies by everything but their values.""" + each as ``users[name]`` or the site's own choice; with ``clear``, a site's old cookies go first, on a browser that + serves cookies/clear. Returns the record of what was set, cookies by everything but their values.""" card = deployed.environment_card or {} live = {site.name: site for site in live_sites(card)} unknown = sorted(set(sites or []) - set(live)) @@ -188,7 +189,8 @@ async def sign_in(context: TaskStepContext, deployed: Any, *, users: Optional[di except ValueError as e: raise RuntimeError(f"live website {site.name!r}: its session returned {e}") from None try: - if clear and site.domains: + if clear and site.domains and protocol_v1.find_extension_method(target.card, BROWSER_EXTENSION_URI, + CLEAR_COOKIES) is not None: await _invoke(deployed, target, BROWSER_EXTENSION_URI, CLEAR_COOKIES, {"domains": list(site.domains)}, timeout) await _invoke(deployed, target, BROWSER_EXTENSION_URI, SET_COOKIES, @@ -225,7 +227,7 @@ async def sign_in_again(context: TaskStepContext, env_id: str) -> None: context, deployed, users=users, sites=[s["name"] for s in entry["sites"]], browser=entry.get("browser")) except Exception as e: logger.warning("reset_env: signing env %s's browser in again failed: %s", env_id, e) - entry["error"] = str(e)[:500] + entry["error"] = str(e)[:_ERROR_CHARS] class SignInBrowserTaskStep(TaskStep): @@ -272,7 +274,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: record = await sign_in(context, deployed, users=self.users, sites=self.sites, browser=self.browser, timeout=self.timeout_seconds) except Exception as e: - context.metadata.setdefault(SIGN_INS_KEY, {})[self.id] = {"env_id": self.env_id, "error": str(e)[:500]} + context.metadata.setdefault(SIGN_INS_KEY, {})[self.id] = {"env_id": self.env_id, "error": str(e)[:_ERROR_CHARS]} logger.warning("sign_in_browser %s: %s", self.id, e) if self.fail_task_on_error: raise diff --git a/tst/unit/task_step/sign_in_browser_test.py b/tst/unit/task_step/sign_in_browser_test.py index ea1f917e1..f5ae88da3 100644 --- a/tst/unit/task_step/sign_in_browser_test.py +++ b/tst/unit/task_step/sign_in_browser_test.py @@ -178,6 +178,28 @@ async def test_the_browser_is_signed_in_with_the_live_sites_session_and_no_value assert TOKEN not in json.dumps(context.to_safe_dict(), default=str) and TOKEN not in caplog.text +@environment_card(name="playwright-mcp") +class SetOnlyBrowser(AgentEnvEnvironment): + def __init__(self) -> None: + self.calls: list[tuple[str, dict]] = [] + + @extension(BROWSER_EXTENSION_URI, op="cookies/set") + async def _set(self, cookies: list[dict]) -> dict: + self.calls.append(("set", {"cookies": cookies})) + return {"set": len(cookies), "open_browser": False} + + +@pytest.mark.asyncio +async def test_a_browser_serving_only_cookies_set_is_signed_in_without_clearing(monkeypatch): + browser = SetOnlyBrowser() + gateway = _Gateway(browser) + monkeypatch.setattr(env_module.protocol_v1, "invoke_extension", gateway.invoke) + context = TaskStepContext(deployed_envs=[gateway.deployed()]) + await SignInBrowserTaskStep(id="s", version=1, env_id="e1").execute(context) + assert [call for call, _ in browser.calls] == ["set"] + assert context.metadata[SIGN_INS_KEY]["s"]["sites"][0]["name"] == "github" + + @pytest.mark.asyncio async def test_a_browser_that_refuses_fails_the_step_without_the_value(monkeypatch, caplog): browser = Browser(refuse=True) From a27198463b389ed25f2a8a670b3b26ce00d1791a Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 16:04:19 -0800 Subject: [PATCH 3/8] fix(website): sign_in_browser picks its deployment by env_step_id, and signs in again after a reset with its own timeout (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../task_step/task_steps/reset_env.py | 2 +- .../task_step/task_steps/sign_in_browser.py | 59 ++++++++++++++----- tst/unit/task_step/sign_in_browser_test.py | 32 ++++++++++ 3 files changed, 77 insertions(+), 16 deletions(-) diff --git a/src/agent_env/task_step/task_steps/reset_env.py b/src/agent_env/task_step/task_steps/reset_env.py index 7ff94195c..59d1717d1 100644 --- a/src/agent_env/task_step/task_steps/reset_env.py +++ b/src/agent_env/task_step/task_steps/reset_env.py @@ -64,7 +64,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: logger.info("reset_env: reset env '%s'", self.env_id) from agent_env.task_step.task_steps.sign_in_browser import sign_in_again - await sign_in_again(context, self.env_id) + await sign_in_again(context, deployed) except NotImplementedError: # Env doesn't support reset — not an error. logger.info("reset_env: env '%s' does not support reset; skipping", self.env_id) diff --git a/src/agent_env/task_step/task_steps/sign_in_browser.py b/src/agent_env/task_step/task_steps/sign_in_browser.py index 2d0d539c8..e9c512f11 100644 --- a/src/agent_env/task_step/task_steps/sign_in_browser.py +++ b/src/agent_env/task_step/task_steps/sign_in_browser.py @@ -11,6 +11,8 @@ browser/v1 ``cookies/set``, each called on the child env's own card as the env's card nests it, so a site sharing its name with an MCP server, or with another child, is still the one called. The cookies never enter the context, a log or the step's record, which keeps only each cookie's name, domain, path and expiry. reset_env signs the browser in again after a reset, as the last sign-in did. +With several deployments of the env in a run, ``env_step_id`` names the deploy_env step whose deployment it signs in; the +record keeps that step and the call timeout, so the sign-in after a reset reaches the same browser the same way. """ from __future__ import annotations @@ -176,7 +178,8 @@ async def sign_in(context: TaskStepContext, deployed: Any, *, users: Optional[di f"({', '.join(str(c.url) for c in offered)}), so the name can't say which to sign in") target = offered[0] if chosen else None browser = target.name if target is not None else browser - record: dict = {"env_id": deployed.env_id, "browser": browser, "sites": []} + record: dict = {"env_id": deployed.env_id, "env_step_id": deploy_step_of(deployed), "browser": browser, + "timeout_seconds": timeout, "sites": []} for site in chosen: user = (users or {}).get(site.name) try: @@ -215,25 +218,53 @@ def _why(error: Exception) -> str: return type(error).__name__ -async def sign_in_again(context: TaskStepContext, env_id: str) -> None: - """After a reset of ``env_id``, sign its browser in again as each sign-in of this run did; never raises.""" - deployed = next((d for d in context.deployed_envs if d.env_id == env_id), None) +def deploy_step_of(deployed: Any) -> Optional[str]: + """The id of the deploy_env step that deployed ``deployed``, or None.""" + return (getattr(deployed, "metadata", None) or {}).get("deploy_step_id") + + +def deployment(context: TaskStepContext, env_id: str, env_step_id: Optional[str], step_id: str) -> Any: + """The deployment of ``env_id`` a sign-in reaches: the one deploy_env step ``env_step_id`` made, else the only one; + raises when there is none, or several and no ``env_step_id`` says which.""" + matches = [d for d in context.deployed_envs if d.env_id == env_id] + if env_step_id is not None: + named = [d for d in matches if deploy_step_of(d) == env_step_id] + if not named: + raise RuntimeError(f"sign_in_browser {step_id!r}: env_step_id {env_step_id!r} names no deployment of env " + f"{env_id!r} (deployed by: {[deploy_step_of(d) for d in matches]})") + matches = named + if not matches: + raise RuntimeError(f"Env '{env_id}' not found in context.deployed_envs") + if len(matches) > 1: + raise RuntimeError(f"sign_in_browser {step_id!r}: {len(matches)} deployments of env {env_id!r} (deployed by: " + f"{[deploy_step_of(d) for d in matches]}); set env_step_id to the deploy_env step whose " + "browser to sign in") + return matches[0] + + +async def sign_in_again(context: TaskStepContext, deployed: Any) -> None: + """After a reset of ``deployed``, sign its browser in again as each sign-in of this run that reached it did, with + the same timeout; never raises.""" for step_id, entry in list((context.metadata.get(SIGN_INS_KEY) or {}).items()): - if deployed is None or entry.get("env_id") != env_id or not entry.get("sites"): + if entry.get("env_id") != deployed.env_id or not entry.get("sites"): + continue + if entry.get("env_step_id") is not None and entry["env_step_id"] != deploy_step_of(deployed): continue users = {s["name"]: s["user"] for s in entry["sites"] if s.get("user")} try: context.metadata[SIGN_INS_KEY][step_id] = await sign_in( - context, deployed, users=users, sites=[s["name"] for s in entry["sites"]], browser=entry.get("browser")) + context, deployed, users=users, sites=[s["name"] for s in entry["sites"]], browser=entry.get("browser"), + timeout=entry.get("timeout_seconds") or 30) except Exception as e: - logger.warning("reset_env: signing env %s's browser in again failed: %s", env_id, e) + logger.warning("reset_env: signing env %s's browser in again failed: %s", deployed.env_id, e) entry["error"] = str(e)[:_ERROR_CHARS] class SignInBrowserTaskStep(TaskStep): """Sign a deployed env's browser in to its live websites before the agent starts. ``users`` maps a site's name to the user to sign in as; ``sites`` limits it to some live sites; ``browser`` names the child env serving browser/v1 - when there are several.""" + when there are several; ``env_step_id`` names the deploy_env step whose deployment it signs in, when the env is + deployed more than once.""" type: ClassVar[str] = "sign_in_browser" entity_refs = (EntityRef.env("env_id"),) @@ -241,7 +272,7 @@ class SignInBrowserTaskStep(TaskStep): def __init__(self, id: str, version: Optional[int], env_id: str, users: Optional[dict[str, str]] = None, sites: Optional[list[str]] = None, browser: Optional[str] = None, depends_on: Optional[list[TaskStepDependency]] = None, fail_task_on_error: bool = True, - timeout_seconds: int = 30): + timeout_seconds: int = 30, env_step_id: Optional[str] = None): super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error) if not env_id: raise ValueError("sign_in_browser needs the env_id of the env whose browser it signs in") @@ -249,11 +280,11 @@ def __init__(self, id: str, version: Optional[int], env_id: str, users: Optional isinstance(k, str) and isinstance(v, str) for k, v in users.items())): raise ValueError("sign_in_browser's users maps a site's name to a user's") self.env_id, self.users, self.sites, self.browser = env_id, users, sites, browser - self.timeout_seconds = timeout_seconds + self.timeout_seconds, self.env_step_id = timeout_seconds, env_step_id def to_dict(self) -> dict: data = {**super().to_dict(), "env_id": self.env_id, "timeout_seconds": self.timeout_seconds} - for key in ("users", "sites", "browser"): + for key in ("users", "sites", "browser", "env_step_id"): if getattr(self, key) is not None: data[key] = getattr(self, key) return data @@ -262,12 +293,10 @@ def to_dict(self) -> dict: def from_dict(cls, data: dict) -> SignInBrowserTaskStep: return cls(**cls._base_from_dict(data), env_id=data["env_id"], users=data.get("users"), sites=data.get("sites"), browser=data.get("browser"), - timeout_seconds=data.get("timeout_seconds", 30)) + timeout_seconds=data.get("timeout_seconds", 30), env_step_id=data.get("env_step_id")) async def execute(self, context: TaskStepContext) -> TaskStepContext: - deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None) - if deployed is None: - raise RuntimeError(f"Env '{self.env_id}' not found in context.deployed_envs") + deployed = deployment(context, self.env_id, self.env_step_id, self.id) if live_sites(deployed.environment_card) and self.env_id not in (context.metadata.get(LIVE_KEY) or {}): mark_live(context, deployed) try: diff --git a/tst/unit/task_step/sign_in_browser_test.py b/tst/unit/task_step/sign_in_browser_test.py index f5ae88da3..77980b146 100644 --- a/tst/unit/task_step/sign_in_browser_test.py +++ b/tst/unit/task_step/sign_in_browser_test.py @@ -241,6 +241,38 @@ async def reset(self, deployed): assert browser.calls[1][1]["cookies"][0]["value"] == f"{TOKEN}-octocat" +@pytest.mark.asyncio +async def test_with_two_deployments_of_the_env_the_one_env_step_id_names_is_signed_in_and_signed_in_again( + gateway, browser, monkeypatch): + first, second = gateway.deployed({"deploy_step_id": "deploy-a"}), gateway.deployed({"deploy_step_id": "deploy-b"}) + context = TaskStepContext(deployed_envs=[first, second]) + with pytest.raises(RuntimeError, match=r"2 deployments of env 'e1' \(deployed by: \['deploy-a', 'deploy-b'\]\)"): + await SignInBrowserTaskStep(id="s", version=1, env_id="e1").execute(context) + with pytest.raises(RuntimeError, match="env_step_id 'deploy-c' names no deployment"): + await SignInBrowserTaskStep(id="s", version=1, env_id="e1", env_step_id="deploy-c").execute(context) + step = SignInBrowserTaskStep(id="sign-in", version=1, env_id="e1", env_step_id="deploy-b", timeout_seconds=90) + assert SignInBrowserTaskStep.from_dict(step.to_dict()).env_step_id == "deploy-b" + await step.execute(context) + entry = context.metadata[SIGN_INS_KEY]["sign-in"] + assert (entry["env_step_id"], entry["timeout_seconds"]) == ("deploy-b", 90) + + timeouts = [] + real_invoke = gateway.invoke + + async def invoke(base_url, card, uri, params=None, timeout=30, *args, **kwargs): + timeouts.append(timeout) + return await real_invoke(base_url, card, uri, params, timeout, *args, **kwargs) + + monkeypatch.setattr(env_module.protocol_v1, "invoke_extension", invoke) + from agent_env.task_step.task_steps.sign_in_browser import sign_in_again + + browser.calls.clear() + await sign_in_again(context, first) + assert browser.calls == [] and timeouts == [] + await sign_in_again(context, second) + assert [call for call, _ in browser.calls] == ["clear", "set"] and set(timeouts) == {90} + + @environment_card(name="github") class GitHubMcp(AgentEnvEnvironment): pass From 1b43983dc958b83bfe87767d14d7391b9a364c5a Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 16:39:18 -0800 Subject: [PATCH 4/8] fix(website): a replay behind the gateway's prefix scopes each cookie's Path under it (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/replay.py | 20 +++++++++++++++---- .../tests/test_website_replay.py | 13 ++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py index 750e7f8d3..36afa8db3 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py @@ -573,16 +573,28 @@ def _answer(self, entry: Entry, head: bool, root: str = "") -> Response: return response def _header(self, name: str, value: str, root: str = "") -> str: - """``value`` as the replay answers it: a Set-Cookie without its Domain, so it holds at the replay's host, and - a redirect to a captured host or a root-relative one led by the ``root`` path the replay is served under, so - it stays on the replay behind a gateway's prefix too.""" + """``value`` as the replay answers it: a Set-Cookie without its Domain, so it holds at the replay's host, with + its Path led by the ``root`` path the replay is served under, so it holds there for the paths it named; and a + redirect to a captured host or a root-relative one led by ``root`` too, so it stays on the replay behind a + gateway's prefix.""" if name == "set-cookie": - return ";".join(p for p in value.split(";") if not p.strip().lower().startswith("domain=")) + return ";".join(_cookie_attribute(p, root) for p in value.split(";") + if not p.strip().lower().startswith("domain=")) if name == "location": return _rewritten_url(value, self.env.archive.hosts, root) return value +def _cookie_attribute(part: str, root: str) -> str: + """A Set-Cookie attribute as the replay answers it: a ``Path`` that names a path of the site led by ``root``, so a + cookie scoped to ``/account`` holds at ``/account``; anything else as it is.""" + name, _, path = part.partition("=") + root = root.rstrip("/") + if not root or name.strip().lower() != "path" or not path.strip().startswith("/"): + return part + return f"{name}={root}{path.strip()}" + + _ABSOLUTE_URL = re.compile(r"(?P\s*)(?:https?:)?//(?P[^/?#\s]*)(?P.*)", re.IGNORECASE | re.DOTALL) _TAG_NAME = re.compile(r"<[a-zA-Z][^\t\n\r\f />\x00]*(?:\s|/(?!>))*") _ATTRIBUTE = re.compile(r"""(?P[^\s/>][^\s/=>]*)(?:\s*=+\s*(?P'[^']*'|"[^"]*"|(?!['"])[^>\s]*))?""" diff --git a/packages/agentenv-protocol/tests/test_website_replay.py b/packages/agentenv-protocol/tests/test_website_replay.py index cf32b7b8c..e83cf5507 100644 --- a/packages/agentenv-protocol/tests/test_website_replay.py +++ b/packages/agentenv-protocol/tests/test_website_replay.py @@ -216,6 +216,18 @@ def test_the_data_plane_exports_the_overrides_state(client): assert rpc(client, "data/get")["parts"][0]["data"] == {} +def test_a_cookie_scoped_to_a_path_holds_at_that_path_behind_the_gateways_prefix(): + site = TestClient(Shop(warc(("https://shop.example/account/sign-in", 200, [ + ("Set-Cookie", "sid=abc; Domain=shop.example; path=/account; Secure"), + ("Set-Cookie", "theme=dark"), + ("Set-Cookie", "note=1; Path=relative")], b"ok"))).create_app()) + behind = {"X-Forwarded-Prefix": "/website/shop"} + assert site.get("/account/sign-in", headers=behind).headers.get_list("set-cookie") == [ + "sid=abc; path=/website/shop/account; Secure", "theme=dark", "note=1; Path=relative"] + assert site.get("/account/sign-in").headers.get_list("set-cookie") == [ + "sid=abc; path=/account; Secure", "theme=dark", "note=1; Path=relative"] + + def test_a_replay_behind_the_gateways_prefix_matches_the_sites_own_path(client): behind = {"X-Forwarded-Prefix": "/website/shop"} assert client.get("/", headers=behind).text == "home" @@ -225,6 +237,7 @@ def test_a_replay_behind_the_gateways_prefix_matches_the_sites_own_path(client): assert client.get("/nowhere", headers=behind).status_code == 404 moved = client.get("/old", headers=behind, follow_redirects=False) assert moved.headers["location"] == "/website/shop/new?x=1" + assert client.get("/login", headers=behind).headers["set-cookie"] == "sid=abc; Path=/website/shop/; HttpOnly" relative = TestClient(Shop(warc(("https://shop.example/a", 302, [("Location", "/b")], b""), ("https://shop.example/c", 302, [("Location", "d")], b""))).create_app()) From d8a61dde3329fbda6a6962accf771af85ca81a44 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 17:15:43 -0800 Subject: [PATCH 5/8] fix(website): a WARC's decoded answers share one size budget, and capture and sign-in name their defaults (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/replay.py | 40 ++++++++++++------- .../tests/test_website_replay.py | 19 +++++++++ .../task_step/task_steps/sign_in_browser.py | 9 +++-- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py index 36afa8db3..d05f766bf 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py @@ -62,6 +62,8 @@ MAX_CAPTURE_BYTES = 1 << 30 CAPTURE_READ_CHUNK_BYTES = 1 << 20 CAPTURE_URL_TIMEOUT_SECONDS = 60 +CAPTURE_MAX_REQUESTS = 500 +CAPTURE_REQUEST_TIMEOUT_SECONDS = 20.0 DEFAULT_IGNORED_QUERY = ("utm_*", "fbclid", "gclid", "msclkid", "_") WARC_VERSION = "WARC/1.1" WARC_TYPES = ("application/warc", "application/warc+gzip") @@ -140,7 +142,7 @@ def holds(self, path: str) -> bool: @classmethod def read(cls, source: str | Path | bytes, ignore_query: Iterable[str] = DEFAULT_IGNORED_QUERY) -> Archive: """The answers of a WARC file, gzipped or not, given as its path or its bytes, refused once it expands past - ``MAX_CAPTURE_BYTES``.""" + ``MAX_CAPTURE_BYTES``, or once its answers' bodies, decoded, together do.""" if isinstance(source, bytes): raw = _expanded(io.BytesIO(source)) else: @@ -182,6 +184,8 @@ def _records(stream: BinaryIO) -> Iterator[tuple[dict[str, str], bytes]]: def _entries(stream: BinaryIO) -> Iterator[Entry]: + """Each answer the WARC holds, its body decoded within what is left of ``MAX_CAPTURE_BYTES`` for all the bodies + together, so many small compressed answers can't fill the memory between them.""" methods: dict[str, str] = {} responses = [] for headers, block in _records(stream): @@ -192,13 +196,17 @@ def _entries(stream: BinaryIO) -> Iterator[Entry]: methods[headers["warc-concurrent-to"]] = method elif kind == "response" and "application/http" in headers.get("content-type", ""): responses.append((headers, block)) + left = MAX_CAPTURE_BYTES for headers, block in responses: - status, entry_headers, body = _http_response(block) + status, entry_headers, body = _http_response(block, left) + left -= len(body) + if left < 0: + raise ValueError(f"the capture's answers are over {MAX_CAPTURE_BYTES} bytes decoded") yield Entry(methods.get(headers.get("warc-record-id", ""), "GET"), headers["warc-target-uri"], status, entry_headers, body) -def _http_response(block: bytes) -> tuple[int, tuple[tuple[str, str], ...], bytes]: +def _http_response(block: bytes, limit: int) -> tuple[int, tuple[tuple[str, str], ...], bytes]: head, _, body = block.partition(b"\r\n\r\n") lines = head.decode("latin-1").split("\r\n") status = int(lines[0].split(" ", 2)[1]) @@ -207,16 +215,16 @@ def _http_response(block: bytes) -> tuple[int, tuple[tuple[str, str], ...], byte if any(n.lower() == "transfer-encoding" and "chunked" in v.lower() for n, v in headers): body = _dechunked(body) headers = tuple((n, v) for n, v in headers if n.lower() != "transfer-encoding") - return status, *_decoded(headers, body) + return status, *_decoded(headers, body, limit) _DECODERS = {"gzip": zlib.MAX_WBITS | 16, "x-gzip": zlib.MAX_WBITS | 16, "deflate": zlib.MAX_WBITS} -def _decoded(headers: tuple[tuple[str, str], ...], body: bytes) -> tuple[tuple[tuple[str, str], ...], bytes]: +def _decoded(headers: tuple[tuple[str, str], ...], body: bytes, limit: int) -> tuple[tuple[tuple[str, str], ...], bytes]: """``body`` with its ``Content-Encoding`` undone and that header dropped, as a capture of this module stores it, so a page imported from another WARC tool is rewritten too; a coding it can't undo (``br``, say) leaves both as they - are, and a body that expands past ``MAX_CAPTURE_BYTES`` is refused.""" + are, and a body that expands past ``limit`` bytes is refused, decoding no further than that.""" codings = [c.strip().lower() for n, v in headers if n.lower() == "content-encoding" for c in v.split(",")] codings = [c for c in codings if c and c != "identity"] if not codings or any(c not in _DECODERS for c in codings): @@ -224,14 +232,14 @@ def _decoded(headers: tuple[tuple[str, str], ...], body: bytes) -> tuple[tuple[t for coding in reversed(codings): inflater = zlib.decompressobj(_DECODERS[coding]) try: - expanded = inflater.decompress(body, MAX_CAPTURE_BYTES + 1) + expanded = inflater.decompress(body, limit + 1) except zlib.error: if coding != "deflate": return headers, body inflater = zlib.decompressobj(-zlib.MAX_WBITS) - expanded = inflater.decompress(body, MAX_CAPTURE_BYTES + 1) - if len(expanded) > MAX_CAPTURE_BYTES or inflater.unconsumed_tail: - raise ValueError(f"a captured answer is over {MAX_CAPTURE_BYTES} bytes decoded") + expanded = inflater.decompress(body, limit + 1) + if len(expanded) > limit or inflater.unconsumed_tail: + raise ValueError(f"the capture's answers are over {MAX_CAPTURE_BYTES} bytes decoded") body = expanded return tuple((n, v) for n, v in headers if n.lower() not in ("content-encoding", "content-length")), body @@ -344,9 +352,9 @@ class CaptureResult: skipped: int = 0 -def capture(urls: list[str], out: BinaryIO, *, depth: int = 1, max_requests: int = 500, assets: bool = True, - timeout: float = 20.0, client: Any = None, user_agent: str = "agentenv-replay-capture/1", - gzipped: bool = True) -> CaptureResult: +def capture(urls: list[str], out: BinaryIO, *, depth: int = 1, max_requests: int = CAPTURE_MAX_REQUESTS, + assets: bool = True, timeout: float = CAPTURE_REQUEST_TIMEOUT_SECONDS, client: Any = None, + user_agent: str = "agentenv-replay-capture/1", gzipped: bool = True) -> CaptureResult: """Capture ``urls`` and what they lead to into ``out``, a WARC file; ``client`` is an ``httpx.Client`` to fetch with instead of one of its own, which must not follow redirects.""" import httpx @@ -727,9 +735,11 @@ def main(argv: list[str] | None = None) -> int: grab.add_argument("urls", nargs="+", metavar="URL") grab.add_argument("--out", required=True, help="the WARC file to write; gzipped unless it ends in .warc") grab.add_argument("--depth", type=int, default=1, help="how many links away from a start URL to follow (1)") - grab.add_argument("--max-requests", type=int, default=500, help="stop after this many requests (500)") + grab.add_argument("--max-requests", type=int, default=CAPTURE_MAX_REQUESTS, + help=f"stop after this many requests ({CAPTURE_MAX_REQUESTS})") grab.add_argument("--no-assets", action="store_true", help="capture pages only, not what they load") - grab.add_argument("--timeout", type=float, default=20.0, help="seconds one request may take (20)") + grab.add_argument("--timeout", type=float, default=CAPTURE_REQUEST_TIMEOUT_SECONDS, + help=f"seconds one request may take ({CAPTURE_REQUEST_TIMEOUT_SECONDS:g})") serve = commands.add_parser("serve", help="serve a capture on PORT as a website/v1 site") serve.add_argument("capture") serve.add_argument("--domain", action="append", default=[], help="a domain the site is declared at") diff --git a/packages/agentenv-protocol/tests/test_website_replay.py b/packages/agentenv-protocol/tests/test_website_replay.py index e83cf5507..5bab3b872 100644 --- a/packages/agentenv-protocol/tests/test_website_replay.py +++ b/packages/agentenv-protocol/tests/test_website_replay.py @@ -419,6 +419,25 @@ def test_a_capture_that_expands_past_its_limit_is_refused_while_it_is_read(clien assert Archive.read(warc(("https://shop.example/", 200, [], b"x" * 40_000))).find("GET", "/").body == b"x" * 40_000 +def test_many_small_compressed_answers_are_refused_once_their_decoded_bodies_together_pass_the_limit(monkeypatch): + from agentenv_protocol.website import replay + + def answers(count: int) -> bytes: + records = b"" + for n in range(count): + body = gzip.compress(b"x" * 20_000) + http = (f"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {len(body)}\r\n\r\n").encode() + body + records += (f"WARC/1.0\r\nWARC-Type: response\r\nWARC-Target-URI: https://shop.example/{n}\r\n" + f"Content-Type: application/http;msgtype=response\r\nContent-Length: {len(http)}\r\n\r\n" + ).encode() + http + b"\r\n\r\n" + return records + + monkeypatch.setattr(replay, "MAX_CAPTURE_BYTES", 50_000) + assert len(answers(3)) < 50_000 and len(Archive.read(answers(2))) == 2 + with pytest.raises(ValueError, match="answers are over 50000 bytes decoded"): + Archive.read(answers(3)) + + class _StyledSite(_Site): """A page whose stylesheet imports another and loads an image, a font, a data URI and another site's image.""" diff --git a/src/agent_env/task_step/task_steps/sign_in_browser.py b/src/agent_env/task_step/task_steps/sign_in_browser.py index e9c512f11..2af309270 100644 --- a/src/agent_env/task_step/task_steps/sign_in_browser.py +++ b/src/agent_env/task_step/task_steps/sign_in_browser.py @@ -38,6 +38,7 @@ REPRODUCIBLE_KEY = "reproducible" SIGN_INS_KEY = "browser_sign_ins" _ERROR_CHARS = 500 +SIGN_IN_TIMEOUT_SECONDS = 30 @dataclass(frozen=True) @@ -157,7 +158,7 @@ def mark_live(context: TaskStepContext, deployed: Any) -> dict: async def sign_in(context: TaskStepContext, deployed: Any, *, users: Optional[dict[str, str]] = None, sites: Optional[list[str]] = None, browser: Optional[str] = None, clear: bool = True, - timeout: int = 30) -> dict: + timeout: int = SIGN_IN_TIMEOUT_SECONDS) -> dict: """Sign ``deployed``'s browser in to its live websites (those named in ``sites``, else every one with a session), each as ``users[name]`` or the site's own choice; with ``clear``, a site's old cookies go first, on a browser that serves cookies/clear. Returns the record of what was set, cookies by everything but their values.""" @@ -254,7 +255,7 @@ async def sign_in_again(context: TaskStepContext, deployed: Any) -> None: try: context.metadata[SIGN_INS_KEY][step_id] = await sign_in( context, deployed, users=users, sites=[s["name"] for s in entry["sites"]], browser=entry.get("browser"), - timeout=entry.get("timeout_seconds") or 30) + timeout=entry.get("timeout_seconds") or SIGN_IN_TIMEOUT_SECONDS) except Exception as e: logger.warning("reset_env: signing env %s's browser in again failed: %s", deployed.env_id, e) entry["error"] = str(e)[:_ERROR_CHARS] @@ -272,7 +273,7 @@ class SignInBrowserTaskStep(TaskStep): def __init__(self, id: str, version: Optional[int], env_id: str, users: Optional[dict[str, str]] = None, sites: Optional[list[str]] = None, browser: Optional[str] = None, depends_on: Optional[list[TaskStepDependency]] = None, fail_task_on_error: bool = True, - timeout_seconds: int = 30, env_step_id: Optional[str] = None): + timeout_seconds: int = SIGN_IN_TIMEOUT_SECONDS, env_step_id: Optional[str] = None): super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error) if not env_id: raise ValueError("sign_in_browser needs the env_id of the env whose browser it signs in") @@ -293,7 +294,7 @@ def to_dict(self) -> dict: def from_dict(cls, data: dict) -> SignInBrowserTaskStep: return cls(**cls._base_from_dict(data), env_id=data["env_id"], users=data.get("users"), sites=data.get("sites"), browser=data.get("browser"), - timeout_seconds=data.get("timeout_seconds", 30), env_step_id=data.get("env_step_id")) + timeout_seconds=data.get("timeout_seconds", SIGN_IN_TIMEOUT_SECONDS), env_step_id=data.get("env_step_id")) async def execute(self, context: TaskStepContext) -> TaskStepContext: deployed = deployment(context, self.env_id, self.env_step_id, self.id) From 61ee22a28feca3b7bccb30a7025c44902414f248 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 18:25:35 -0800 Subject: [PATCH 6/8] refactor(website): a replayed or live site serves no /go and answers no state; data/get alone (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/replay.py | 4 ++-- .../tests/test_website_live.py | 19 ++++--------------- .../tests/test_website_replay.py | 8 -------- 3 files changed, 6 insertions(+), 25 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py index d05f766bf..955aaabb7 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py @@ -54,7 +54,7 @@ from ..agent_env_environment import add_data, reset_data from ..types import FilePart -from .environment import SESSION_PATH, STATE_PATH, WebsiteEnvironment, default_store_hook, website +from .environment import SESSION_PATH, WebsiteEnvironment, default_store_hook, website from .errors import NotFound from .routes import error_response, reserved_path @@ -457,7 +457,7 @@ def create_app(self) -> Starlette: if declaration.static is not None or declaration.live: raise ValueError(f"{type(self).__name__} serves its capture, so it takes no static folder and isn't live") asgi = super().create_app() - built_in = {SESSION_PATH: self._session, STATE_PATH: self._state} + built_in = {SESSION_PATH: self._session} paths = {r.path for r in asgi.router.routes if isinstance(r, Route) and built_in.get(r.path) == r.endpoint} replay = _Replay(self, type(self).app) asgi.router.routes.insert(0, _Captured(self, paths, replay)) diff --git a/packages/agentenv-protocol/tests/test_website_live.py b/packages/agentenv-protocol/tests/test_website_live.py index a58366df6..0bd6d0ac4 100644 --- a/packages/agentenv-protocol/tests/test_website_live.py +++ b/packages/agentenv-protocol/tests/test_website_live.py @@ -8,9 +8,9 @@ from starlette.testclient import TestClient from agentenv_protocol import DataPart, add_data, get_data, reset_data -from agentenv_protocol.client import find_extension, find_extension_method -from agentenv_protocol.website import (WEBSITE_EXTENSION_URI, WEBSITE_STATE_OP, Cookie, WebsiteEnvironment, api, - ready_check, session, website) +from agentenv_protocol.client import find_extension +from agentenv_protocol.website import (WEBSITE_EXTENSION_URI, Cookie, WebsiteEnvironment, api, ready_check, session, + website) from agentenv_protocol.website.browser import cookies, devtools, redacted, storage_state SESSION = "/agentenv/ext/website/session" @@ -54,7 +54,7 @@ def client(monkeypatch) -> TestClient: def test_a_live_site_serves_no_pages_only_its_health_data_plane_and_card(client): - for path in ("/", "/login", "/api/session", "/go"): + for path in ("/", "/login", "/api/session"): assert client.get(path).status_code == 404, path assert client.get("/api/health").status_code == 200 card = client.get("/.well-known/agent-env.json").json() @@ -93,17 +93,6 @@ class Plain(WebsiteEnvironment): assert answer.status_code == 501 and answer.json()["error"]["code"] == "no_session" -def test_a_live_site_keeping_the_default_store_answers_no_state(): - @website(domains=["example.com"], live=True) - class Plain(WebsiteEnvironment): - pass - - client = TestClient(Plain().create_app()) - card = client.get("/.well-known/agent-env.json").json() - assert find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_STATE_OP) is None - assert client.get("/agentenv/ext/website/state").status_code == 404 and client.get("/go").status_code == 404 - - def test_a_session_method_returning_a_bad_cookie_is_refused_without_its_value(): @website(domains=["example.com"], live=True) class Broken(WebsiteEnvironment): diff --git a/packages/agentenv-protocol/tests/test_website_replay.py b/packages/agentenv-protocol/tests/test_website_replay.py index 5bab3b872..448ef63cc 100644 --- a/packages/agentenv-protocol/tests/test_website_replay.py +++ b/packages/agentenv-protocol/tests/test_website_replay.py @@ -211,7 +211,6 @@ def test_api_routes_win_over_the_capture_and_the_capture_over_built_in_routes(cl def test_the_data_plane_exports_the_overrides_state(client): assert client.post("/api/cart", json={"sku": "lamp"}).json() == {"sku": "lamp", "id": 1} assert rpc(client, "data/get")["parts"][0]["data"] == {"cart": [{"sku": "lamp", "id": 1}]} - assert client.get("/go").json()["state_diff"]["cart"]["added"] == [{"sku": "lamp", "id": 1}] rpc(client, "data/reset") assert rpc(client, "data/get")["parts"][0]["data"] == {} @@ -307,13 +306,6 @@ def load(capture: bytes) -> None: rpc(client, "data/reset") assert client.get("/api/session").json() == {"captured": True} - empty = TestClient(Shop(warc()).create_app()) - assert empty.get("/go").json()["state_diff"] == {} - rpc(empty, "data/add", {"parts": [{"kind": "file", "file": {"bytes": base64.b64encode(warc( - ("https://shop.example/go", 200, [("Content-Type", "application/json")], b'{"captured go": 1}'))).decode(), - "name": "go.warc"}}]}) - assert empty.get("/go").json() == {"captured go": 1} - class Rows: """Data hooks of a models site, a list of rows.""" From 956163f2f9e6c7e11e7983c56d9e5e74fc7c69d5 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 20:09:11 -0800 Subject: [PATCH 7/8] fix(website): a replay records every host it captured, and its example reads sign-ups through data/get (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agentenv-protocol/examples/website/replay/README.md | 2 +- packages/agentenv-protocol/examples/website/replay/site.py | 2 +- .../src/agentenv_protocol/website/replay.py | 7 ++++--- packages/agentenv-protocol/tests/test_website_replay.py | 6 ++++++ 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/replay/README.md b/packages/agentenv-protocol/examples/website/replay/README.md index f41fd1bb0..b6c48e3ba 100644 --- a/packages/agentenv-protocol/examples/website/replay/README.md +++ b/packages/agentenv-protocol/examples/website/replay/README.md @@ -62,7 +62,7 @@ When the capture's answers are served: ## 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 +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. diff --git a/packages/agentenv-protocol/examples/website/replay/site.py b/packages/agentenv-protocol/examples/website/replay/site.py index 09f37cd20..404fc1d5f 100644 --- a/packages/agentenv-protocol/examples/website/replay/site.py +++ b/packages/agentenv-protocol/examples/website/replay/site.py @@ -1,6 +1,6 @@ """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.""" +data plane's ``data/get`` exports them.""" from __future__ import annotations from pydantic import BaseModel diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py index 955aaabb7..b1f1aad90 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/replay.py @@ -121,14 +121,15 @@ def __len__(self) -> int: return len(self.entries) def add(self, entry: Entry) -> bool: - """Keep ``entry`` unless the archive holds an answer to its request already; whether it was kept.""" + """Keep ``entry`` unless the archive holds an answer to its request already, recording its host either way, so + links to every captured host stay on the replay; whether it was kept.""" url = urlsplit(entry.url) + if url.hostname: + self.hosts.add(url.hostname.lower()) key = request_key(entry.method, url.path, url.query, self.ignore_query) if key in self.entries: return False self.entries[key] = entry - if url.hostname: - self.hosts.add(url.hostname.lower()) return True def find(self, method: str, path: str, query: str = "") -> Optional[Entry]: diff --git a/packages/agentenv-protocol/tests/test_website_replay.py b/packages/agentenv-protocol/tests/test_website_replay.py index 448ef63cc..dbc54654b 100644 --- a/packages/agentenv-protocol/tests/test_website_replay.py +++ b/packages/agentenv-protocol/tests/test_website_replay.py @@ -380,6 +380,12 @@ def test_the_example_serves_its_capture_and_its_sign_up(): exported = rpc(client, "data/get")["parts"][0]["data"] assert [s["email"] for s in exported["subscriptions"]] == ["ann@example.com"] +def test_an_answer_kept_once_still_records_each_host_it_was_captured_from() -> None: + archive = Archive([Entry("GET", "https://example.com/", 200, (), b"apex"), + Entry("GET", "https://www.example.com/", 200, (), b"www")]) + assert archive.find("GET", "/").body == b"apex" and archive.hosts == {"example.com", "www.example.com"} + + def test_an_entry_is_kept_once_per_request(): archive = Archive([Entry("GET", "https://a.example/x?b=1&a=2", 200, (), b"first"), From 46566a58694d97954f9f65fd2cad5d3312fdf4af Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 21:15:54 -0800 Subject: [PATCH 8/8] fix(website): a wildcard site domain covers its names when a browser's cookies are cleared (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/agentenv_protocol/website/browser.py | 7 +++++-- packages/agentenv-protocol/tests/test_website_live.py | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/browser.py b/packages/agentenv-protocol/src/agentenv_protocol/website/browser.py index 51d9928b2..36fd6a243 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/browser.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/browser.py @@ -41,9 +41,12 @@ def matches(self, domains: Iterable[str]) -> bool: 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.""" + """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() - return any(host == d or host.endswith("." + d) for d in (d.lstrip(".").lower() for d in domains)) + 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]: diff --git a/packages/agentenv-protocol/tests/test_website_live.py b/packages/agentenv-protocol/tests/test_website_live.py index 0bd6d0ac4..448118b72 100644 --- a/packages/agentenv-protocol/tests/test_website_live.py +++ b/packages/agentenv-protocol/tests/test_website_live.py @@ -168,6 +168,9 @@ def test_a_cookie_is_kept_without_its_value_and_given_to_a_browser_in_its_own_sh assert Cookie(name="a", value="", domain=".A.example").matches(["a.example"]) assert Cookie(name="a", value="", domain="sub.a.example").matches(["a.example"]) assert not Cookie(name="a", value="", domain="nota.example").matches(["a.example"]) + for domain in ("app.example.com", ".example.com", "deep.app.example.com"): + assert Cookie(name="a", value="", domain=domain).matches(["*.example.com"]), domain + assert not Cookie(name="a", value="", domain="badexample.com").matches(["*.example.com"]) def test_a_website_browser_declares_the_browser_v1_whose_cookies_sign_in_browser_sets():