diff --git a/packages/python/ess-auth/pyproject.toml b/packages/python/ess-auth/pyproject.toml new file mode 100644 index 0000000..69d3a92 --- /dev/null +++ b/packages/python/ess-auth/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "ess-auth" +version = "0.1.0" +description = "OAuth2 client-credentials helper and JWT verification utilities" +requires-python = ">=3.12,<3.13" +dependencies = ["httpx>=0.27", "PyJWT>=2.0.0"] + +[project.optional-dependencies] +jwks = ["PyJWT[crypto]>=2.0.0"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/ess_auth"] diff --git a/packages/python/ess-auth/src/ess_auth/__init__.py b/packages/python/ess-auth/src/ess_auth/__init__.py new file mode 100644 index 0000000..23dd940 --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/__init__.py @@ -0,0 +1,12 @@ +"""OAuth2 client-credentials authentication and JWT verification utilities.""" + +from .jwt import JWKSVerifier, decode_jwt, generate_jwt +from .token_helper import AuthenticationError, TokenHelper + +__all__ = [ + "AuthenticationError", + "JWKSVerifier", + "TokenHelper", + "decode_jwt", + "generate_jwt", +] diff --git a/packages/python/ess-auth/src/ess_auth/config.py b/packages/python/ess-auth/src/ess_auth/config.py new file mode 100644 index 0000000..14eb73c --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/config.py @@ -0,0 +1,3 @@ +"""Default configuration constants.""" + +EXPIRATION_WINDOW = 120 # seconds before expiry to trigger refresh diff --git a/packages/python/ess-auth/src/ess_auth/jwt.py b/packages/python/ess-auth/src/ess_auth/jwt.py new file mode 100644 index 0000000..dd5b53f --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/jwt.py @@ -0,0 +1,187 @@ +"""JWT token generation, decoding, and JWKS verification.""" + +from __future__ import annotations + +import logging +import time + +import jwt as pyjwt + +try: + from jwt import PyJWKClient as _PyJWKClient +except ImportError: + _PyJWKClient = None # type: ignore[assignment,misc] + +from .token_helper import AuthenticationError + +logger = logging.getLogger(__name__) + +_DEFAULT_ALGORITHM = "HS256" +_SECONDS_PER_DAY = 86400 + + +def generate_jwt( + subject: str, + secret: str, + *, + expires_days: int = 30, + algorithm: str = _DEFAULT_ALGORITHM, + **extra_claims: object, +) -> str: + """Generate an HS256 JWT. + + Parameters + ---------- + subject: + The ``sub`` claim — identifies the token holder (e.g., a user ID). + secret: + The signing secret (must match ``AUTH_SECRET`` on the server). + expires_days: + Token lifetime in days (default: 30). + algorithm: + JWT algorithm (default: HS256). + **extra_claims: + Additional claims merged into the payload. + + Returns + ------- + str + The encoded JWT string. + """ + now = int(time.time()) + payload: dict[str, object] = { + "sub": subject, + "iat": now, + "exp": now + expires_days * _SECONDS_PER_DAY, + **extra_claims, + } + return pyjwt.encode(payload, secret, algorithm=algorithm) + + +def decode_jwt( + token: str, + secret: str, + *, + algorithm: str = _DEFAULT_ALGORITHM, +) -> dict: + """Decode and verify an HS256 JWT. + + Parameters + ---------- + token: + The encoded JWT string. + secret: + The signing secret used to verify the signature. + algorithm: + Expected JWT algorithm (default: HS256). + + Returns + ------- + dict + The decoded payload. + + Raises + ------ + jwt.InvalidTokenError + If the token is invalid, expired, or the signature doesn't match. + """ + return pyjwt.decode(token, secret, algorithms=[algorithm]) + + +class JWKSVerifier: + """Verify JWTs against a JWKS endpoint (RS256) with optional HS256 fallback. + + Parameters + ---------- + jwks_uri: + URL of the JWKS endpoint for RS256 public key discovery. + issuer: + Expected ``iss`` claim value. + audience: + Expected ``aud`` claim value. + hs256_secret: + Optional shared secret for HS256 fallback. When *None*, only RS256 + is attempted. + """ + + def __init__( + self, + jwks_uri: str, + issuer: str, + audience: str, + *, + hs256_secret: str | None = None, + ) -> None: + if _PyJWKClient is None: + msg = ( + "JWKS verification requires the 'cryptography' package. " + "Install it with: pip install 'ess-auth[jwks]'" + ) + raise ImportError(msg) + self._jwks_client = _PyJWKClient(jwks_uri, cache_keys=True) + self._issuer = issuer + self._audience = audience + self._hs256_secret = hs256_secret or None + + def verify(self, token: str) -> dict: + """Decode and verify a JWT token. + + Tries RS256 (JWKS) first. If that fails and an HS256 secret is + configured, falls back to HS256 verification. + + Returns + ------- + dict + The decoded JWT payload. + + Raises + ------ + AuthenticationError + If verification fails for all configured methods. + """ + try: + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + return pyjwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience=self._audience, + issuer=self._issuer, + ) + except (pyjwt.InvalidTokenError, pyjwt.PyJWKClientError): + logger.debug("RS256 verification failed, trying fallback", exc_info=True) + + if self._hs256_secret: + try: + return pyjwt.decode(token, self._hs256_secret, algorithms=["HS256"]) + except pyjwt.InvalidTokenError: + pass + + raise AuthenticationError("Invalid token") + + @staticmethod + def extract_token( + headers: dict[bytes, bytes], + authorization: str | None = None, + ) -> str: + """Extract raw JWT from HTTP headers. + + Checks ``authorization`` first (parsed ``Authorization: Bearer`` value), + then falls back to the ``x-api-key`` header. + + Raises + ------ + AuthenticationError + If no token is found. + """ + token_value = authorization + if not token_value: + api_key = headers.get(b"x-api-key", b"").decode() + if api_key: + token_value = api_key + if not token_value: + raise AuthenticationError("Missing authorization") + scheme, _, value = token_value.strip().partition(" ") + if scheme.lower() == "bearer" and value: + return value.strip() + return token_value.strip() diff --git a/packages/python/ess-auth/src/ess_auth/token_helper.py b/packages/python/ess-auth/src/ess_auth/token_helper.py new file mode 100644 index 0000000..f32f04c --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/token_helper.py @@ -0,0 +1,112 @@ +"""OAuth2 Client Credentials token helper with automatic caching and refresh.""" + +from __future__ import annotations + +import logging +import time + +import httpx + +from .config import EXPIRATION_WINDOW + +logger = logging.getLogger(__name__) + + +class AuthenticationError(Exception): + """Raised when a token request fails.""" + + +class TokenHelper: + """Manages OAuth2 client-credentials tokens with caching and auto-refresh. + + Tokens are cached and transparently refreshed when they are within + ``refresh_margin`` seconds of expiry. + + Parameters + ---------- + client_id: + OAuth2 client ID. + secret: + OAuth2 client secret. + token_url: + Token endpoint URL. + refresh_margin: + Seconds before expiry to trigger a proactive refresh. + """ + + def __init__( + self, + client_id: str, + secret: str, + *, + token_url: str, + refresh_margin: int = EXPIRATION_WINDOW, + ) -> None: + self._client_id = client_id + self._secret = secret + self._token_url = token_url + self._refresh_margin = refresh_margin + + self._access_token: str | None = None + self._expires_at: float = 0.0 + + @property + def token(self) -> str | None: + """The current cached access token, or ``None`` if not yet fetched.""" + return self._access_token + + @property + def is_expired(self) -> bool: + """Whether the cached token is missing or within the refresh window.""" + return self._access_token is None or time.monotonic() >= ( + self._expires_at - self._refresh_margin + ) + + def fetch_token(self, http: httpx.Client | None = None) -> str: + """Return a valid access token, refreshing if necessary. + + Parameters + ---------- + http: + Optional ``httpx.Client`` to reuse. A short-lived client is + created automatically when *None*. + """ + if not self.is_expired: + assert self._access_token is not None # noqa: S101 # nosec B101 # narrowing assertion after None check + return self._access_token + + if http is not None: + self._refresh(http) + else: + with httpx.Client(timeout=30.0) as client: + self._refresh(client) + + assert self._access_token is not None # noqa: S101 # nosec B101 # narrowing assertion after None check + return self._access_token + + def fetch_token_info(self, http: httpx.Client | None = None) -> tuple[str, float]: + """Return ``(token, expires_at_monotonic)``.""" + token = self.fetch_token(http) + return token, self._expires_at + + def _refresh(self, http: httpx.Client) -> None: + logger.info("Refreshing OAuth2 access token from %s", self._token_url) + try: + response = http.post( + self._token_url, + data={"grant_type": "client_credentials"}, + auth=(self._client_id, self._secret), + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise AuthenticationError( + f"Token request failed: HTTP {exc.response.status_code}" + ) from exc + except httpx.HTTPError as exc: + raise AuthenticationError(f"Token request failed: {exc}") from exc + + payload = response.json() + self._access_token = payload["access_token"] + expires_in = int(payload.get("expires_in", 3600)) + self._expires_at = time.monotonic() + expires_in + logger.info("Token acquired, expires in %ds", expires_in) diff --git a/packages/python/ess-browser/README.md b/packages/python/ess-browser/README.md new file mode 100644 index 0000000..eb2c85b --- /dev/null +++ b/packages/python/ess-browser/README.md @@ -0,0 +1,64 @@ +# ess-browser + +Shared Playwright browser session with SSO handling. + +```python +from ess_browser import BrowserSession + +with BrowserSession(headed=True) as session: + page = session.new_page() + page.goto("https://example.com/") + session.wait_for_login(page, "https://example.com/") +``` + +## Installation + +From the workspace root: + +```bash +uv sync --all-packages +``` + +## API + +### `BrowserSession` + +Context manager that launches a persistent Chrome browser session. + +| Parameter | Default | Description | +|---|---|---| +| `headed` | `False` | Show the browser window | +| `profile_dir` | `~/.ess-browser/profile` | Browser profile for session persistence | +| `viewport` | `1280x900` | Browser viewport size | +| `enable_extensions` | `False` | Load Chrome extensions from the profile (headed mode only) | +| `init_scripts` | `[]` | Additional JS snippets injected into every page | +| `skip_duo_update_prompt` | `True` | Auto-dismiss the Duo "update Chrome" nag | + +Methods: + +- `new_page() -> Page` -- open a new browser tab +- `wait_for_login(page, target_url, timeout_ms=300_000)` -- block until SSO completes +- `is_auth_redirect(current_url, target_url) -> bool` -- check if on an SSO page + +#### Duo prompt auto-dismiss + +By default, an init script auto-clicks the "Skip for now" button on +`duosecurity.com` pages so the Chrome-update nag doesn't block automated +flows. Pass `skip_duo_update_prompt=False` to disable this behavior for +manual or debugging sessions. + +#### Chrome extensions + +Set `enable_extensions=True` to load extensions installed in the profile +directory. This only has an effect in headed mode; Playwright ignores +extensions when running headless. + +### `ess_browser.auth` + +Lower-level SSO detection functions: + +- `AUTH_DOMAINS` -- tuple of known SSO/IdP domain patterns +- `DUO_SKIP_UPDATE_SCRIPT` -- JS snippet that dismisses the Duo update prompt +- `LOGIN_TIMEOUT_MS` -- default timeout (5 minutes) +- `is_auth_redirect(current_url, target_url) -> bool` +- `wait_for_login(page, target_url, timeout_ms=LOGIN_TIMEOUT_MS)` diff --git a/packages/python/ess-browser/pyproject.toml b/packages/python/ess-browser/pyproject.toml new file mode 100644 index 0000000..0410570 --- /dev/null +++ b/packages/python/ess-browser/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "ess-browser" +version = "0.1.0" +description = "Shared Playwright browser session with SSO handling" +readme = "README.md" +requires-python = ">=3.12,<3.13" +dependencies = [ + "playwright>=1.40.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build] +exclude = ["**/test_*.py"] + +[tool.hatch.build.targets.wheel] +packages = ["src/ess_browser"] diff --git a/packages/python/ess-browser/src/ess_browser/__init__.py b/packages/python/ess-browser/src/ess_browser/__init__.py new file mode 100644 index 0000000..3ad11cf --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/__init__.py @@ -0,0 +1,20 @@ +"""Shared Playwright browser session with SSO handling.""" + +from .auth import ( + AUTH_DOMAINS, + DUO_SKIP_UPDATE_SCRIPT, + LOGIN_TIMEOUT_MS, + is_auth_redirect, + wait_for_login, +) +from .session import DEFAULT_PROFILE_DIR, BrowserSession + +__all__ = [ + "AUTH_DOMAINS", + "BrowserSession", + "DEFAULT_PROFILE_DIR", + "DUO_SKIP_UPDATE_SCRIPT", + "LOGIN_TIMEOUT_MS", + "is_auth_redirect", + "wait_for_login", +] diff --git a/packages/python/ess-browser/src/ess_browser/auth.py b/packages/python/ess-browser/src/ess_browser/auth.py new file mode 100644 index 0000000..6cd291f --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/auth.py @@ -0,0 +1,136 @@ +"""SSO redirect detection and login-wait helpers.""" + +from __future__ import annotations + +import logging +import sys +from urllib.parse import urlparse + +from playwright.sync_api import Page + +logger = logging.getLogger(__name__) + +LOGIN_TIMEOUT_MS = 5 * 60 * 1000 # 5 minutes + +AUTH_DOMAINS: tuple[str, ...] = ( + # Cisco + "id.cisco.com", + "cloudsso.cisco.com", + # Duo + "duosecurity.com", + "login.duosecurity.com", + "sso.duosecurity.com", + "duomobile.com", + # Microsoft + "login.microsoftonline.com", + "login.live.com", + # Google + "accounts.google.com", + # Other IdPs + "auth0.com", + "okta.com", + # Generic patterns (substring match) + "sso.", + "idp.", + "adfs.", +) + + +# JS init script that auto-dismisses the Duo "update Chrome" prompt. +# Only runs on ``duosecurity.com`` and its subdomains; exits immediately +# on all other pages. +DUO_SKIP_UPDATE_SCRIPT = """\ +(() => { + "use strict"; + const hostname = location.hostname; + if (hostname !== "duosecurity.com" && !hostname.endsWith(".duosecurity.com")) return; + + const SKIP_TEXT = "Skip for now"; + const CLICK_DELAY_MS = 500; + const OBSERVER_TIMEOUT_MS = 15000; + + function findSkipButton() { + const buttons = document.querySelectorAll( + "#pwl-prompt-root button.button--link" + ); + for (const button of buttons) { + if (button.textContent.trim() === SKIP_TEXT) { + return button; + } + } + return null; + } + + function clickAfterDelay(button) { + setTimeout(() => { + button.click(); + console.log("[duo-skip-chrome-update] Clicked '%s'", SKIP_TEXT); + }, CLICK_DELAY_MS); + } + + function tryClick() { + const button = findSkipButton(); + if (button) { + clickAfterDelay(button); + return true; + } + return false; + } + + if (!tryClick()) { + const root = document.body || document.documentElement; + const observer = new MutationObserver(() => { + if (tryClick()) { + observer.disconnect(); + } + }); + + observer.observe(root, { childList: true, subtree: true }); + + setTimeout(() => { + observer.disconnect(); + console.log( + "[duo-skip-chrome-update] Timed out waiting for '%s' button", + SKIP_TEXT + ); + }, OBSERVER_TIMEOUT_MS); + } +})(); +""" + + +def is_auth_redirect(current_url: str, target_url: str) -> bool: + """Return True if the browser landed on an SSO page instead of the target.""" + current_host = urlparse(current_url).netloc.lower() + target_host = urlparse(target_url).netloc.lower() + if current_host == target_host: + return False + return any(domain in current_host for domain in AUTH_DOMAINS) + + +def wait_for_login( + page: Page, + target_url: str, + timeout_ms: int = LOGIN_TIMEOUT_MS, +) -> None: + """Block until the user completes SSO and the browser returns to the target. + + Args: + page: The Playwright page currently on an SSO login screen. + target_url: The URL the browser should return to after login. + timeout_ms: Maximum time to wait for login completion. + """ + target_host = urlparse(target_url).netloc.lower() + msg = ( + "SSO login required — please authenticate in the browser window. " + f"Waiting up to {timeout_ms // 1000} seconds ..." + ) + print(msg, file=sys.stderr) + logger.warning(msg) + page.wait_for_url( + f"**://{target_host}/**", + timeout=timeout_ms, + wait_until="load", + ) + print("SSO login complete.", file=sys.stderr) + logger.info("SSO login complete.") diff --git a/packages/python/ess-browser/src/ess_browser/session.py b/packages/python/ess-browser/src/ess_browser/session.py new file mode 100644 index 0000000..e98f971 --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/session.py @@ -0,0 +1,234 @@ +"""Persistent Chrome browser session with SSO support.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from types import TracebackType +from urllib.parse import urlparse + +from playwright.sync_api import BrowserContext, Page, Playwright, sync_playwright +from playwright.sync_api import Error as PlaywrightError + +from .auth import DUO_SKIP_UPDATE_SCRIPT, is_auth_redirect, wait_for_login + +logger = logging.getLogger(__name__) + +DEFAULT_PROFILE_DIR = Path.home() / ".ess-browser" / "profile" +_DEFAULT_VIEWPORT = {"width": 1280, "height": 900} +_UNSAFE_URL_PREFIXES = ("chrome://", "chrome-extension://", "about:", "devtools://") + + +_TARGET_CLOSED_MESSAGE = "Target page, context or browser has been closed" + + +def _is_target_closed(exc: PlaywrightError) -> bool: + """True when *exc* signals a closed page/context/browser. + + Playwright may raise either the private ``TargetClosedError`` + subclass or a plain ``Error`` with the canonical message, depending + on the code path and library version. We check the class name + first, then fall back to a substring match on the canonical message. + """ + if type(exc).__name__ == "TargetClosedError": + return True + return _TARGET_CLOSED_MESSAGE in str(exc) + + +def _safe_origin(url: str) -> str: + """Return the origin for logging without leaking secrets. + + Strips query params, fragments, paths, and ``user:pass@`` from the + URL. Returns ``scheme://host[:port]`` for network URLs, + ``//host[:port]`` for scheme-relative URLs, or just ``scheme:`` + for URLs without a network host (``file:``, ``data:``, etc.). + """ + try: + parsed = urlparse(url) + except (TypeError, ValueError): + return "" + if not parsed.hostname: + return f"{parsed.scheme}:" if parsed.scheme else "" + + hostname = parsed.hostname + if ":" in hostname: + hostname = f"[{hostname}]" + + try: + port = f":{parsed.port}" if parsed.port else "" + except ValueError: + port = "" + + if not parsed.scheme: + return f"//{hostname}{port}" + return f"{parsed.scheme}://{hostname}{port}" + + +def _evaluate_on_existing_pages( + pages: list[Page], + scripts: list[str], +) -> None: + """Best-effort run of *scripts* on already-open pages. + + Persistent contexts may restore tabs pointing at ``chrome://``, + extension pages, or already-closed pages. Scripts are already + registered via ``add_init_script`` for future navigations, so + failures here are non-fatal. + + "Target closed" errors are silently swallowed (expected for + crashed/closed tabs). Other errors are logged at warning level + so they surface in logs without aborting session startup. + """ + for page in pages: + if page.is_closed(): + continue + try: + url = page.url + except PlaywrightError as exc: + if _is_target_closed(exc): + continue + raise + if any(url.startswith(prefix) for prefix in _UNSAFE_URL_PREFIXES): + logger.debug("Skipping init-script injection on %s", url) + continue + origin = _safe_origin(url) + for script in scripts: + try: + page.evaluate(script) + except PlaywrightError as exc: + if _is_target_closed(exc): + logger.debug("Page closed during init-script injection") + break + logger.warning( + "Init-script injection failed on %s (%s)", + origin, + type(exc).__name__, + ) + except Exception as exc: + logger.warning( + "Init-script injection failed on %s (%s)", + origin, + type(exc).__name__, + ) + + +class BrowserSession: + """Context manager for a persistent Chrome browser session. + + Uses system Chrome (``channel="chrome"``) and stores session data + in a persistent profile directory so SSO cookies survive between runs. + + Args: + headed: Show the browser window. Use for first-time SSO login. + profile_dir: Browser profile directory for session persistence. + viewport: Browser viewport dimensions. + enable_extensions: Load Chrome extensions from the profile. + Only works in headed mode; Playwright ignores extensions + when headless. + init_scripts: Additional JavaScript snippets to inject into + every page via ``add_init_script``. + skip_duo_update_prompt: Inject a script that auto-dismisses + the Duo "update Chrome" nag. Disable for manual flows + where you want full control of the Duo page. + """ + + def __init__( # noqa: PLR0913 -- all params are keyword-only with defaults + self, + *, + headed: bool = False, + profile_dir: str | Path | None = None, + viewport: dict[str, int] | None = None, + enable_extensions: bool = False, + init_scripts: list[str] | None = None, + skip_duo_update_prompt: bool = True, + ) -> None: + self._headed = headed + self._profile = Path(profile_dir) if profile_dir else DEFAULT_PROFILE_DIR + self._viewport = viewport or _DEFAULT_VIEWPORT + self._enable_extensions = enable_extensions + self._init_scripts = list(init_scripts) if init_scripts else [] + self._skip_duo_update_prompt = skip_duo_update_prompt + self._pw: Playwright | None = None + self._context: BrowserContext | None = None + + def __enter__(self) -> BrowserSession: + self._profile.mkdir(parents=True, exist_ok=True) + self._pw = sync_playwright().start() + + launch_kwargs: dict[str, object] = { + "user_data_dir": str(self._profile), + "channel": "chrome", + "headless": not self._headed, + "accept_downloads": False, + "viewport": self._viewport, + } + + if self._enable_extensions: + launch_kwargs["ignore_default_args"] = ["--disable-extensions"] + + try: + self._context = self._pw.chromium.launch_persistent_context( + **launch_kwargs, # type: ignore[arg-type] + ) + + all_scripts: list[str] = [] + if self._skip_duo_update_prompt: + all_scripts.append(DUO_SKIP_UPDATE_SCRIPT) + all_scripts.extend(self._init_scripts) + + for script in all_scripts: + self._context.add_init_script(script) + + _evaluate_on_existing_pages(self._context.pages, all_scripts) + except Exception: + if self._pw is not None: + try: + self._pw.stop() + finally: + self._pw = None + raise + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + try: + if self._context: + self._context.close() + finally: + if self._pw: + self._pw.stop() + self._context = None + self._pw = None + + @property + def context(self) -> BrowserContext: + """The underlying Playwright browser context.""" + if self._context is None: + msg = "BrowserSession is not active. Use as a context manager." + raise RuntimeError(msg) + return self._context + + def new_page(self) -> Page: + """Open a new browser tab.""" + return self.context.new_page() + + @staticmethod + def is_auth_redirect(current_url: str, target_url: str) -> bool: + """Check if the browser is on an SSO page instead of the target.""" + return is_auth_redirect(current_url, target_url) + + @staticmethod + def wait_for_login( + page: Page, + target_url: str, + timeout_ms: int | None = None, + ) -> None: + """Block until SSO completes and the browser returns to the target.""" + if timeout_ms is not None: + wait_for_login(page, target_url, timeout_ms=timeout_ms) + else: + wait_for_login(page, target_url) diff --git a/packages/python/ess-browser/src/ess_browser/test_auth.py b/packages/python/ess-browser/src/ess_browser/test_auth.py new file mode 100644 index 0000000..3dfdc7d --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/test_auth.py @@ -0,0 +1,42 @@ +"""Unit tests for SSO auth helpers.""" + +from __future__ import annotations + +from .auth import DUO_SKIP_UPDATE_SCRIPT, is_auth_redirect + + +class TestIsAuthRedirect: + def test_same_host_is_not_redirect(self): + assert not is_auth_redirect( + "https://app.example.com/page", + "https://app.example.com/", + ) + + def test_sso_host_is_redirect(self): + assert is_auth_redirect( + "https://sso.example.com/login", + "https://app.example.com/", + ) + + def test_duo_host_is_redirect(self): + assert is_auth_redirect( + "https://login.duosecurity.com/frame", + "https://app.example.com/", + ) + + def test_unrelated_host_is_not_redirect(self): + assert not is_auth_redirect( + "https://example.com/", + "https://app.example.com/", + ) + + +class TestDuoSkipUpdateScript: + """Verify the domain guard in the JS script.""" + + def test_rejects_evil_subdomain(self): + assert 'hostname !== "duosecurity.com"' in DUO_SKIP_UPDATE_SCRIPT + assert 'hostname.endsWith(".duosecurity.com")' in DUO_SKIP_UPDATE_SCRIPT + + def test_uses_safe_observer_root(self): + assert "document.body || document.documentElement" in DUO_SKIP_UPDATE_SCRIPT diff --git a/packages/python/ess-browser/src/ess_browser/test_session.py b/packages/python/ess-browser/src/ess_browser/test_session.py new file mode 100644 index 0000000..5783c65 --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/test_session.py @@ -0,0 +1,318 @@ +"""Unit tests for BrowserSession launch configuration.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, PropertyMock, call, patch + +import pytest +from playwright.sync_api import Error as PlaywrightError + +from .auth import DUO_SKIP_UPDATE_SCRIPT +from .session import BrowserSession, _safe_origin + + +class _TargetClosedError(PlaywrightError): + """Mimics Playwright's private TargetClosedError for testing.""" + + __name__ = "TargetClosedError" + + def __init__( + self, + message: str = "Target page, context or browser has been closed", + ) -> None: + super().__init__(message) + + +# Make type(exc).__name__ return "TargetClosedError" so _is_target_closed matches. +_TargetClosedError.__name__ = "TargetClosedError" + + +@pytest.fixture() +def _mock_playwright(): + """Patch sync_playwright so no real browser is launched.""" + with patch("ess_browser.session.sync_playwright") as factory: + pw = MagicMock() + factory.return_value.start.return_value = pw + + mock_page = MagicMock() + mock_page.url = "about:blank" + mock_page.is_closed.return_value = False + mock_context = MagicMock() + mock_context.pages = [mock_page] + pw.chromium.launch_persistent_context.return_value = mock_context + + yield { + "pw": pw, + "context": mock_context, + "page": mock_page, + } + + +class TestEnableExtensions: + """Verify ignore_default_args is set when extensions are enabled.""" + + def test_extensions_enabled(self, _mock_playwright, tmp_path): + session = BrowserSession( + enable_extensions=True, + profile_dir=tmp_path / "profile", + ) + with session: + kwargs = _mock_playwright["pw"].chromium.launch_persistent_context.call_args + assert kwargs.kwargs.get("ignore_default_args") == ["--disable-extensions"] + + def test_extensions_disabled_by_default(self, _mock_playwright, tmp_path): + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + kwargs = _mock_playwright["pw"].chromium.launch_persistent_context.call_args + assert "ignore_default_args" not in kwargs.kwargs + + +class TestInitScripts: + """Verify custom init scripts are injected.""" + + def test_custom_init_scripts_added(self, _mock_playwright, tmp_path): + custom = "console.log('hello');" + session = BrowserSession( + init_scripts=[custom], + skip_duo_update_prompt=False, + profile_dir=tmp_path / "profile", + ) + with session: + ctx = _mock_playwright["context"] + ctx.add_init_script.assert_called_once_with(custom) + + def test_init_scripts_evaluated_on_existing_pages(self, _mock_playwright, tmp_path): + custom = "console.log('hello');" + _mock_playwright["page"].url = "https://example.com" + session = BrowserSession( + init_scripts=[custom], + skip_duo_update_prompt=False, + profile_dir=tmp_path / "profile", + ) + with session: + page = _mock_playwright["page"] + page.evaluate.assert_called_once_with(custom) + + +class TestDuoSkipScript: + """Verify DUO_SKIP_UPDATE_SCRIPT injection is controlled by the flag.""" + + def test_duo_script_injected_by_default(self, _mock_playwright, tmp_path): + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + ctx = _mock_playwright["context"] + ctx.add_init_script.assert_any_call(DUO_SKIP_UPDATE_SCRIPT) + + def test_duo_script_skipped_when_disabled(self, _mock_playwright, tmp_path): + session = BrowserSession( + skip_duo_update_prompt=False, + profile_dir=tmp_path / "profile", + ) + with session: + ctx = _mock_playwright["context"] + ctx.add_init_script.assert_not_called() + + def test_duo_script_evaluated_on_existing_pages(self, _mock_playwright, tmp_path): + _mock_playwright["page"].url = "https://example.com" + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + page = _mock_playwright["page"] + page.evaluate.assert_any_call(DUO_SKIP_UPDATE_SCRIPT) + + def test_duo_plus_custom_scripts_order(self, _mock_playwright, tmp_path): + custom = "console.log('custom');" + session = BrowserSession( + init_scripts=[custom], + profile_dir=tmp_path / "profile", + ) + with session: + ctx = _mock_playwright["context"] + assert ctx.add_init_script.call_args_list == [ + call(DUO_SKIP_UPDATE_SCRIPT), + call(custom), + ] + + +class TestSafePageEvaluation: + """Verify init scripts skip chrome:// and other unsafe pages.""" + + def test_skips_chrome_urls(self, _mock_playwright, tmp_path): + _mock_playwright["page"].url = "chrome://settings" + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + _mock_playwright["page"].evaluate.assert_not_called() + + def test_skips_chrome_extension_urls(self, _mock_playwright, tmp_path): + _mock_playwright["page"].url = "chrome-extension://abc/popup.html" + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + _mock_playwright["page"].evaluate.assert_not_called() + + def test_skips_devtools_urls(self, _mock_playwright, tmp_path): + _mock_playwright["page"].url = "devtools://devtools/bundled/inspector.html" + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + _mock_playwright["page"].evaluate.assert_not_called() + + def test_skips_about_urls(self, _mock_playwright, tmp_path): + _mock_playwright["page"].url = "about:blank" + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + _mock_playwright["page"].evaluate.assert_not_called() + + def test_evaluate_failure_does_not_kill_session(self, _mock_playwright, tmp_path): + _mock_playwright["page"].url = "https://example.com" + _mock_playwright["page"].evaluate.side_effect = RuntimeError("CSP") + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + assert session.context is not None + + def test_skips_closed_page(self, _mock_playwright, tmp_path): + _mock_playwright["page"].is_closed.return_value = True + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + _mock_playwright["page"].evaluate.assert_not_called() + + def test_handles_target_closed_on_url_access(self, _mock_playwright, tmp_path): + page = _mock_playwright["page"] + page.is_closed.return_value = False + url_prop = PropertyMock(side_effect=_TargetClosedError()) + original = type(page).__dict__.get("url") + type(page).url = url_prop + try: + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + page.evaluate.assert_not_called() + finally: + if original is None: + del type(page).url + else: + type(page).url = original + + def test_target_closed_during_evaluate_does_not_kill_session( + self, _mock_playwright, tmp_path + ): + _mock_playwright["page"].url = "https://example.com" + _mock_playwright["page"].evaluate.side_effect = _TargetClosedError() + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + assert session.context is not None + + def test_target_closed_with_custom_message(self, _mock_playwright, tmp_path): + _mock_playwright["page"].url = "https://example.com" + _mock_playwright["page"].evaluate.side_effect = _TargetClosedError( + "Connection closed", + ) + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + assert session.context is not None + + def test_plain_error_with_target_closed_message(self, _mock_playwright, tmp_path): + _mock_playwright["page"].url = "https://example.com" + _mock_playwright["page"].evaluate.side_effect = PlaywrightError( + "Target page, context or browser has been closed", + ) + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + assert session.context is not None + + def test_plain_error_with_target_closed_message_on_url( + self, _mock_playwright, tmp_path + ): + page = _mock_playwright["page"] + page.is_closed.return_value = False + url_prop = PropertyMock( + side_effect=PlaywrightError( + "Target page, context or browser has been closed", + ), + ) + original = type(page).__dict__.get("url") + type(page).url = url_prop + try: + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + page.evaluate.assert_not_called() + finally: + if original is None: + del type(page).url + else: + type(page).url = original + + def test_short_target_closed_is_not_suppressed(self, _mock_playwright, tmp_path): + _mock_playwright["page"].url = "https://example.com" + _mock_playwright["page"].evaluate.side_effect = PlaywrightError( + "Target closed", + ) + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + assert session.context is not None + _mock_playwright["page"].evaluate.assert_called() + + def test_browser_closed_is_not_suppressed(self, _mock_playwright, tmp_path): + _mock_playwright["page"].url = "https://example.com" + _mock_playwright["page"].evaluate.side_effect = PlaywrightError( + "Browser has been closed", + ) + session = BrowserSession(profile_dir=tmp_path / "profile") + with session: + assert session.context is not None + _mock_playwright["page"].evaluate.assert_called() + + def test_non_target_playwright_error_from_url_is_raised( + self, _mock_playwright, tmp_path + ): + page = _mock_playwright["page"] + page.is_closed.return_value = False + url_prop = PropertyMock( + side_effect=PlaywrightError("Protocol error"), + ) + original = type(page).__dict__.get("url") + type(page).url = url_prop + try: + session = BrowserSession(profile_dir=tmp_path / "profile") + with pytest.raises(PlaywrightError, match="Protocol error"): + session.__enter__() + finally: + if original is None: + del type(page).url + else: + type(page).url = original + + +class TestSafeOrigin: + """Verify _safe_origin strips secrets and sensitive paths.""" + + def test_strips_query_and_path(self): + assert _safe_origin("https://example.com/path?token=secret") == ( + "https://example.com" + ) + + def test_strips_userinfo(self): + assert _safe_origin("https://alice:x@host.com/p") == ("https://host.com") + + def test_preserves_port(self): + assert _safe_origin("https://host.com:8443/p") == ("https://host.com:8443") + + def test_file_url_returns_scheme_only(self): + assert _safe_origin("file:///etc/passwd") == "file:" + + def test_data_url_returns_scheme_only(self): + assert _safe_origin("data:text/html,

hi

") == "data:" + + def test_malformed_port_does_not_raise(self): + assert _safe_origin("https://host:bad/path") == "https://host" + + def test_ipv6_brackets_preserved(self): + assert _safe_origin("https://[::1]:8443/path") == "https://[::1]:8443" + + def test_ipv6_no_port(self): + assert _safe_origin("https://[::1]/path") == "https://[::1]" + + def test_scheme_relative_url(self): + assert _safe_origin("//example.com/path") == "//example.com" + + def test_scheme_relative_url_with_port(self): + assert _safe_origin("//example.com:9090/path") == "//example.com:9090" + + def test_empty_string(self): + assert _safe_origin("") == "" diff --git a/pyproject.toml b/pyproject.toml index 5b92674..0a491e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ members = [ "tools/python/gcp_gemini", "tools/python/langsmith-hosting", "tools/python/pulumi-utils", + "packages/python/ess-auth", + "packages/python/ess-browser", "packages/python/langsmith-client", "packages/python/langsmith-network", "packages/python/azure-ai", diff --git a/tools/python/langsmith-hosting/pyproject.toml b/tools/python/langsmith-hosting/pyproject.toml index 0cd1d66..a05a106 100644 --- a/tools/python/langsmith-hosting/pyproject.toml +++ b/tools/python/langsmith-hosting/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.12,<3.13" dependencies = [ "boto3==1.42.85", + "click>=8.1", "langsmith-network", "pulumi>=3.0.0", "pulumi-aws>=6.0.0", @@ -26,3 +27,6 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/langsmith_hosting"] + +[project.scripts] +langsmith-hosting = "langsmith_hosting.cli:cli" diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/cli.py b/tools/python/langsmith-hosting/src/langsmith_hosting/cli.py new file mode 100644 index 0000000..7424d69 --- /dev/null +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/cli.py @@ -0,0 +1,128 @@ +"""LangSmith Hosting CLI utilities. + +Quick-access commands for inspecting deployed agents on the EKS cluster. +Requires ``kubectl`` configured for the target cluster. +""" + +import json as json_mod +import subprocess # nosec B404 # developer tooling shells out to kubectl + +import click + +_INGRESS_NAME = "dataplane-langgraph-dataplane-ingress" +_DEFAULT_NAMESPACE = "default" + + +def _kubectl_json(*args: str, namespace: str = _DEFAULT_NAMESPACE) -> dict: + """Run a kubectl command and return parsed JSON output.""" + result = subprocess.run( # nosec B603 B607 + ["kubectl", "-n", namespace, *args, "-o", "json"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if result.returncode != 0: + raise click.ClickException(result.stderr.strip() or "kubectl failed") + return json_mod.loads(result.stdout) + + +def _detect_scheme(data: dict) -> str: + """Return 'https' if the ingress has an HTTPS listener, else 'http'.""" + annotations = data.get("metadata", {}).get("annotations", {}) + listen_ports = annotations.get("alb.ingress.kubernetes.io/listen-ports", "") + if "HTTPS" in listen_ports: + return "https" + return "http" + + +def _get_ingress_data(namespace: str) -> dict: + """Get the full ingress resource.""" + return _kubectl_json("get", "ingress", _INGRESS_NAME, namespace=namespace) + + +def _get_alb_hostname(data: dict) -> str: + """Get the ALB hostname from ingress data.""" + ingresses = data.get("status", {}).get("loadBalancer", {}).get("ingress", []) + if not ingresses or not ingresses[0].get("hostname"): + raise click.ClickException( + "ALB not provisioned. Check that the ingress has ALB annotations." + ) + return ingresses[0]["hostname"] + + +def _get_agent_paths(data: dict) -> list[dict[str, str]]: + """Get agent paths from the ingress rules.""" + agents = [] + for rule in data.get("spec", {}).get("rules", []): + for path in rule.get("http", {}).get("paths", []): + p = path.get("path", "") + if not p.startswith("/lgp/"): + continue + service = path.get("backend", {}).get("service", {}).get("name", "unknown") + agents.append({"path": p, "service": service}) + return agents + + +@click.group() +@click.option( + "-n", + "--namespace", + default=_DEFAULT_NAMESPACE, + show_default=True, + help="Kubernetes namespace for the dataplane ingress.", +) +@click.pass_context +def cli(ctx: click.Context, namespace: str): + """LangSmith Hosting utilities.""" + ctx.ensure_object(dict) + ctx.obj["namespace"] = namespace + + +@cli.command() +@click.pass_context +def ingress(ctx: click.Context): + """Print the ALB hostname for the shared dataplane ingress.""" + data = _get_ingress_data(ctx.obj["namespace"]) + click.echo(_get_alb_hostname(data)) + + +@cli.command() +@click.pass_context +def alb(ctx: click.Context): + """Print the ALB hostname (alias for ``ingress``).""" + data = _get_ingress_data(ctx.obj["namespace"]) + click.echo(_get_alb_hostname(data)) + + +@cli.command() +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +def endpoints(ctx: click.Context, as_json: bool): + """List agent endpoints on the shared ALB.""" + data = _get_ingress_data(ctx.obj["namespace"]) + hostname = _get_alb_hostname(data) + scheme = _detect_scheme(data) + agents = _get_agent_paths(data) + + if not agents: + raise click.ClickException("No agents found on the ingress.") + + if as_json: + out = [ + { + "service": a["service"], + "url": f"{scheme}://{hostname}{a['path']}", + "path": a["path"], + } + for a in agents + ] + click.echo(json_mod.dumps(out, indent=2)) + return + + click.echo(f"ALB: {hostname}\n") + for a in agents: + url = f"{scheme}://{hostname}{a['path']}" + click.echo(f" {a['service']}") + click.echo(f" {url}") + click.echo() diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/config.py b/tools/python/langsmith-hosting/src/langsmith_hosting/config.py index 6f67f67..4731cc9 100644 --- a/tools/python/langsmith-hosting/src/langsmith_hosting/config.py +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/config.py @@ -88,6 +88,10 @@ class LangSmithConfig: langsmith_api_key: pulumi.Output[str] langsmith_workspace_id: str + # Ingress (optional — ALB for deployed agents) + ingress_hostname: str | None + ingress_certificate_arn: str | None + def load_config() -> LangSmithConfig: """Load and validate all configuration values from the Pulumi stack config. @@ -129,4 +133,7 @@ def load_config() -> LangSmithConfig: # LangSmith Control Plane langsmith_api_key=cfg.require_secret("langsmithApiKey"), langsmith_workspace_id=cfg.require("langsmithWorkspaceId"), + # Ingress + ingress_hostname=cfg.get("ingressHostname"), + ingress_certificate_arn=cfg.get("ingressCertificateArn"), ) diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/dataplane.py b/tools/python/langsmith-hosting/src/langsmith_hosting/dataplane.py index c42b0f6..7461eca 100644 --- a/tools/python/langsmith-hosting/src/langsmith_hosting/dataplane.py +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/dataplane.py @@ -26,13 +26,8 @@ _KEDA_CHART_VERSION = "2.16.0" _DATAPLANE_CHART_VERSION = "0.2.17" -# Disabled until an ingress hostname is configured. Once a DNS record points to -# the ALB, set to True and pass the hostname via ingress.hostname in the Helm -# values. See docs/ingress-hostname-setup.md in the Terraform project. _WATCH_NAMESPACES = "default" -_ENABLE_HEALTH_CHECK = False - _REDIS_CPU_REQUEST = "1000m" _REDIS_MEMORY_REQUEST = "2Gi" _REDIS_CPU_LIMIT = "2000m" @@ -102,6 +97,50 @@ def create_dataplane( # ========================================================================= # 3. Install LangSmith dataplane (depends on both KEDA and Listener) # ========================================================================= + + # Build ingress values with ALB annotations so the AWS Load Balancer + # Controller provisions an ALB for the shared dataplane ingress. + ingress_annotations: dict[str, str] = { + "alb.ingress.kubernetes.io/scheme": "internet-facing", + "alb.ingress.kubernetes.io/target-type": "ip", + "alb.ingress.kubernetes.io/listen-ports": '[{"HTTP": 80}]', + "alb.ingress.kubernetes.io/healthcheck-path": "/ok", + "alb.ingress.kubernetes.io/healthcheck-protocol": "HTTP", + "alb.ingress.kubernetes.io/backend-protocol": "HTTP", + } + if cfg.ingress_certificate_arn: + cert_arn = cfg.ingress_certificate_arn + ingress_annotations.update( + { + "alb.ingress.kubernetes.io/certificate-arn": cert_arn, + "alb.ingress.kubernetes.io/listen-ports": ( + '[{"HTTP": 80}, {"HTTPS": 443}]' + ), + "alb.ingress.kubernetes.io/ssl-redirect": "443", + } + ) + + ingress_values: dict = { + "ingressClassName": "alb", + "annotations": ingress_annotations, + } + ingress_hostname = (cfg.ingress_hostname or "").strip() or None + if ingress_hostname and not cfg.ingress_certificate_arn: + raise pulumi.RunError( + "ingressCertificateArn is required when ingressHostname is set — " + "the operator constructs https:// health-check URLs from the hostname." + ) + if ingress_hostname: + ingress_values["hostname"] = ingress_hostname + + # Enable health checks only when a hostname is configured, because the + # listener constructs the check URL from the hostname. Without one, the + # URL is malformed and deployments fail with UnsupportedProtocol. + # Note: the operator always constructs https:// health check URLs when + # a hostname is set, so TLS (via ingressCertificateArn) is required + # before setting ingressHostname. + enable_health_check = ingress_hostname is not None + k8s.helm.v3.Release( f"{cluster_name}-dataplane", name="dataplane", @@ -119,11 +158,9 @@ def create_dataplane( "smithBackendUrl": _SMITH_BACKEND_URL, "langgraphListenerId": listener.listener_id, "watchNamespaces": ",".join(namespaces), - "enableLGPDeploymentHealthCheck": _ENABLE_HEALTH_CHECK, - }, - "ingress": { - "ingressClassName": "alb", + "enableLGPDeploymentHealthCheck": enable_health_check, }, + "ingress": ingress_values, "redis": { "statefulSet": { "resources": { diff --git a/tools/python/langsmith-hosting/src/langsmith_hosting/eks.py b/tools/python/langsmith-hosting/src/langsmith_hosting/eks.py index b263a4d..113dd18 100644 --- a/tools/python/langsmith-hosting/src/langsmith_hosting/eks.py +++ b/tools/python/langsmith-hosting/src/langsmith_hosting/eks.py @@ -142,6 +142,25 @@ def create_eks_cluster( tags=tags, ) + # ========================================================================= + # Launch template (IMDS hop limit = 2 for DinD sidecar) + # ========================================================================= + # The DinD sidecar runs a privileged container inside the pod to provide + # Docker-in-Docker for code execution sandboxes. It needs IMDS access to + # fetch ECR credentials via `aws ecr get-login-password`. With the default + # hop limit of 1, requests from the nested Docker daemon cannot reach IMDS + # (pod→node = hop 1, DinD→pod = hop 2). Setting the limit to 2 allows the + # second hop to succeed. + launch_template = aws.ec2.LaunchTemplate( + f"{cluster_name}-node-launch-template", + metadata_options=aws.ec2.LaunchTemplateMetadataOptionsArgs( + http_endpoint="enabled", + http_tokens="required", + http_put_response_hop_limit=2, + ), + tags=tags, + ) + # ========================================================================= # Managed node group # ========================================================================= @@ -155,6 +174,10 @@ def create_eks_cluster( "max_size": node_max_size, "desired_size": node_desired_size, }, + launch_template={ + "id": launch_template.id, + "version": launch_template.latest_version.apply(str), + }, ) # ========================================================================= @@ -185,9 +208,10 @@ def create_eks_cluster( # ========================================================================= # IAM role for EBS CSI controller (Pod Identity) # ========================================================================= - # The EBS CSI controller runs in a Deployment (not a DaemonSet), so it - # cannot rely on the node role via IMDS (hop limit = 1 blocks pod access). - # Pod Identity injects credentials directly without IMDS. + # The EBS CSI controller runs in a Deployment (not a DaemonSet). While + # the launch template now sets hop limit = 2 (for DinD IMDS access), + # Pod Identity is still preferred here — it injects credentials directly + # without IMDS, avoiding any timing/propagation issues. # Registered BEFORE the addon so credentials are available on first boot. ebs_csi_role = aws.iam.Role( f"{cluster_name}-ebs-csi-role", diff --git a/uv.lock b/uv.lock index 9753d74..314a7a5 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,8 @@ requires-python = "==3.12.*" members = [ "azure-ai", "core-github", + "ess-auth", + "ess-browser", "essentials", "gcp-gemini", "langsmith-client", @@ -378,6 +380,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] +[[package]] +name = "ess-auth" +version = "0.1.0" +source = { editable = "packages/python/ess-auth" } +dependencies = [ + { name = "httpx" }, + { name = "pyjwt" }, +] + +[package.optional-dependencies] +jwks = [ + { name = "pyjwt", extra = ["crypto"] }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pyjwt", specifier = ">=2.0.0" }, + { name = "pyjwt", extras = ["crypto"], marker = "extra == 'jwks'", specifier = ">=2.0.0" }, +] +provides-extras = ["jwks"] + +[[package]] +name = "ess-browser" +version = "0.1.0" +source = { editable = "packages/python/ess-browser" } +dependencies = [ + { name = "playwright" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "playwright", specifier = ">=1.40.0" }] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + [[package]] name = "essentials" version = "0.1.0" @@ -621,6 +664,24 @@ grpc = [ { name = "grpcio" }, ] +[[package]] +name = "greenlet" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" }, + { url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" }, + { url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" }, + { url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" }, +] + [[package]] name = "grpc-google-iam-v1" version = "0.14.3" @@ -779,6 +840,7 @@ version = "0.1.0" source = { editable = "tools/python/langsmith-hosting" } dependencies = [ { name = "boto3" }, + { name = "click" }, { name = "langsmith-network" }, { name = "pulumi" }, { name = "pulumi-aws" }, @@ -793,6 +855,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "boto3", specifier = "==1.42.85" }, + { name = "click", specifier = ">=8.1" }, { name = "langsmith-network", editable = "packages/python/langsmith-network" }, { name = "pulumi", specifier = ">=3.0.0" }, { name = "pulumi-aws", specifier = ">=6.0.0" }, @@ -958,6 +1021,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] +[[package]] +name = "playwright" +version = "1.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" }, + { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1245,6 +1327,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygithub" version = "2.8.1"