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'
' + 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'') + site = TestClient(Shop(warc(("https://shop.example/", 200, [("Content-Type", "text/html")], page))).create_app()) + assert site.get("/").content == (b'' + b'') + + +def test_a_replay_serves_no_captured_content_security_policy_so_its_rewritten_page_still_loads(): + page = (b'' + b'') + 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..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"