Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions examples/cloud_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment thread
volodymyr-narada marked this conversation as resolved.

# The cloud session is still running after exiting the context manager.
# You can save the session ID for later reconnection or management.
Expand Down Expand Up @@ -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}")
Comment thread
volodymyr-narada marked this conversation as resolved.
Comment thread
volodymyr-narada marked this conversation as resolved.

############################################################################
# IMPORTANT: The cloud browser continues accruing costs until the session #
# is stopped or times out. To avoid unexpected costs, make sure to stop #
Expand Down
8 changes: 0 additions & 8 deletions packages/narada/src/narada/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,14 +261,6 @@ 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)

cloud_window = CloudBrowserWindow(
browser_window_id=browser_window_id,
session_id=session_id,
Expand Down
96 changes: 95 additions & 1 deletion packages/narada/src/narada/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -69,6 +70,15 @@ class _PresignedPost(BaseModel):
fields: dict[str, Any]


@dataclass
class SessionDownloadItem:
"""A file downloaded during a cloud browser session (file name, size, presigned GET URL)."""

file_name: str
size: int
download_url: str


class BaseBrowserWindow(ABC):
_auth_headers: dict[str, str]
_base_url: str
Expand Down Expand Up @@ -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 (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"
)
return await _get_cloud_browser_downloads(
base_url=self._base_url,
auth_headers=self._auth_headers,
session_id=self._cloud_browser_session_id,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate get_downloaded_files implementation across classes

Low Severity

get_downloaded_files is implemented separately on both RemoteBrowserWindow and CloudBrowserWindow, with identical logic (calling _get_cloud_browser_downloads with the same parameters). Both classes already expose a cloud_browser_session_id property, so a single implementation in BaseBrowserWindow using that property could consolidate the two, eliminating the duplicated logic and reducing the risk of future inconsistency.

Additional Locations (1)

Fix in Cursor Fix in Web


def __str__(self) -> str:
return f"RemoteBrowserWindow(browser_window_id={self.browser_window_id})"

Expand Down Expand Up @@ -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 (file name, 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("
Expand All @@ -696,6 +726,70 @@ 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 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=timeout,
) as resp:
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(
file_name=item["file_name"],
size=item["size"],
download_url=presigned_urls[i],
)
for i, item in enumerate(files)
]


async def _stop_cloud_browser_session(
*,
base_url: str,
Expand All @@ -709,7 +803,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()
Expand Down
Loading