From 362f54d46e383d2f9e822f8a5308b99f4cd96f2c Mon Sep 17 00:00:00 2001 From: Volodymyr Kasaraba Date: Mon, 23 Feb 2026 19:55:29 -0500 Subject: [PATCH 1/5] allow access cloud session downloads --- examples/cloud_browser.py | 4 +++ packages/narada/src/narada/client.py | 27 +++++++++++---- packages/narada/src/narada/window.py | 52 ++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/examples/cloud_browser.py b/examples/cloud_browser.py index f641ea6..b469943 100644 --- a/examples/cloud_browser.py +++ b/examples/cloud_browser.py @@ -50,6 +50,10 @@ async def main() -> None: ) await remote_window.close() # This will stop the cloud session. + # Get files downloaded during the session + downloaded_files = await window.get_downloaded_files() + print(f"Downloaded files {downloaded_files}") + ############################################################################ # IMPORTANT: The cloud browser continues accruing costs until the session # # is stopped or times out. To avoid unexpected costs, make sure to stop # diff --git a/packages/narada/src/narada/client.py b/packages/narada/src/narada/client.py index 695d27c..8e5376a 100644 --- a/packages/narada/src/narada/client.py +++ b/packages/narada/src/narada/client.py @@ -261,13 +261,26 @@ async def _initialize_cloud_browser_window( logging.info("Waiting for Narada extension to be installed...") await asyncio.sleep(1) - # TODO: consider this - # Get side panel page - # side_panel_url = create_side_panel_url(config, browser_window_id) - # side_panel_page = next( - # (p for p in context.pages if p.url == side_panel_url), None - # ) - # await self._fix_download_behavior(side_panel_page) + # Set download behavior so the extension can download files (e.g. when the agent + # downloads during the session). The side panel may appear shortly after init. + await browser.close() + browser = await self._playwright.chromium.connect_over_cdp( + cdp_websocket_url, headers=cdp_auth_headers + ) + context = browser.contexts[0] + side_panel_url = create_side_panel_url(config, browser_window_id) + for _ in range(10): + side_panel_page = next( + (p for p in context.pages if p.url == side_panel_url), None + ) + if side_panel_page is not None: + await self._fix_download_behavior(side_panel_page) + break + await asyncio.sleep(0.5) + else: + logging.debug( + "Side panel page not found for cloud browser; downloads may not work" + ) cloud_window = CloudBrowserWindow( browser_window_id=browser_window_id, diff --git a/packages/narada/src/narada/window.py b/packages/narada/src/narada/window.py index 4ea9050..7adcad4 100644 --- a/packages/narada/src/narada/window.py +++ b/packages/narada/src/narada/window.py @@ -3,6 +3,7 @@ import os import time from abc import ABC +from dataclasses import dataclass from http import HTTPStatus from pathlib import Path from typing import IO, Any, TypeVar, overload, override @@ -69,6 +70,15 @@ class _PresignedPost(BaseModel): fields: dict[str, Any] +@dataclass +class SessionDownloadItem: + """A file downloaded during a cloud browser session (path, size, presigned GET URL).""" + + path: str + size: int + download_url: str + + class BaseBrowserWindow(ABC): _auth_headers: dict[str, str] _base_url: str @@ -639,6 +649,18 @@ async def close(self, *, timeout: int | None = None) -> None: timeout=timeout, ) + async def get_downloaded_files(self) -> list[SessionDownloadItem]: + """Return files downloaded during this cloud browser session (path, size, presigned GET URL per file).""" + if self._cloud_browser_session_id is None: + raise ValueError( + "Cloud browser session ID is required to get downloaded files" + ) + return await _get_cloud_browser_downloads( + base_url=self._base_url, + auth_headers=self._auth_headers, + session_id=self._cloud_browser_session_id, + ) + def __str__(self) -> str: return f"RemoteBrowserWindow(browser_window_id={self.browser_window_id})" @@ -687,6 +709,14 @@ async def close(self, *, timeout: int | None = None) -> None: timeout=timeout, ) + async def get_downloaded_files(self) -> list[SessionDownloadItem]: + """Return files downloaded during this cloud browser session (path, size, presigned GET URL per file).""" + return await _get_cloud_browser_downloads( + base_url=self._base_url, + auth_headers=self._auth_headers, + session_id=self._session_id, + ) + def __str__(self) -> str: return ( "CloudBrowserWindow(" @@ -696,6 +726,28 @@ def __str__(self) -> str: ) +async def _get_cloud_browser_downloads( + *, + base_url: str, + auth_headers: dict[str, str], + session_id: str, +) -> list[SessionDownloadItem]: + """GET cloud-browser session downloads and return list of SessionDownloadItem.""" + async with aiohttp.ClientSession() as session: + async with session.get( + f"{base_url}/cloud-browser/sessions/{session_id}/downloads", + headers=auth_headers, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if not resp.ok: + resp.raise_for_status() + data = await resp.json() + return [ + SessionDownloadItem(path=item["path"], size=item["size"], download_url=item["download_url"]) + for item in data + ] + + async def _stop_cloud_browser_session( *, base_url: str, From 5920b6a48478e4ad517014704de9dff4cdc661c3 Mon Sep 17 00:00:00 2001 From: Volodymyr Kasaraba Date: Mon, 23 Feb 2026 20:02:57 -0500 Subject: [PATCH 2/5] fix formatting --- packages/narada/src/narada/window.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/narada/src/narada/window.py b/packages/narada/src/narada/window.py index 7adcad4..94acdea 100644 --- a/packages/narada/src/narada/window.py +++ b/packages/narada/src/narada/window.py @@ -743,7 +743,9 @@ async def _get_cloud_browser_downloads( resp.raise_for_status() data = await resp.json() return [ - SessionDownloadItem(path=item["path"], size=item["size"], download_url=item["download_url"]) + SessionDownloadItem( + path=item["path"], size=item["size"], download_url=item["download_url"] + ) for item in data ] From a13107b13e09792fde24cd48ae11b804dbbe4f19 Mon Sep 17 00:00:00 2001 From: Volodymyr Kasaraba Date: Tue, 24 Feb 2026 14:03:51 -0500 Subject: [PATCH 3/5] fix failed cdp connection bug --- examples/cloud_browser.py | 14 +++---- packages/narada/src/narada/client.py | 21 ---------- packages/narada/src/narada/window.py | 57 ++++++++++++++++++++++++---- 3 files changed, 56 insertions(+), 36 deletions(-) diff --git a/examples/cloud_browser.py b/examples/cloud_browser.py index b469943..f0e7ed3 100644 --- a/examples/cloud_browser.py +++ b/examples/cloud_browser.py @@ -13,15 +13,15 @@ async def main() -> None: session_timeout=3600, # Optional: session timeout in seconds ) - # Run a task in this browser window. - response = await window.agent( - prompt=( - 'Search for "LLM Compiler" on Google and open the first arXiv paper on the results ' - "page, then tell me who the authors are." - ) + # Run a task in this browser window. + response = await window.agent( + prompt=( + 'Search for "LLM Compiler" on Google and open the first arXiv paper on the results ' + "page, then tell me who the authors are." ) + ) - print("Response:", response.model_dump_json(indent=2)) + print("Response:", response.model_dump_json(indent=2)) # The cloud session is still running after exiting the context manager. # You can save the session ID for later reconnection or management. diff --git a/packages/narada/src/narada/client.py b/packages/narada/src/narada/client.py index 8e5376a..b25c252 100644 --- a/packages/narada/src/narada/client.py +++ b/packages/narada/src/narada/client.py @@ -261,27 +261,6 @@ async def _initialize_cloud_browser_window( logging.info("Waiting for Narada extension to be installed...") await asyncio.sleep(1) - # Set download behavior so the extension can download files (e.g. when the agent - # downloads during the session). The side panel may appear shortly after init. - await browser.close() - browser = await self._playwright.chromium.connect_over_cdp( - cdp_websocket_url, headers=cdp_auth_headers - ) - context = browser.contexts[0] - side_panel_url = create_side_panel_url(config, browser_window_id) - for _ in range(10): - side_panel_page = next( - (p for p in context.pages if p.url == side_panel_url), None - ) - if side_panel_page is not None: - await self._fix_download_behavior(side_panel_page) - break - await asyncio.sleep(0.5) - else: - logging.debug( - "Side panel page not found for cloud browser; downloads may not work" - ) - cloud_window = CloudBrowserWindow( browser_window_id=browser_window_id, session_id=session_id, diff --git a/packages/narada/src/narada/window.py b/packages/narada/src/narada/window.py index 94acdea..dc1dac6 100644 --- a/packages/narada/src/narada/window.py +++ b/packages/narada/src/narada/window.py @@ -726,27 +726,68 @@ def __str__(self) -> str: ) +async def _fetch_presigned_download_url( + http_session: aiohttp.ClientSession, + *, + base_url: str, + auth_headers: dict[str, str], + session_id: str, + key: str, + timeout: aiohttp.ClientTimeout, +) -> str: + async with http_session.get( + f"{base_url}/cloud-browser/replay/download-url", + params={"session_id": session_id, "key": key}, + headers=auth_headers, + timeout=timeout, + ) as resp: + resp.raise_for_status() + data = await resp.json() + return data["presigned_url"] + + async def _get_cloud_browser_downloads( *, base_url: str, auth_headers: dict[str, str], session_id: str, ) -> list[SessionDownloadItem]: - """GET cloud-browser session downloads and return list of SessionDownloadItem.""" - async with aiohttp.ClientSession() as session: - async with session.get( - f"{base_url}/cloud-browser/sessions/{session_id}/downloads", + """GET cloud-browser session downloads and return list of SessionDownloadItem with presigned URLs.""" + timeout = aiohttp.ClientTimeout(total=60) + async with aiohttp.ClientSession() as http_session: + async with http_session.get( + f"{base_url}/cloud-browser/replay/downloads", + params={"session_id": session_id}, headers=auth_headers, - timeout=aiohttp.ClientTimeout(total=30), + timeout=timeout, ) as resp: if not resp.ok: resp.raise_for_status() data = await resp.json() + files = data.get("downloaded_files") or [] + if not files: + return [] + + presigned_urls = await asyncio.gather( + *[ + _fetch_presigned_download_url( + http_session, + base_url=base_url, + auth_headers=auth_headers, + session_id=session_id, + key=f["key"], + timeout=timeout, + ) + for f in files + ] + ) return [ SessionDownloadItem( - path=item["path"], size=item["size"], download_url=item["download_url"] + path=item["file_name"], + size=item["size"], + download_url=presigned_urls[i], ) - for item in data + for i, item in enumerate(files) ] @@ -763,7 +804,7 @@ async def _stop_cloud_browser_session( f"{base_url}/cloud-browser/stop-cloud-browser-session", headers=auth_headers, json={"session_id": session_id}, - timeout=aiohttp.ClientTimeout(total=timeout or 10), + timeout=aiohttp.ClientTimeout(total=timeout or 40), ) as resp: if resp.ok: response_data = await resp.json() From 4a2aa9ce3afca0c39d76cbdf59ccb4b91e434785 Mon Sep 17 00:00:00 2001 From: Volodymyr Kasaraba Date: Tue, 24 Feb 2026 14:14:20 -0500 Subject: [PATCH 4/5] upd field name --- packages/narada/src/narada/window.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/narada/src/narada/window.py b/packages/narada/src/narada/window.py index dc1dac6..c51c9bf 100644 --- a/packages/narada/src/narada/window.py +++ b/packages/narada/src/narada/window.py @@ -72,9 +72,9 @@ class _PresignedPost(BaseModel): @dataclass class SessionDownloadItem: - """A file downloaded during a cloud browser session (path, size, presigned GET URL).""" + """A file downloaded during a cloud browser session (file name, size, presigned GET URL).""" - path: str + file_name: str size: int download_url: str @@ -650,7 +650,7 @@ async def close(self, *, timeout: int | None = None) -> None: ) async def get_downloaded_files(self) -> list[SessionDownloadItem]: - """Return files downloaded during this cloud browser session (path, size, presigned GET URL per file).""" + """Return files downloaded during this cloud browser session (file name, size, presigned GET URL per file).""" if self._cloud_browser_session_id is None: raise ValueError( "Cloud browser session ID is required to get downloaded files" @@ -710,7 +710,7 @@ async def close(self, *, timeout: int | None = None) -> None: ) async def get_downloaded_files(self) -> list[SessionDownloadItem]: - """Return files downloaded during this cloud browser session (path, size, presigned GET URL per file).""" + """Return files downloaded during this cloud browser session (file name, size, presigned GET URL per file).""" return await _get_cloud_browser_downloads( base_url=self._base_url, auth_headers=self._auth_headers, @@ -783,7 +783,7 @@ async def _get_cloud_browser_downloads( ) return [ SessionDownloadItem( - path=item["file_name"], + file_name=item["file_name"], size=item["size"], download_url=presigned_urls[i], ) From 504a0bf6a861fa693db5a4a17d03ca235c7bb421 Mon Sep 17 00:00:00 2001 From: Volodymyr Kasaraba Date: Tue, 3 Mar 2026 14:21:37 -0500 Subject: [PATCH 5/5] improve code quality --- packages/narada/src/narada/window.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/narada/src/narada/window.py b/packages/narada/src/narada/window.py index c51c9bf..a02576a 100644 --- a/packages/narada/src/narada/window.py +++ b/packages/narada/src/narada/window.py @@ -761,8 +761,7 @@ async def _get_cloud_browser_downloads( headers=auth_headers, timeout=timeout, ) as resp: - if not resp.ok: - resp.raise_for_status() + resp.raise_for_status() data = await resp.json() files = data.get("downloaded_files") or [] if not files: