From fe2b96ac0789d8e246a80e14894339a2081cc5e5 Mon Sep 17 00:00:00 2001 From: Zizheng Tai Date: Wed, 22 Apr 2026 14:53:20 -0700 Subject: [PATCH] Use ID token for Pyodide Narada client --- packages/narada-pyodide/pyproject.toml | 2 +- packages/narada-pyodide/src/narada/client.py | 30 ++++-- packages/narada-pyodide/src/narada/version.py | 4 +- packages/narada-pyodide/src/narada/window.py | 79 +++++++++------- .../tests/test_cloud_browser.py | 93 +++++++++++++++++++ uv.lock | 2 +- 6 files changed, 168 insertions(+), 42 deletions(-) diff --git a/packages/narada-pyodide/pyproject.toml b/packages/narada-pyodide/pyproject.toml index 185a567..f1c0d14 100644 --- a/packages/narada-pyodide/pyproject.toml +++ b/packages/narada-pyodide/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "narada-pyodide" -version = "0.0.44" +version = "0.0.45a1" description = "Pyodide-compatible Python client SDK for Narada" license = "Apache-2.0" readme = "README.md" diff --git a/packages/narada-pyodide/src/narada/client.py b/packages/narada-pyodide/src/narada/client.py index d821cd8..f5760db 100644 --- a/packages/narada-pyodide/src/narada/client.py +++ b/packages/narada-pyodide/src/narada/client.py @@ -10,12 +10,19 @@ from pyodide.http import pyfetch from narada.version import __version__ -from narada.window import CloudBrowserWindow +from narada.window import CloudBrowserWindow, _build_auth_headers, _normalize_narada_env class Narada: def __init__(self, *, api_key: str | None = None) -> None: - self._api_key = api_key or os.environ["NARADA_API_KEY"] + self._api_key = api_key or os.environ.get("NARADA_API_KEY") + self._user_id = os.environ.get("NARADA_USER_ID") + self._env = _normalize_narada_env(os.environ.get("NARADA_ENV")) + + if self._api_key is None and (self._user_id is None or self._env is None): + raise ValueError( + "Either `api_key` or all of `NARADA_USER_ID` and `NARADA_ENV` must be provided" + ) async def __aenter__(self) -> Narada: await self._validate_sdk_config() @@ -27,9 +34,14 @@ async def __aexit__(self, *args: Any) -> None: async def _fetch_sdk_config(self) -> _SdkConfig | None: base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2") url = f"{base_url}/sdk/config" + headers = await _build_auth_headers( + api_key=self._api_key, + user_id=self._user_id, + env=self._env, + ) try: - resp = await pyfetch(url, headers={"x-api-key": self._api_key}) + resp = await pyfetch(url, headers=headers) if not resp.ok: logging.warning( "Failed to fetch SDK config: %s %s", resp.status, await resp.text() @@ -66,6 +78,11 @@ async def open_and_initialize_cloud_browser_window( endpoint_url = ( f"{base_url}/cloud-browser/create-and-initialize-cloud-browser-session" ) + headers = await _build_auth_headers( + api_key=self._api_key, + user_id=self._user_id, + env=self._env, + ) request_body = { "session_name": session_name, "session_timeout": session_timeout, @@ -75,10 +92,7 @@ async def open_and_initialize_cloud_browser_window( resp = await pyfetch( endpoint_url, method="POST", - headers={ - "Content-Type": "application/json", - "x-api-key": self._api_key, - }, + headers=headers, body=json.dumps(request_body), ) if not resp.ok: @@ -93,4 +107,6 @@ async def open_and_initialize_cloud_browser_window( browser_window_id=response_data["browser_window_id"], session_id=response_data["session_id"], api_key=self._api_key, + user_id=self._user_id, + env=self._env, ) diff --git a/packages/narada-pyodide/src/narada/version.py b/packages/narada-pyodide/src/narada/version.py index 4fa4cfc..54a5fc6 100644 --- a/packages/narada-pyodide/src/narada/version.py +++ b/packages/narada-pyodide/src/narada/version.py @@ -1,7 +1,7 @@ import importlib.metadata try: - __version__ = importlib.metadata.version("narada") + __version__ = importlib.metadata.version("narada-pyodide") except Exception: - # Fallback version if package metadata is not available + # Fallback sentinel. Validation treats this as a fatal release issue. __version__ = "unknown" diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index b9ab730..3aea969 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -91,6 +91,35 @@ async def _narada_get_id_token() -> str: ... _ResponseModel = TypeVar("_ResponseModel", bound=BaseModel) +def _normalize_narada_env(env: str | None) -> Literal["prod", "dev", None]: + if env is not None and env not in ("prod", "dev"): + raise ValueError(f"Invalid environment: {env!r}") + return cast(Literal["prod", "dev", None], env) + + +async def _build_auth_headers( + *, + api_key: str | None, + user_id: str | None, + env: Literal["prod", "dev", None], +) -> dict[str, str]: + headers: dict[str, str] = {"Content-Type": "application/json"} + + if api_key is not None: + headers["x-api-key"] = api_key + return headers + + if user_id is None or env is None: + raise ValueError( + "Either `api_key` or all of `user_id` and `env` must be provided" + ) + + headers["Authorization"] = f"Bearer {await _narada_get_id_token()}" + headers["X-Narada-User-ID"] = user_id + headers["X-Narada-Env"] = env + return headers + + @dataclass class SessionDownloadItem: """A file downloaded during a cloud browser session (file name, size, presigned GET URL).""" @@ -131,24 +160,12 @@ def __init__( def browser_window_id(self) -> str: return self._browser_window_id - async def _get_auth_headers( - self, *, include_content_type: bool = False - ) -> dict[str, str]: - headers: dict[str, str] = {} - if include_content_type: - headers["Content-Type"] = "application/json" - - if self._api_key is not None: - headers["x-api-key"] = self._api_key - return headers - - assert self._user_id is not None - assert self._env is not None - - headers["Authorization"] = f"Bearer {await _narada_get_id_token()}" - headers["X-Narada-User-ID"] = self._user_id - headers["X-Narada-Env"] = self._env - return headers + async def _get_auth_headers(self) -> dict[str, str]: + return await _build_auth_headers( + api_key=self._api_key, + user_id=self._user_id, + env=self._env, + ) async def upload_file(self, *, file: IO) -> File: """Uploads a file that can be used as an attachment in a subsequent `agent` request. @@ -233,7 +250,7 @@ async def dispatch_request( """ deadline = time.monotonic() + timeout - headers = await self._get_auth_headers(include_content_type=True) + headers = await self._get_auth_headers() agent_prefix = ( agent.prompt_prefix() if isinstance(agent, Agent) else f"{agent} " @@ -629,7 +646,7 @@ async def _run_extension_action( *, timeout: int | None = None, ) -> _ResponseModel | None: - headers = await self._get_auth_headers(include_content_type=True) + headers = await self._get_auth_headers() body = { "action": request.model_dump(), @@ -671,15 +688,11 @@ async def _run_extension_action( class LocalBrowserWindow(BaseBrowserWindow): def __init__(self) -> None: - env = os.environ.get("NARADA_ENV") - if env is not None and env not in ("prod", "dev"): - raise ValueError(f"Invalid environment: {env!r}") - super().__init__( api_key=os.environ.get("NARADA_API_KEY"), base_url=os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2"), user_id=os.environ.get("NARADA_USER_ID"), - env=env, + env=_normalize_narada_env(os.environ.get("NARADA_ENV")), browser_window_id=os.environ["NARADA_BROWSER_WINDOW_ID"], ) @@ -694,12 +707,14 @@ def __init__( browser_window_id: str, cloud_browser_session_id: str | None = None, api_key: str | None = None, + user_id: str | None = None, + env: Literal["prod", "dev", None] = None, ) -> None: super().__init__( - api_key=api_key or os.environ["NARADA_API_KEY"], + api_key=api_key or os.environ.get("NARADA_API_KEY"), base_url=os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2"), - user_id=None, - env=None, + user_id=user_id or os.environ.get("NARADA_USER_ID"), + env=_normalize_narada_env(env or os.environ.get("NARADA_ENV")), browser_window_id=browser_window_id, ) self._cloud_browser_session_id = cloud_browser_session_id @@ -744,12 +759,14 @@ def __init__( browser_window_id: str, session_id: str, api_key: str | None = None, + user_id: str | None = None, + env: Literal["prod", "dev", None] = None, ) -> None: super().__init__( - api_key=api_key or os.environ["NARADA_API_KEY"], + api_key=api_key or os.environ.get("NARADA_API_KEY"), base_url=os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2"), - user_id=None, - env=None, + user_id=user_id or os.environ.get("NARADA_USER_ID"), + env=_normalize_narada_env(env or os.environ.get("NARADA_ENV")), browser_window_id=browser_window_id, ) self._session_id = session_id diff --git a/packages/narada-pyodide/tests/test_cloud_browser.py b/packages/narada-pyodide/tests/test_cloud_browser.py index 4420efa..c50e1d6 100644 --- a/packages/narada-pyodide/tests/test_cloud_browser.py +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -84,6 +84,7 @@ def new() -> SimpleNamespace: client_module = importlib.import_module("narada.client") window_module = importlib.import_module("narada.window") window_module._narada_parent_run_ids = _FakeJsProxy([]) + window_module._narada_get_id_token = AsyncMock(return_value="frontend-id-token") return narada_pkg, client_module, window_module @@ -130,6 +131,98 @@ async def test_open_and_initialize_cloud_browser_window_maps_response( } +@pytest.mark.asyncio +async def test_open_and_initialize_cloud_browser_window_supports_frontend_bearer_auth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("NARADA_API_KEY", raising=False) + monkeypatch.setenv("NARADA_USER_ID", "user-123") + monkeypatch.setenv("NARADA_ENV", "dev") + + pyfetch = AsyncMock( + side_effect=[ + _FakeResponse( + json_data={ + "packages": {"narada-pyodide": {"min_required_version": "0.0.1"}} + } + ), + _FakeResponse( + json_data={ + "session_id": "session-456", + "session_name": "demo", + "browser_window_id": "browser-window-456", + } + ), + _FakeResponse(json_data={"success": True}), + ] + ) + narada_pkg, _, _ = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + + async with narada_pkg.Narada() as client: + window = await client.open_and_initialize_cloud_browser_window( + session_name="demo", + session_timeout=321, + require_extension=True, + ) + + assert isinstance(window, narada_pkg.CloudBrowserWindow) + assert window.browser_window_id == "browser-window-456" + assert window.cloud_browser_session_id == "session-456" + + sdk_config_call, create_call = pyfetch.await_args_list + assert sdk_config_call.args[0].endswith("/sdk/config") + assert sdk_config_call.kwargs["headers"] == { + "Content-Type": "application/json", + "Authorization": "Bearer frontend-id-token", + "X-Narada-User-ID": "user-123", + "X-Narada-Env": "dev", + } + assert create_call.args[0].endswith( + "/cloud-browser/create-and-initialize-cloud-browser-session" + ) + assert create_call.kwargs["headers"] == { + "Content-Type": "application/json", + "Authorization": "Bearer frontend-id-token", + "X-Narada-User-ID": "user-123", + "X-Narada-Env": "dev", + } + + await window.close() + stop_call = pyfetch.await_args_list[-1] + assert stop_call.args[0].endswith("/cloud-browser/stop-cloud-browser-session") + assert stop_call.kwargs["headers"] == { + "Content-Type": "application/json", + "Authorization": "Bearer frontend-id-token", + "X-Narada-User-ID": "user-123", + "X-Narada-Env": "dev", + } + + +@pytest.mark.asyncio +async def test_open_and_initialize_cloud_browser_window_raises_when_version_is_unknown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyfetch = AsyncMock( + return_value=_FakeResponse( + json_data={ + "packages": {"narada-pyodide": {"min_required_version": "999.0.0"}} + } + ) + ) + narada_pkg, client_module, _ = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + monkeypatch.setattr(client_module, "__version__", "unknown") + + with pytest.raises(RuntimeError) as exc_info: + async with narada_pkg.Narada(api_key="test-api-key"): + pass + + assert ( + "narada-pyodide version metadata is unavailable or invalid ('unknown')" + in str(exc_info.value) + ) + assert pyfetch.await_count == 1 + + @pytest.mark.asyncio async def test_cloud_browser_window_close_stops_cloud_session( monkeypatch: pytest.MonkeyPatch, diff --git a/uv.lock b/uv.lock index 66d4fea..dff1976 100644 --- a/uv.lock +++ b/uv.lock @@ -356,7 +356,7 @@ requires-dist = [{ name = "pydantic", specifier = "==2.12.5" }] [[package]] name = "narada-pyodide" -version = "0.0.44" +version = "0.0.45a1" source = { editable = "packages/narada-pyodide" } dependencies = [ { name = "narada-core" },