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..b6c48e3ba
--- /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 `'
+ b'
').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..59d1717d1 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, 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
new file mode 100644
index 000000000..2af309270
--- /dev/null
+++ b/src/agent_env/task_step/task_steps/sign_in_browser.py
@@ -0,0 +1,316 @@
+"""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.
+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
+
+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"
+_ERROR_CHARS = 500
+SIGN_IN_TIMEOUT_SECONDS = 30
+
+
+@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 = 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."""
+ 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, "env_step_id": deploy_step_of(deployed), "browser": browser,
+ "timeout_seconds": timeout, "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 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,
+ {"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__
+
+
+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 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"),
+ 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]
+
+
+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; ``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"),)
+
+ 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 = 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")
+ 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, 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", "env_step_id"):
+ 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", 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)
+ 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)[:_ERROR_CHARS]}
+ 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..77980b146
--- /dev/null
+++ b/tst/unit/task_step/sign_in_browser_test.py
@@ -0,0 +1,300 @@
+"""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
+
+
+@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)
+ 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"
+
+
+@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
+
+
+@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"