Repository navigation
feat(browser): browser_sign_in, and browser steps reach a multi env's computers (FD-4944) #556
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
3151789
feat(browser): browser_sign_in, and browser steps reach a multi env's…
earakely-scale 5b62f4a
fix(browser): browser_sign_in finds the site by the name it's served …
earakely-scale f469be6
test(browser): the sign-in e2e puts its multi env with no MCP servers…
earakely-scale 1b40fb5
Merge remote-tracking branch 'origin/main' into edgararakelyan/fd-494…
earakely-scale c8678d3
fix(browser): a website deployed alone is signed in by the names it s…
earakely-scale File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| """browser_sign_in: an env's website browser signed in to a site as a user, with the cookies and local storage the | ||
| site's website/v1 session gives, scoped to the site's names and loaded through the browser's data/add.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from typing import ClassVar, Optional | ||
|
|
||
| from agentenv_protocol import METHOD_ADD, DataPart, client as protocol_v1 | ||
| from agentenv_protocol.website import WEBSITE_EXTENSION_URI | ||
|
|
||
| from agent_env.entity.refs import EntityRef | ||
| from agent_env.env.env import DeployedEnv, Env, website_child | ||
| from agent_env.env.envs.computer.utils import sites | ||
| from agent_env.env.envs.website import WebsiteEnv | ||
| from agent_env.store.base import NotFoundError | ||
| from agent_env.task_step.context import TaskStepContext | ||
| from agent_env.task_step.preflight import stored_env | ||
| from agent_env.task_step.task_step import TaskStepDependency | ||
| from agent_env.task_step.task_steps.browser.base import BrowserStep | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| #: How long a site may take to answer for a session, and the browser to load it. | ||
| SESSION_TIMEOUT_S = 30 | ||
| ADD_TIMEOUT_S = 120 | ||
|
|
||
|
|
||
| class BrowserSignInTaskStep(BrowserStep): | ||
| """Sign the browser in to the website env ``site``, deployed in the run on its own or in a multi env, as ``user``: | ||
| the site's ``session {user}`` gives the cookies and local storage, each cookie is set for every name the site is | ||
| reached by, and the browser's ``data/add`` loads them, which opens its start tabs again while no agent has acted. | ||
| A task runs it after the load that seeds the site, and again after a reset, which starts the browser afresh.""" | ||
|
|
||
| type: ClassVar[str] = "browser_sign_in" | ||
| entity_refs = (EntityRef.env("env_id"), EntityRef.env("site")) | ||
|
|
||
| def __init__(self, id: str, version: Optional[int], env_id: str, site: str, user: str, | ||
| browser: Optional[str] = None, depends_on: Optional[list[TaskStepDependency]] = None, | ||
| fail_task_on_error: bool = True): | ||
| super().__init__(id, version, env_id, browser, depends_on=depends_on, fail_task_on_error=fail_task_on_error) | ||
| self.site = site | ||
| self.user = user | ||
|
|
||
| def config_problems(self) -> list[str]: | ||
| problems = super().config_problems() | ||
| if not isinstance(self.site, str) or not self.site: | ||
| problems.append(f"step {self.id!r}: site must be a website env's id") | ||
| if not isinstance(self.user, str) or not self.user: | ||
| problems.append(f"step {self.id!r}: user must be a non-empty string; browser_delete_cookies signs out") | ||
| return problems | ||
|
|
||
| def preflight(self) -> list[str]: | ||
| problems = super().preflight() | ||
| if not isinstance(self.site, str) or not self.site: | ||
| return problems | ||
| site = stored_env(self.site) | ||
| if site is not None and not isinstance(site, WebsiteEnv): | ||
| problems.append(f"step {self.id!r}: site {self.site!r} is a {type(site).__name__}, not a website env") | ||
| return problems | ||
|
|
||
| def to_dict(self) -> dict: | ||
| return {**super().to_dict(), "site": self.site, "user": self.user} | ||
|
|
||
| @classmethod | ||
| def from_dict(cls, data: dict) -> BrowserSignInTaskStep: | ||
| return cls(**cls._browser_from_dict(data), site=data.get("site"), user=data.get("user")) | ||
|
|
||
| async def execute(self, context: TaskStepContext) -> TaskStepContext: | ||
| record, child, names = await self._site(context) | ||
| if protocol_v1.find_extension_method(child.card, WEBSITE_EXTENSION_URI, "session") is None: | ||
| raise RuntimeError(f"site {self.site!r} serves no website/v1 session, so it can't sign a browser in") | ||
| session = await record.invoke(WEBSITE_EXTENSION_URI, "session", {"user": self.user}, child=child, | ||
| timeout=SESSION_TIMEOUT_S) | ||
| cookies = scoped(session.get("cookies") or [], names) | ||
| owner, browser, card = self._find(context) | ||
| if protocol_v1.serves(card, METHOD_ADD) is False: | ||
| raise RuntimeError(f"the website browser {browser or owner.env_id!r} serves no data/add: rebuild it on a " | ||
| "newer agentenv-framework-protocol") | ||
| state = {"cookies": cookies, "local_storage": session.get("local_storage") or []} | ||
| await protocol_v1.add_data(_data_plane(owner, browser), [DataPart(data=state)], timeout=ADD_TIMEOUT_S, | ||
| headers=owner.control_headers) | ||
| self._record(context, site=self.site, user=self.user, | ||
| cookies=sorted({cookie["name"] for cookie in cookies}), names=names) | ||
| logger.info(f"{self.id}: signed {browser or owner.env_id!r} in to {self.site!r} as {self.user!r} " | ||
| f"({len(cookies)} cookie(s) on {', '.join(names)})") | ||
| return context | ||
|
|
||
| async def _site(self, context: TaskStepContext) -> tuple[DeployedEnv, protocol_v1.ChildEnv, list[str]]: | ||
| """The record that serves the site, the site among its children, and the names a browser reaches it by: a | ||
| website env deployed on its own, or one of a deployed multi env's websites.""" | ||
| for record in context.deployed_envs: | ||
| try: | ||
| env = Env.get(record.env_id, record.env_version) | ||
| except NotFoundError: | ||
| continue | ||
| website = env if record.env_id == self.site else next( | ||
| (site for site in getattr(env, "website_envs", None) or [] if site.id == self.site), None) | ||
| if not isinstance(website, WebsiteEnv): | ||
| continue | ||
| child = _served(record, website.environment_name) | ||
| if child is None: | ||
| raise RuntimeError(f"env {record.env_id!r}'s card serves no website as {website.environment_name!r}") | ||
| found = await sites.deployed_sites([record], services=[website.environment_name]) | ||
| names = list(dict.fromkeys(name for site in found for name in site["domains"])) or _own_names(website, child) | ||
| if not names: | ||
| raise RuntimeError(f"site {self.site!r} has no names a browser reaches it by") | ||
| return record, child, names | ||
| raise RuntimeError(f"site {self.site!r} isn't deployed in this run: no env in context.deployed_envs is it or " | ||
| "has it among its websites") | ||
|
|
||
|
|
||
| def _served(record: DeployedEnv, service: str) -> Optional[protocol_v1.ChildEnv]: | ||
| """The website ``record``'s card serves as ``service``, the name of the route it's at, which its card's name | ||
| needn't be (``sites.served_as``); a leaf card, a website's deployed alone, is its own.""" | ||
| card = record.environment_card or {} | ||
| if not isinstance(card.get("children_environments"), list): | ||
| return protocol_v1.ChildEnv(name=card.get("name"), url=card.get("url"), card=card) if card else None | ||
| return next((child for child in protocol_v1.children(card) | ||
| if website_child(child) and sites.served_as(child) == service), None) | ||
|
|
||
|
|
||
| def _own_names(website: WebsiteEnv, child: protocol_v1.ChildEnv) -> list[str]: | ||
| """The names ``website`` stores, else those its card's ``website/v1`` declares, for a site ``deployed_sites`` | ||
| doesn't route: one a plugin deploys alone, with a card of its own and no frontend.""" | ||
| declared = protocol_v1.extension_params(child.card, WEBSITE_EXTENSION_URI).get("domains") | ||
| names = website.domains or (declared if isinstance(declared, list) else []) | ||
| return list(dict.fromkeys(name for name in names if isinstance(name, str) and name)) | ||
|
|
||
|
|
||
| def scoped(cookies: list[dict], names: list[str]) -> list[dict]: | ||
| """Each of ``cookies``, which ``session`` leaves unplaced, set for each of the site's ``names``: an exact name by | ||
| its URL, so the cookie is the name's alone, over HTTPS, a ``.localhost`` one over HTTP; a ``*.`` name as a domain | ||
| cookie on the name it ends in.""" | ||
| placed = [] | ||
| for cookie in cookies: | ||
| unplaced = {key: value for key, value in cookie.items() if key not in ("url", "domain")} | ||
| for name in names: | ||
| if name.startswith("*."): | ||
| placed.append({**unplaced, "domain": f".{name[2:]}", "path": unplaced.get("path") or "/"}) | ||
| else: | ||
| scheme = "http" if name == "localhost" or name.endswith(".localhost") else "https" | ||
| placed.append({**{key: value for key, value in unplaced.items() if key != "path"}, | ||
| "url": f"{scheme}://{name}{unplaced.get('path') or '/'}"}) | ||
| return placed | ||
|
|
||
|
|
||
| def _data_plane(owner: DeployedEnv, browser: Optional[str]) -> str: | ||
| """Where the browser's data plane is: its child's in ``owner``'s card, or ``owner``'s own for a browser alone.""" | ||
| if browser is None: | ||
| return owner.environment_url | ||
| child = owner.child_env(browser) | ||
| if child is None: | ||
| raise RuntimeError(f"env {owner.env_id!r}'s card lists no {browser!r}") | ||
| return child.base_url(owner.environment_url) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| FROM mirror.gcr.io/library/python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf | ||
| RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* | ||
| WORKDIR /app | ||
| RUN pip install --no-cache-dir starlette uvicorn pydantic httpx | ||
| COPY agentenv_protocol /app/agentenv_protocol | ||
| COPY server.py /app/server.py | ||
| ENV PYTHONPATH=/app | ||
| ENV PORT=8000 | ||
| EXPOSE 8000 | ||
| RUN useradd --create-home --uid 10001 appuser | ||
| USER appuser | ||
| CMD ["python", "server.py"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| """A website on WebsiteEnvironment with no pages of its own, named shop.test, for integration-testing browser_sign_in: | ||
| website/v1's session signs a browser in, and /api/session says who a request's cookie signs in.""" | ||
| from agentenv_protocol.website import WebsiteEnvironment, website | ||
|
|
||
|
|
||
| @website(domains=["shop.test"]) | ||
| class Shop(WebsiteEnvironment): | ||
| pass | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| Shop().serve() |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.