Skip to content
Open
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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,36 @@ Then you can iteratively query it with:
fara-cli --task "whats the weather in new york now" --endpoint_config azure_foundry_config.json
```

### Run Fara on Browser Use Cloud

Fara can connect to any existing Chromium browser over CDP. To run it on a
managed Browser Use Cloud browser, create a browser, give its CDP URL to Fara,
then stop the browser when Fara exits:

```bash
export BROWSER_USE_API_KEY=bu_your_key_here

browser=$(curl -sS https://api.browser-use.com/api/v4/browsers \
-H "X-Browser-Use-API-Key: $BROWSER_USE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"proxyCountryCode":"us"}')

export FARA_BROWSER_ID=$(echo "$browser" | jq -r .id)
export FARA_CDP_URL=$(echo "$browser" | jq -r .cdpUrl)

fara-cli --task "whats the weather in new york now" \
--endpoint_config azure_foundry_config.json

curl -sS -X PATCH \
"https://api.browser-use.com/api/v4/browsers/$FARA_BROWSER_ID" \
-H "X-Browser-Use-API-Key: $BROWSER_USE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"action":"stop"}'
```

`--cdp_url` overrides `FARA_CDP_URL`. The same connection works with Fara1.5
and the previous Fara-7B runner.

To try Fara inside Magentic-UI — a sandboxed browser environment with auditable action logging and user prompts at critical points — follow the instructions in the [Magentic-UI repo](https://github.com/microsoft/magentic-ui). You will need a model endpoint as before, but instead of fara-cli you can use Magentic-UI which has a nice UI (see video demos below).

Note: If you're using Windows, we highly recommend using WSL2 (Windows Subsystem for Linux). Please see the Windows instructions in the [Installation](#installation) section.
Expand Down
28 changes: 21 additions & 7 deletions src/fara/environments/playwright/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class PlaywrightEnvironmentConfig(BrowserEnvironmentConfig):
single_tab_mode: bool = True
default_timeout: int = 60000
page_script_path: str | None = None
cdp_url: str | None = None
use_browserbase: bool = False
browserbase_project_id: str | None = None
browserbase_api_key: str | None = None
Expand All @@ -56,7 +57,7 @@ class PlaywrightEnvironment(BrowserEnvironment):
"""Browser environment using Playwright.

Supports regular Chromium/Firefox/WebKit, persistent browser contexts,
and BrowserBase cloud sessions.
remote Chromium browsers over CDP, and BrowserBase cloud sessions.
"""

os_type = OSType.LINUX
Expand Down Expand Up @@ -133,7 +134,10 @@ async def initialize(self, **kwargs) -> None:
logger=self.logger,
)

if self.config.use_browserbase:
if self.config.cdp_url:
await self._init_remote_browser()
await self._setup_browser()
elif self.config.use_browserbase:
await self._init_browserbase()
elif self.config.browser_data_dir:
await self._init_persistent_browser()
Expand Down Expand Up @@ -167,6 +171,21 @@ async def _init_regular_browser(self) -> None:
)
self._page = await self._context.new_page()

async def _init_remote_browser(self) -> None:
"""Connect to an existing Chromium browser over CDP."""
cdp_url = self.config.cdp_url
if not cdp_url:
raise ValueError("A CDP URL is required for a remote browser")
self._browser = await self._playwright.chromium.connect_over_cdp(cdp_url)
if not self._browser.contexts:
raise RuntimeError("The remote CDP browser has no browser context")
self._context = self._browser.contexts[0]
self._page = (
self._context.pages[0]
if self._context.pages
else await self._context.new_page()
)

_BROWSERBASE_MAX_ATTEMPTS = 5
_BROWSERBASE_RATE_LIMIT_BACKOFF_S = 10
_BROWSERBASE_CAPTCHA_WAIT_S = 90
Expand Down Expand Up @@ -452,7 +471,6 @@ async def get_observation(self) -> bytes:
"""Get screenshot of current page."""
return await self.get_screenshot()


async def left_click(self, x: int, y: int) -> None:
new_page = await self._controller.click_coords(self._page, x, y)
if new_page is not None:
Expand Down Expand Up @@ -501,7 +519,6 @@ async def get_screenshot(self, path: str | None = None) -> bytes:
"""Capture a screenshot of the current page."""
return await self._controller.get_screenshot(self._page, path=path)


async def goto_url(self, url: str) -> None:
await self._controller.visit_page(self._page, url)

Expand All @@ -511,7 +528,6 @@ async def go_back(self) -> None:
async def refresh(self) -> None:
await self._page.reload(wait_until="commit")


async def middle_click(self, x: int, y: int) -> None:
await self._page.mouse.click(x, y, button="middle")

Expand All @@ -534,7 +550,6 @@ async def hscroll(self, pixels: int) -> None:
"""Horizontal scroll. Positive=right, negative=left."""
await self._page.mouse.wheel(pixels, 0)


async def click(self, x: float, y: float) -> Dict[str, Any]:
"""Click at coordinates."""
await self.left_click(int(x), int(y))
Expand Down Expand Up @@ -575,7 +590,6 @@ async def scroll_up(self, amount: int = 400) -> Dict[str, Any]:
await self._controller.page_up(self._page, amount=amount)
return {"success": True}


async def click_id(self, identifier: str) -> Dict[str, Any]:
"""Click on an element by its identifier."""
new_page = await self._controller.click_id(self._page, identifier)
Expand Down
20 changes: 19 additions & 1 deletion src/fara/fara_7b/browser/browser_bb.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def __init__(
to_resize_viewport: bool = True,
single_tab_mode: bool = True,
animate_actions: bool = False,
cdp_url: str | None = None,
use_browser_base: bool = False,
logger: Optional[logging.Logger] = None,
):
Expand All @@ -46,6 +47,7 @@ def __init__(
self.to_resize_viewport = to_resize_viewport
self.animate_actions = animate_actions
self.single_tab_mode = single_tab_mode
self.cdp_url = cdp_url
self.use_browser_base = use_browser_base
self.logger = logger or logging.getLogger("browser_manager")
self.is_linux = platform.system() == "Linux"
Expand Down Expand Up @@ -123,7 +125,9 @@ async def init(
self._playwright = await async_playwright().start()
self.shared_data_point = shared_data_point

if self.use_browser_base:
if self.cdp_url:
await self._init_remote_browser()
elif self.use_browser_base:
await self._init_browser_base(self.shared_data_point)
elif self.browser_data_dir is None:
await self._init_regular_browser(channel=self.browser_channel)
Expand Down Expand Up @@ -192,6 +196,20 @@ async def delayed_resume():
self._context.on("console", handle_console)
self._page.on("console", handle_console)

async def _init_remote_browser(self) -> None:
"""Connect to an existing Chromium browser over CDP."""
if not self.cdp_url:
raise ValueError("A CDP URL is required for a remote browser")
self.browser = await self._playwright.chromium.connect_over_cdp(self.cdp_url)
if not self.browser.contexts:
raise RuntimeError("The remote CDP browser has no browser context")
self._context = self.browser.contexts[0]
self._page = (
self._context.pages[0]
if self._context.pages
else await self._context.new_page()
)

async def _init_regular_browser(self, channel: str = "chromium") -> None:
"""Initialize regular browser according to the specified channel."""
if not self.headless and self.is_linux:
Expand Down
19 changes: 16 additions & 3 deletions src/fara/run_fara.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ async def run_fara15_agent(
output_folder: str = None,
save_screenshots: bool = True,
max_rounds: int = 100,
cdp_url: str | None = None,
use_browser_base: bool = False,
) -> None:
"""Interactive loop for the Fara-1.5 agent."""
Expand All @@ -66,6 +67,7 @@ async def run_fara15_agent(
browser_channel="chromium",
start_page=start_page,
single_tab_mode=True,
cdp_url=cdp_url,
use_browserbase=use_browser_base,
)
await env.initialize()
Expand Down Expand Up @@ -114,9 +116,7 @@ async def run_fara15_agent(
print("Running Fara...\n")
final_answer, _, _ = await agent.run(run_context)

while (
run_context.solver_log.status == SolverStatus.WAITING_FOR_USER
):
while run_context.solver_log.status == SolverStatus.WAITING_FOR_USER:
print(f"\nFara asks: {final_answer}")
reply = input("Your response (Enter to abandon): ").strip()
if not reply:
Expand Down Expand Up @@ -151,6 +151,7 @@ async def run_fara7b_agent(
downloads_folder: str = None,
save_screenshots: bool = True,
max_rounds: int = 100,
cdp_url: str | None = None,
use_browser_base: bool = False,
) -> None:
"""Interactive loop for the previous-generation Fara-7B agent."""
Expand All @@ -166,6 +167,7 @@ async def run_fara7b_agent(
to_resize_viewport=True,
single_tab_mode=True,
animate_actions=False,
cdp_url=cdp_url,
use_browser_base=use_browser_base,
logger=logger,
)
Expand Down Expand Up @@ -248,6 +250,12 @@ def main():
default=100,
help="Maximum number of rounds for the agent to run",
)
parser.add_argument(
"--cdp_url",
type=str,
default=os.environ.get("FARA_CDP_URL"),
help="Connect to an existing Chromium browser over CDP (defaults to FARA_CDP_URL)",
)
parser.add_argument(
"--browserbase",
action="store_true",
Expand Down Expand Up @@ -286,6 +294,9 @@ def main():

args = parser.parse_args()

if args.browserbase and args.cdp_url:
parser.error("--browserbase and --cdp_url cannot be used together")

if args.browserbase:
assert os.environ.get(
"BROWSERBASE_API_KEY"
Expand Down Expand Up @@ -322,6 +333,7 @@ def main():
downloads_folder=args.output_folder,
save_screenshots=args.save_screenshots,
max_rounds=args.max_rounds,
cdp_url=args.cdp_url,
use_browser_base=args.browserbase,
)
)
Expand All @@ -335,6 +347,7 @@ def main():
output_folder=args.output_folder,
save_screenshots=args.save_screenshots,
max_rounds=args.max_rounds,
cdp_url=args.cdp_url,
use_browser_base=args.browserbase,
)
)
Expand Down
70 changes: 70 additions & 0 deletions tests/test_remote_cdp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Tests for connecting both Fara browser runners to an existing CDP browser."""

from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest

from fara.environments.playwright import PlaywrightEnvironment
from fara.fara_7b.browser.browser_bb import BrowserBB


def _fake_playwright(existing_pages):
context = SimpleNamespace(pages=existing_pages, new_page=AsyncMock())
browser = SimpleNamespace(contexts=[context])
chromium = SimpleNamespace(connect_over_cdp=AsyncMock(return_value=browser))
return SimpleNamespace(chromium=chromium), browser, context


@pytest.mark.asyncio
async def test_playwright_environment_connects_to_remote_cdp():
page = object()
playwright, browser, context = _fake_playwright([page])
env = PlaywrightEnvironment(cdp_url="wss://cloud.example/cdp")
env._playwright = playwright

await env._init_remote_browser()

playwright.chromium.connect_over_cdp.assert_awaited_once_with(
"wss://cloud.example/cdp"
)
assert env._browser is browser
assert env._context is context
assert env._page is page


@pytest.mark.asyncio
async def test_playwright_environment_opens_page_when_remote_context_is_empty():
playwright, _, context = _fake_playwright([])
page = object()
context.new_page.return_value = page
env = PlaywrightEnvironment(cdp_url="wss://cloud.example/cdp")
env._playwright = playwright

await env._init_remote_browser()

context.new_page.assert_awaited_once_with()
assert env._page is page


@pytest.mark.asyncio
async def test_fara7b_connects_to_remote_cdp():
page = object()
playwright, browser, context = _fake_playwright([page])
manager = BrowserBB(
viewport_height=900,
viewport_width=1440,
headless=True,
page_script_path=None,
cdp_url="wss://cloud.example/cdp",
)
manager._playwright = playwright

await manager._init_remote_browser()

playwright.chromium.connect_over_cdp.assert_awaited_once_with(
"wss://cloud.example/cdp"
)
assert manager.browser is browser
assert manager._context is context
assert manager._page is page