refactor(plugins/browser): shared BaseCloudBrowserProvider for browser_use/browserbase/firecrawl

This commit is contained in:
Teknium
2026-09-02 11:23:21 -07:00
parent 75b7e054a5
commit e43f381b4e
8 changed files with 342 additions and 625 deletions

161
plugins/browser/_common.py Normal file
View File

@@ -0,0 +1,161 @@
"""Shared base for the bundled cloud-browser provider plugins.
Every vendor (Browserbase, Browser Use, Firecrawl) speaks the same REST shape:
POST to create a session, one request to release it, credentials from
:func:`agent.secret_scope.get_secret`. :class:`CloudBrowserProvider` owns that
lifecycle; subclasses supply class attributes and small hooks. Log messages are
rendered with the vendor label so the emitted text matches the pre-refactor
per-vendor modules, and they are emitted on the subclass module's logger.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any, Dict, List, Optional
import requests
from agent.browser_provider import BrowserProvider
_CLOSE_OK = {200, 201, 204}
class CloudBrowserProvider(BrowserProvider):
"""REST cloud-browser provider driven by class attributes + hooks.
Subclasses set ``provider_id`` / ``label``, ``release_method`` /
``release_path`` (``{session_id}`` placeholder, appended to
``config["base_url"]``), implement ``_get_config_or_none()`` and
``_headers(config)``, and write ``create_session`` on top of
:meth:`_post_create` / :meth:`_check_created` / :meth:`_session_name`.
"""
provider_id: str
label: str
release_method: str
release_path: str
missing_credentials_error: str = ""
# Setup-picker metadata; ``setup_tag=None`` hides the provider from the picker.
setup_tag: Optional[str] = None
setup_env_vars: List[Dict[str, str]] = []
# Rendered noun in "Failed to create <label> session"; Firecrawl says "browser session".
create_label_suffix: str = ""
# Browserbase's close warning historically omits the vendor name.
close_fail_fmt: Optional[str] = None
@property
def name(self) -> str:
return self.provider_id
@property
def display_name(self) -> str:
return self.label
@property
def _log(self) -> logging.Logger:
return logging.getLogger(type(self).__module__)
def is_available(self) -> bool:
return self._get_config_or_none() is not None
# -- config / request hooks ------------------------------------------
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
raise NotImplementedError
def _get_config(self) -> Dict[str, Any]:
config = self._get_config_or_none()
if config is None:
raise ValueError(self.missing_credentials_error)
return config
def _headers(self, config: Dict[str, Any]) -> Dict[str, str]:
raise NotImplementedError
def _release_headers(self, config: Dict[str, Any]) -> Dict[str, str]:
return self._headers(config)
def _release_body(self, config: Dict[str, Any]) -> Optional[Dict[str, object]]:
return None
def _release(self, config: Dict[str, Any], session_id: str, timeout: int) -> requests.Response:
kwargs: Dict[str, Any] = {"headers": self._release_headers(config), "timeout": timeout}
body = self._release_body(config)
if body is not None:
kwargs["json"] = body
url = f"{config['base_url']}{self.release_path.format(session_id=session_id)}"
return getattr(requests, self.release_method)(url, **kwargs)
# -- create helpers ---------------------------------------------------
@staticmethod
def _session_name(task_id: str) -> str:
return f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
def _post_create(
self, url: str, headers: Dict[str, str], payload: Dict[str, object], *, wrap_errors: bool = True
) -> requests.Response:
"""POST the create request; wrap network failures into RuntimeError unless
the caller (managed gateway) needs the raw exception to retry."""
try:
return requests.post(url, headers=headers, json=payload, timeout=30)
except requests.RequestException as exc:
if not wrap_errors:
raise
raise RuntimeError(f"{self.label} API connection failed: {exc}") from exc
def _check_created(self, response: requests.Response) -> None:
if not response.ok:
raise RuntimeError(
f"Failed to create {self.label}{self.create_label_suffix} session: "
f"{response.status_code} {response.text}"
)
# -- lifecycle --------------------------------------------------------
def close_session(self, session_id: str) -> bool:
try:
config = self._get_config()
except ValueError:
self._log.warning("Cannot close %s session %s — missing credentials", self.label, session_id)
return False
try:
response = self._release(config, session_id, timeout=10)
if response.status_code in _CLOSE_OK:
self._log.debug("Successfully closed %s session %s", self.label, session_id)
return True
self._log.warning(
self.close_fail_fmt or f"Failed to close {self.label} session %s: HTTP %s - %s",
session_id,
response.status_code,
response.text[:200],
)
return False
except Exception as e:
self._log.error("Exception closing %s session %s: %s", self.label, session_id, e)
return False
def emergency_cleanup(self, session_id: str) -> None:
config = self._get_config_or_none()
if config is None:
self._log.warning(
"Cannot emergency-cleanup %s session %s — missing credentials", self.label, session_id
)
return
try:
self._release(config, session_id, timeout=5)
except Exception as e:
self._log.debug("Emergency cleanup failed for %s session %s: %s", self.label, session_id, e)
def get_setup_schema(self) -> Optional[Dict[str, Any]]:
if self.setup_tag is None:
return None
return {
"name": self.label,
"badge": "paid",
"tag": self.setup_tag,
"env_vars": [dict(v) for v in self.setup_env_vars],
# Cloud-scoped hook: installs the agent-browser CLI only (the vendor hosts Chromium).
"post_setup": "browserbase",
}

View File

@@ -1,8 +1,4 @@
"""Browser Use cloud browser plugin — bundled, auto-loaded.
Mirrors the ``plugins/web/<vendor>/`` layout: ``provider.py`` holds the
provider class; ``__init__.py::register`` instantiates and registers it.
"""
"""Browser Use cloud browser plugin — bundled, auto-loaded."""
from __future__ import annotations
@@ -10,5 +6,4 @@ from plugins.browser.browser_use.provider import BrowserUseBrowserProvider
def register(ctx) -> None:
"""Register the Browser Use provider with the plugin context."""
ctx.register_browser_provider(BrowserUseBrowserProvider())

View File

@@ -1,50 +1,32 @@
"""Browser Use cloud browser provider — plugin form.
Subclasses :class:`agent.browser_provider.BrowserProvider` (the plugin-facing
ABC introduced in PR #25214). The legacy in-tree module
``tools.browser_providers.browser_use`` was removed in the same PR; this file
is now the canonical implementation.
"""Browser Use cloud browser provider (plugin form).
Browser Use is the only browser backend with dual auth: a direct
``BROWSER_USE_API_KEY`` for self-billed users, or the managed Nous tool
gateway (which Hermes uses to bill Browser Use sessions to a Nous
subscription). The dispatch order — direct API key first, managed gateway
second — preserves the pre-migration behaviour in
``tools.browser_providers.browser_use.BrowserUseProvider._get_config_or_none``.
``BROWSER_USE_API_KEY`` for self-billed users, or the managed Nous tool gateway
(bills sessions to a Nous subscription). Dispatch order: direct key first,
managed gateway second, unless ``tool_gateway.browser: gateway`` flips it.
Config keys this provider responds to::
browser:
cloud_provider: "browser-use" # explicit selection
tool_gateway:
browser: "gateway" # optional: prefer managed gateway
# even when BROWSER_USE_API_KEY is set
Auth env vars (one of)::
BROWSER_USE_API_KEY=... # https://browser-use.com
# OR a managed Nous gateway entry (configured via 'hermes setup')
Config: ``browser.cloud_provider: "browser-use"``; ``tool_gateway.browser``.
Auth: ``BROWSER_USE_API_KEY`` (https://browser-use.com) or a managed Nous
gateway entry configured via ``hermes setup``.
"""
from __future__ import annotations
import logging
import os
import threading
import uuid
from typing import Any, Dict, Optional
import requests
from agent.browser_provider import BrowserProvider
from agent.secret_scope import get_secret
from plugins.browser._common import CloudBrowserProvider
logger = logging.getLogger(__name__)
# Idempotency tracking for managed-mode session creation. The managed Nous
# gateway returns 409 "already in progress" on retried POSTs; we forward the
# original idempotency key so the gateway can deduplicate. Cleared on
# success or terminal failure.
# Idempotency keys for managed-mode session creation: the gateway returns 409
# "already in progress" on retried POSTs, so we forward the original key.
# Cleared on success or terminal failure.
_pending_create_keys: Dict[str, str] = {}
_pending_create_keys_lock = threading.Lock()
@@ -58,7 +40,6 @@ def _get_or_create_pending_create_key(task_id: str) -> str:
existing = _pending_create_keys.get(task_id)
if existing:
return existing
created = f"browser-use-session-create:{uuid.uuid4().hex}"
_pending_create_keys[task_id] = created
return created
@@ -70,80 +51,50 @@ def _clear_pending_create_key(task_id: str) -> None:
def _should_preserve_pending_create_key(response: requests.Response) -> bool:
"""Decide whether to keep the idempotency key after a failed create.
Preserve the key when the failure looks retryable (5xx) OR when the
gateway reports the original request is still in flight (409 "already
in progress") — in either case, retrying with the same key lets the
gateway deduplicate.
Drop the key on any other 4xx (auth failure, bad request, etc.) — those
won't succeed by being retried.
"""
"""Keep the idempotency key when the failure is retryable: any 5xx, or a 409
whose error message says the original request is still in flight. Any other
4xx (auth, bad request) will not succeed on retry, so the key is dropped."""
if response.status_code >= 500:
return True
if response.status_code != 409:
return False
try:
payload = response.json()
except Exception:
return False
if not isinstance(payload, dict):
return False
error = payload.get("error")
if not isinstance(error, dict):
return False
message = str(error.get("message") or "").lower()
return "already in progress" in message
return "already in progress" in str(error.get("message") or "").lower()
class BrowserUseBrowserProvider(BrowserProvider):
"""Browser Use (https://browser-use.com) cloud browser backend.
class BrowserUseBrowserProvider(CloudBrowserProvider):
"""Browser Use (https://browser-use.com) cloud browser backend."""
Dual auth: prefers a direct BROWSER_USE_API_KEY when set, falling back
to the managed Nous tool gateway when ``tool_gateway.browser`` config
routes through it. Setting ``tool_gateway.browser: gateway`` flips the
order so managed billing wins even when BROWSER_USE_API_KEY is present.
"""
@property
def name(self) -> str:
return "browser-use"
@property
def display_name(self) -> str:
return "Browser Use"
provider_id = "browser-use"
label = "Browser Use"
release_method = "patch"
release_path = "/browsers/{session_id}"
# Hidden from the hermes tools picker: the "Browser Use" row activates the
# CLI backend (tools/browser_use_cli.py). This provider stays registered for
# the Nous gateway path and un-migrated legacy cloud_provider configs.
setup_tag = None
def is_available(self) -> bool:
return self._get_config_or_none(refresh_token=False) is not None
# ------------------------------------------------------------------
# Config resolution (direct API key OR managed Nous gateway)
# ------------------------------------------------------------------
def _get_config_or_none(self, *, refresh_token: bool = True) -> Optional[Dict[str, Any]]:
# Import here to avoid a hard dependency at module-import time —
# managed_tool_gateway pulls in the Nous auth stack which can be
# heavy and is not needed for direct-API-key users.
from tools.managed_tool_gateway import (
peek_nous_access_token,
resolve_managed_tool_gateway,
)
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
read_selection,
)
# Lazy import: managed_tool_gateway pulls in the Nous auth stack, which
# direct-API-key users never need.
from tools.managed_tool_gateway import peek_nous_access_token, resolve_managed_tool_gateway
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, read_selection
def _managed_config() -> Optional[Dict[str, Any]]:
# Keep availability scans off the synchronous OAuth refresh path.
managed = resolve_managed_tool_gateway(
"browser-use",
token_reader=None if refresh_token else peek_nous_access_token,
"browser-use", token_reader=None if refresh_token else peek_nous_access_token
)
if managed is None:
return None
@@ -155,73 +106,45 @@ class BrowserUseBrowserProvider(BrowserProvider):
api_key = get_secret("BROWSER_USE_API_KEY")
selected = read_selection("browser")
direct = {"api_key": api_key, "base_url": _BASE_URL, "managed_mode": False}
# Strict selection: "nous" (or legacy use_gateway: true) → managed
# gateway ONLY; any other stored browser selection → direct API key
# ONLY (no silent managed fallback); never-configured → legacy
# behavior (direct key when present, else managed gateway).
# Strict selection: "nous" (or legacy use_gateway: true) → managed gateway
# ONLY; any other stored selection → direct key ONLY (no silent managed
# fallback); never-configured → direct key when present, else managed.
if selected == NOUS_MANAGED_PROVIDER:
return _managed_config()
if selected is not None:
if api_key:
return {
"api_key": api_key,
"base_url": _BASE_URL,
"managed_mode": False,
}
return None
if api_key:
return {
"api_key": api_key,
"base_url": _BASE_URL,
"managed_mode": False,
}
return _managed_config()
return direct if api_key else None
return direct if api_key else _managed_config()
def _get_config(self) -> Dict[str, Any]:
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
managed_nous_tools_enabled,
read_selection,
selection_error,
NOUS_MANAGED_PROVIDER, managed_nous_tools_enabled, read_selection, selection_error,
)
config = self._get_config_or_none()
if config is None:
selected = read_selection("browser")
if selected == NOUS_MANAGED_PROVIDER:
raise ValueError(selection_error(
"browser",
NOUS_MANAGED_PROVIDER,
"the Nous Tool Gateway is not available (not entitled or "
"unreachable)",
))
if selected is not None:
raise ValueError(selection_error(
"browser",
selected,
"BROWSER_USE_API_KEY is not set",
))
message = (
"Browser Use requires a direct BROWSER_USE_API_KEY credential."
if config is not None:
return config
selected = read_selection("browser")
if selected == NOUS_MANAGED_PROVIDER:
raise ValueError(selection_error(
"browser", NOUS_MANAGED_PROVIDER,
"the Nous Tool Gateway is not available (not entitled or unreachable)",
))
if selected is not None:
raise ValueError(selection_error("browser", selected, "BROWSER_USE_API_KEY is not set"))
if managed_nous_tools_enabled():
raise ValueError(
"Browser Use requires either a direct BROWSER_USE_API_KEY "
"credential or a managed Browser Use gateway configuration."
)
if managed_nous_tools_enabled():
message = (
"Browser Use requires either a direct BROWSER_USE_API_KEY "
"credential or a managed Browser Use gateway configuration."
)
raise ValueError(message)
return config
# ------------------------------------------------------------------
# Session lifecycle
# ------------------------------------------------------------------
raise ValueError("Browser Use requires a direct BROWSER_USE_API_KEY credential.")
def _headers(self, config: Dict[str, Any]) -> Dict[str, str]:
return {
"Content-Type": "application/json",
"X-Browser-Use-API-Key": config["api_key"],
}
return {"Content-Type": "application/json", "X-Browser-Use-API-Key": config["api_key"]}
def _release_body(self, config: Dict[str, Any]) -> Dict[str, object]:
return {"action": "stop"}
def create_session(self, task_id: str) -> Dict[str, object]:
config = self._get_config()
@@ -232,120 +155,34 @@ class BrowserUseBrowserProvider(BrowserProvider):
headers["X-Idempotency-Key"] = _get_or_create_pending_create_key(task_id)
# Keep gateway-backed sessions short so billing authorization does not
# default to a long Browser-Use timeout when Hermes only needs a task-
# scoped ephemeral browser.
# default to a long Browser-Use timeout for a task-scoped browser.
payload = (
{
"timeout": _DEFAULT_MANAGED_TIMEOUT_MINUTES,
"proxyCountryCode": _DEFAULT_MANAGED_PROXY_COUNTRY_CODE,
}
if managed_mode
else {}
{"timeout": _DEFAULT_MANAGED_TIMEOUT_MINUTES, "proxyCountryCode": _DEFAULT_MANAGED_PROXY_COUNTRY_CODE}
if managed_mode else {}
)
try:
response = requests.post(
f"{config['base_url']}/browsers",
headers=headers,
json=payload,
timeout=30,
)
except requests.RequestException as exc:
# Managed mode: propagate raw so callers can retry with the
# preserved idempotency key. Direct mode: wrap network failures
# into a clean RuntimeError for end users.
if managed_mode:
raise
raise RuntimeError(
f"Browser Use API connection failed: {exc}"
) from exc
if not response.ok:
if managed_mode and not _should_preserve_pending_create_key(response):
_clear_pending_create_key(task_id)
raise RuntimeError(
f"Failed to create Browser Use session: "
f"{response.status_code} {response.text}"
)
# Managed mode propagates network errors raw so callers can retry with
# the preserved idempotency key; direct mode wraps them.
response = self._post_create(
f"{config['base_url']}/browsers", headers, payload, wrap_errors=not managed_mode
)
if not response.ok and managed_mode and not _should_preserve_pending_create_key(response):
_clear_pending_create_key(task_id)
self._check_created(response)
session_data = response.json()
if managed_mode:
_clear_pending_create_key(task_id)
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
external_call_id = (
response.headers.get("x-external-call-id") if managed_mode else None
)
session_name = self._session_name(task_id)
logger.info("Created Browser Use session %s", session_name)
cdp_url = session_data.get("cdpUrl") or session_data.get("connectUrl") or ""
return {
"session_name": session_name,
"bb_session_id": session_data["id"],
"cdp_url": cdp_url,
# Browser Use sessions have a fixed server-side lifetime. Preserve
# the authority returned by the API so the dispatcher can retire an
# expired CDP endpoint instead of reconnecting to it indefinitely.
"cdp_url": session_data.get("cdpUrl") or session_data.get("connectUrl") or "",
# Sessions have a fixed server-side lifetime; preserve the API's
# authority so the dispatcher retires an expired CDP endpoint.
"expires_at": session_data.get("timeoutAt"),
"features": {"browser_use": True},
"external_call_id": external_call_id,
"external_call_id": response.headers.get("x-external-call-id") if managed_mode else None,
}
def close_session(self, session_id: str) -> bool:
try:
config = self._get_config()
except ValueError:
logger.warning(
"Cannot close Browser Use session %s — missing credentials", session_id
)
return False
try:
response = requests.patch(
f"{config['base_url']}/browsers/{session_id}",
headers=self._headers(config),
json={"action": "stop"},
timeout=10,
)
if response.status_code in {200, 201, 204}:
logger.debug("Successfully closed Browser Use session %s", session_id)
return True
else:
logger.warning(
"Failed to close Browser Use session %s: HTTP %s - %s",
session_id,
response.status_code,
response.text[:200],
)
return False
except Exception as e:
logger.error("Exception closing Browser Use session %s: %s", session_id, e)
return False
def emergency_cleanup(self, session_id: str) -> None:
config = self._get_config_or_none()
if config is None:
logger.warning(
"Cannot emergency-cleanup Browser Use session %s — missing credentials",
session_id,
)
return
try:
requests.patch(
f"{config['base_url']}/browsers/{session_id}",
headers=self._headers(config),
json={"action": "stop"},
timeout=5,
)
except Exception as e:
logger.debug(
"Emergency cleanup failed for Browser Use session %s: %s", session_id, e
)
def get_setup_schema(self) -> Optional[Dict[str, Any]]:
# Hidden from the hermes tools picker: the "Browser Use" row now
# activates the CLI-based backend (tools/browser_use_cli.py). This
# provider stays registered for the Nous gateway path and un-migrated
# legacy cloud_provider configs.
return None

View File

@@ -1,9 +1,4 @@
"""Browserbase cloud browser plugin — bundled, auto-loaded.
Mirrors the ``plugins/web/<vendor>/`` and ``plugins/image_gen/openai/``
layout: ``provider.py`` holds the provider class; ``__init__.py::register``
instantiates and registers it via the plugin context.
"""
"""Browserbase cloud browser plugin — bundled, auto-loaded."""
from __future__ import annotations
@@ -11,5 +6,4 @@ from plugins.browser.browserbase.provider import BrowserbaseBrowserProvider
def register(ctx) -> None:
"""Register the Browserbase provider with the plugin context."""
ctx.register_browser_provider(BrowserbaseBrowserProvider())

View File

@@ -1,70 +1,44 @@
"""Browserbase cloud browser provider — plugin form.
"""Browserbase cloud browser provider (plugin form).
Subclasses :class:`agent.browser_provider.BrowserProvider` (the plugin-facing
ABC introduced in PR #25214). The legacy in-tree module
``tools.browser_providers.browserbase`` was removed in the same PR; this file
is now the canonical implementation.
Direct ``BROWSERBASE_API_KEY`` + ``BROWSERBASE_PROJECT_ID`` credentials only;
the Nous subscription routes through Browser Use instead.
Browserbase requires direct ``BROWSERBASE_API_KEY`` and ``BROWSERBASE_PROJECT_ID``
credentials. Managed Nous gateway support has been removed — the Nous
subscription now routes through Browser Use instead (see
``plugins/browser/browser_use/``).
Config keys this provider responds to::
browser:
cloud_provider: "browserbase"
Auth env vars::
BROWSERBASE_API_KEY=... # https://browserbase.com
BROWSERBASE_PROJECT_ID=...
Optional feature knobs::
BROWSERBASE_BASE_URL=... # default https://api.browserbase.com
BROWSERBASE_PROXIES=true # default true
BROWSERBASE_ADVANCED_STEALTH=false
BROWSERBASE_KEEP_ALIVE=true # default true
BROWSERBASE_SESSION_TIMEOUT=... (seconds, integer, max 21600 = 6h)
Config: ``browser.cloud_provider: "browserbase"``. Optional knobs:
``BROWSERBASE_BASE_URL`` (default https://api.browserbase.com),
``BROWSERBASE_PROXIES`` (default true), ``BROWSERBASE_ADVANCED_STEALTH``
(default false), ``BROWSERBASE_KEEP_ALIVE`` (default true),
``BROWSERBASE_SESSION_TIMEOUT`` (seconds, integer, max 21600 = 6h).
"""
from __future__ import annotations
import logging
import os
import uuid
from typing import Any, Dict, Optional
import requests
from agent.browser_provider import BrowserProvider
from agent.secret_scope import get_secret
from plugins.browser._common import CloudBrowserProvider
logger = logging.getLogger(__name__)
class BrowserbaseBrowserProvider(BrowserProvider):
"""Browserbase (https://browserbase.com) cloud browser backend.
class BrowserbaseBrowserProvider(CloudBrowserProvider):
"""Browserbase (https://browserbase.com) cloud browser backend."""
Direct credentials only — managed-Nous-gateway support lives on the
Browser Use provider now.
"""
@property
def name(self) -> str:
return "browserbase"
@property
def display_name(self) -> str:
return "Browserbase"
def is_available(self) -> bool:
return self._get_config_or_none() is not None
# ------------------------------------------------------------------
# Config resolution
# ------------------------------------------------------------------
provider_id = "browserbase"
label = "Browserbase"
release_method = "post"
release_path = "/v1/sessions/{session_id}"
missing_credentials_error = (
"Browserbase requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID "
"environment variables."
)
close_fail_fmt = "Failed to close session %s: HTTP %s - %s"
setup_tag = "Cloud browser with stealth and proxies"
setup_env_vars = [
{"key": "BROWSERBASE_API_KEY", "prompt": "Browserbase API key", "url": "https://browserbase.com"},
{"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"},
]
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
api_key = get_secret("BROWSERBASE_API_KEY")
@@ -73,140 +47,80 @@ class BrowserbaseBrowserProvider(BrowserProvider):
return {
"api_key": api_key,
"project_id": project_id,
"base_url": os.environ.get(
"BROWSERBASE_BASE_URL", "https://api.browserbase.com"
).rstrip("/"),
"base_url": os.environ.get("BROWSERBASE_BASE_URL", "https://api.browserbase.com").rstrip("/"),
}
return None
def _get_config(self) -> Dict[str, Any]:
config = self._get_config_or_none()
if config is None:
raise ValueError(
"Browserbase requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID "
"environment variables."
)
return config
def _headers(self, config: Dict[str, Any]) -> Dict[str, str]:
return {"Content-Type": "application/json", "X-BB-API-Key": config["api_key"]}
# ------------------------------------------------------------------
# Session lifecycle
# ------------------------------------------------------------------
def _release_headers(self, config: Dict[str, Any]) -> Dict[str, str]:
return {"X-BB-API-Key": config["api_key"], "Content-Type": "application/json"}
def _release_body(self, config: Dict[str, Any]) -> Dict[str, object]:
return {"projectId": config["project_id"], "status": "REQUEST_RELEASE"}
def create_session(self, task_id: str) -> Dict[str, object]:
config = self._get_config()
# Optional env-var knobs
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
enable_advanced_stealth = (
os.environ.get("BROWSERBASE_ADVANCED_STEALTH", "false").lower() == "true"
)
enable_keep_alive = (
os.environ.get("BROWSERBASE_KEEP_ALIVE", "true").lower() != "false"
)
enable_advanced_stealth = os.environ.get("BROWSERBASE_ADVANCED_STEALTH", "false").lower() == "true"
enable_keep_alive = os.environ.get("BROWSERBASE_KEEP_ALIVE", "true").lower() != "false"
custom_timeout_ms = os.environ.get("BROWSERBASE_SESSION_TIMEOUT")
features_enabled = {
"basic_stealth": True,
"proxies": False,
"advanced_stealth": False,
"keep_alive": False,
"custom_timeout": False,
}
session_config: Dict[str, object] = {"projectId": config["project_id"]}
if enable_keep_alive:
session_config["keepAlive"] = True
if custom_timeout_ms:
try:
timeout_val = int(custom_timeout_ms)
if timeout_val > 0:
session_config["timeout"] = timeout_val
except ValueError:
logger.warning(
"Invalid BROWSERBASE_SESSION_TIMEOUT value: %s", custom_timeout_ms
)
logger.warning("Invalid BROWSERBASE_SESSION_TIMEOUT value: %s", custom_timeout_ms)
if enable_proxies:
session_config["proxies"] = True
if enable_advanced_stealth:
session_config["browserSettings"] = {"advancedStealth": True}
# --- Create session via API ---
headers = {
"Content-Type": "application/json",
"X-BB-API-Key": config["api_key"],
}
url = f"{config['base_url']}/v1/sessions"
headers = self._headers(config)
response = self._post_create(url, headers, session_config)
try:
response = requests.post(
f"{config['base_url']}/v1/sessions",
headers=headers,
json=session_config,
timeout=30,
)
proxies_fallback = False
keepalive_fallback = False
# Handle 402 — paid features unavailable
if response.status_code == 402:
if enable_keep_alive:
keepalive_fallback = True
logger.warning(
"keepAlive may require paid plan (402), retrying without it. "
"Sessions may timeout during long operations."
)
session_config.pop("keepAlive", None)
response = requests.post(
f"{config['base_url']}/v1/sessions",
headers=headers,
json=session_config,
timeout=30,
)
if response.status_code == 402 and enable_proxies:
proxies_fallback = True
logger.warning(
"Proxies unavailable (402), retrying without proxies. "
"Bot detection may be less effective."
)
session_config.pop("proxies", None)
response = requests.post(
f"{config['base_url']}/v1/sessions",
headers=headers,
json=session_config,
timeout=30,
)
except requests.RequestException as exc:
raise RuntimeError(
f"Browserbase API connection failed: {exc}"
) from exc
if not response.ok:
raise RuntimeError(
f"Failed to create Browserbase session: "
f"{response.status_code} {response.text}"
)
# 402 — paid features unavailable: drop keepAlive, then proxies, and retry.
proxies_fallback = False
keepalive_fallback = False
if response.status_code == 402:
if enable_keep_alive:
keepalive_fallback = True
logger.warning(
"keepAlive may require paid plan (402), retrying without it. "
"Sessions may timeout during long operations."
)
session_config.pop("keepAlive", None)
response = self._post_create(url, headers, session_config)
if response.status_code == 402 and enable_proxies:
proxies_fallback = True
logger.warning(
"Proxies unavailable (402), retrying without proxies. "
"Bot detection may be less effective."
)
session_config.pop("proxies", None)
response = self._post_create(url, headers, session_config)
self._check_created(response)
session_data = response.json()
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
if enable_proxies and not proxies_fallback:
features_enabled["proxies"] = True
if enable_advanced_stealth:
features_enabled["advanced_stealth"] = True
if enable_keep_alive and not keepalive_fallback:
features_enabled["keep_alive"] = True
if custom_timeout_ms and "timeout" in session_config:
features_enabled["custom_timeout"] = True
session_name = self._session_name(task_id)
features_enabled = {
"basic_stealth": True,
"proxies": enable_proxies and not proxies_fallback,
"advanced_stealth": enable_advanced_stealth,
"keep_alive": enable_keep_alive and not keepalive_fallback,
"custom_timeout": bool(custom_timeout_ms) and "timeout" in session_config,
}
feature_str = ", ".join(k for k, v in features_enabled.items() if v)
logger.info(
"Created Browserbase session %s with features: %s", session_name, feature_str
)
logger.info("Created Browserbase session %s with features: %s", session_name, feature_str)
return {
"session_name": session_name,
@@ -214,87 +128,3 @@ class BrowserbaseBrowserProvider(BrowserProvider):
"cdp_url": session_data["connectUrl"],
"features": features_enabled,
}
def close_session(self, session_id: str) -> bool:
try:
config = self._get_config()
except ValueError:
logger.warning(
"Cannot close Browserbase session %s — missing credentials", session_id
)
return False
try:
response = requests.post(
f"{config['base_url']}/v1/sessions/{session_id}",
headers={
"X-BB-API-Key": config["api_key"],
"Content-Type": "application/json",
},
json={
"projectId": config["project_id"],
"status": "REQUEST_RELEASE",
},
timeout=10,
)
if response.status_code in {200, 201, 204}:
logger.debug("Successfully closed Browserbase session %s", session_id)
return True
else:
logger.warning(
"Failed to close session %s: HTTP %s - %s",
session_id,
response.status_code,
response.text[:200],
)
return False
except Exception as e:
logger.error("Exception closing Browserbase session %s: %s", session_id, e)
return False
def emergency_cleanup(self, session_id: str) -> None:
config = self._get_config_or_none()
if config is None:
logger.warning(
"Cannot emergency-cleanup Browserbase session %s — missing credentials",
session_id,
)
return
try:
requests.post(
f"{config['base_url']}/v1/sessions/{session_id}",
headers={
"X-BB-API-Key": config["api_key"],
"Content-Type": "application/json",
},
json={
"projectId": config["project_id"],
"status": "REQUEST_RELEASE",
},
timeout=5,
)
except Exception as e:
logger.debug(
"Emergency cleanup failed for Browserbase session %s: %s", session_id, e
)
def get_setup_schema(self) -> Dict[str, Any]:
return {
"name": "Browserbase",
"badge": "paid",
"tag": "Cloud browser with stealth and proxies",
"env_vars": [
{
"key": "BROWSERBASE_API_KEY",
"prompt": "Browserbase API key",
"url": "https://browserbase.com",
},
{
"key": "BROWSERBASE_PROJECT_ID",
"prompt": "Browserbase project ID",
},
],
# Cloud-scoped hook: installs the agent-browser CLI only (no
# local Chromium — Browserbase hosts the browser).
"post_setup": "browserbase",
}

View File

@@ -1,10 +1,4 @@
"""Firecrawl cloud browser plugin — bundled, auto-loaded.
Distinct from ``plugins/web/firecrawl/`` (the web search/extract/crawl
plugin); both share the FIRECRAWL_API_KEY but speak to different endpoints
(``/v2/browser`` here vs ``/v2/search`` / ``/v2/scrape`` / ``/v2/crawl``
over there).
"""
"""Firecrawl cloud browser plugin — bundled, auto-loaded (distinct from plugins/web/firecrawl/)."""
from __future__ import annotations
@@ -12,5 +6,4 @@ from plugins.browser.firecrawl.provider import FirecrawlBrowserProvider
def register(ctx) -> None:
"""Register the Firecrawl cloud-browser provider with the plugin context."""
ctx.register_browser_provider(FirecrawlBrowserProvider())

View File

@@ -1,82 +1,61 @@
"""Firecrawl cloud browser provider — plugin form.
"""Firecrawl cloud browser provider (plugin form).
Subclasses :class:`agent.browser_provider.BrowserProvider` (the plugin-facing
ABC introduced in PR #25214). The legacy in-tree module
``tools.browser_providers.firecrawl`` was removed in the same PR; this file
is now the canonical implementation.
Cloud-browser path only (``/v2/browser``) — distinct from the firecrawl WEB
plugin at ``plugins/web/firecrawl/`` (search/extract/crawl on ``/v2/*``); both
share ``FIRECRAWL_API_KEY``.
This is the cloud-browser path — distinct from the firecrawl WEB plugin at
``plugins/web/firecrawl/`` which handles search/extract/crawl on
``/v2/search`` / ``/v2/scrape`` / ``/v2/crawl``. The two plugins share the
``FIRECRAWL_API_KEY`` env var but talk to different endpoints (this one
hits ``/v2/browser``).
Config keys this provider responds to::
browser:
cloud_provider: "firecrawl" # explicit selection only — not in the
# legacy auto-detect walk
Auth env vars::
FIRECRAWL_API_KEY=... # https://firecrawl.dev
FIRECRAWL_API_URL=... # optional override (default https://api.firecrawl.dev)
FIRECRAWL_BROWSER_TTL=... # optional, default 300 seconds
Config: ``browser.cloud_provider: "firecrawl"`` (explicit selection only — not
in the legacy auto-detect walk). Env: ``FIRECRAWL_API_KEY``
(https://firecrawl.dev), ``FIRECRAWL_API_URL`` (default
https://api.firecrawl.dev), ``FIRECRAWL_BROWSER_TTL`` (default 300 seconds).
"""
from __future__ import annotations
import logging
import os
import uuid
from typing import Any, Dict
from typing import Any, Dict, Optional
import requests
from agent.browser_provider import BrowserProvider
from agent.secret_scope import get_secret
from plugins.browser._common import CloudBrowserProvider
logger = logging.getLogger(__name__)
_BASE_URL = "https://api.firecrawl.dev"
class FirecrawlBrowserProvider(BrowserProvider):
"""Firecrawl (https://firecrawl.dev) cloud browser backend.
class FirecrawlBrowserProvider(CloudBrowserProvider):
"""Firecrawl (https://firecrawl.dev) cloud browser backend."""
Cloud-browser path only — search/extract/crawl live in the separate
``plugins/web/firecrawl/`` plugin.
"""
@property
def name(self) -> str:
return "firecrawl"
@property
def display_name(self) -> str:
return "Firecrawl"
def is_available(self) -> bool:
return bool(get_secret("FIRECRAWL_API_KEY"))
# ------------------------------------------------------------------
# Session lifecycle
# ------------------------------------------------------------------
provider_id = "firecrawl"
label = "Firecrawl"
release_method = "delete"
release_path = "/v2/browser/{session_id}"
create_label_suffix = " browser"
setup_tag = "Cloud browser with remote execution"
setup_env_vars = [
{"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
]
def _api_url(self) -> str:
return os.environ.get("FIRECRAWL_API_URL", _BASE_URL)
def _headers(self) -> Dict[str, str]:
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
return {"base_url": self._api_url()} if get_secret("FIRECRAWL_API_KEY") else None
def _get_config(self) -> Dict[str, Any]:
# Never raises: a missing key surfaces from _headers() inside the request
# try-block, so close_session logs it as an exception (legacy behaviour).
return {"base_url": self._api_url()}
def _headers(self, config: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
api_key = get_secret("FIRECRAWL_API_KEY")
if not api_key:
raise ValueError(
"FIRECRAWL_API_KEY environment variable is required. "
"Get your key at https://firecrawl.dev"
)
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
return {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
def create_session(self, task_id: str) -> Dict[str, object]:
try:
@@ -84,29 +63,11 @@ class FirecrawlBrowserProvider(BrowserProvider):
except (ValueError, TypeError):
ttl = 300
body: Dict[str, object] = {"ttl": ttl}
try:
response = requests.post(
f"{self._api_url()}/v2/browser",
headers=self._headers(),
json=body,
timeout=30,
)
except requests.RequestException as exc:
raise RuntimeError(
f"Firecrawl API connection failed: {exc}"
) from exc
if not response.ok:
raise RuntimeError(
f"Failed to create Firecrawl browser session: "
f"{response.status_code} {response.text}"
)
response = self._post_create(f"{self._api_url()}/v2/browser", self._headers(), {"ttl": ttl})
self._check_created(response)
data = response.json()
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
session_name = self._session_name(task_id)
logger.info("Created Firecrawl browser session %s", session_name)
return {
@@ -115,60 +76,3 @@ class FirecrawlBrowserProvider(BrowserProvider):
"cdp_url": data["cdpUrl"],
"features": {"firecrawl": True},
}
def close_session(self, session_id: str) -> bool:
try:
response = requests.delete(
f"{self._api_url()}/v2/browser/{session_id}",
headers=self._headers(),
timeout=10,
)
if response.status_code in {200, 201, 204}:
logger.debug("Successfully closed Firecrawl session %s", session_id)
return True
else:
logger.warning(
"Failed to close Firecrawl session %s: HTTP %s - %s",
session_id,
response.status_code,
response.text[:200],
)
return False
except Exception as e:
logger.error("Exception closing Firecrawl session %s: %s", session_id, e)
return False
def emergency_cleanup(self, session_id: str) -> None:
if not self.is_available():
logger.warning(
"Cannot emergency-cleanup Firecrawl session %s — missing credentials",
session_id,
)
return
try:
requests.delete(
f"{self._api_url()}/v2/browser/{session_id}",
headers=self._headers(),
timeout=5,
)
except Exception as e:
logger.debug(
"Emergency cleanup failed for Firecrawl session %s: %s", session_id, e
)
def get_setup_schema(self) -> Dict[str, Any]:
return {
"name": "Firecrawl",
"badge": "paid",
"tag": "Cloud browser with remote execution",
"env_vars": [
{
"key": "FIRECRAWL_API_KEY",
"prompt": "Firecrawl API key",
"url": "https://firecrawl.dev",
},
],
# Cloud-scoped hook: installs the agent-browser CLI only (no
# local Chromium — Firecrawl hosts the browser).
"post_setup": "browserbase",
}

View File

@@ -154,6 +154,9 @@ def _install_fake_tools_package():
sys.modules[f"plugins.browser.{_name}.provider"] = types.SimpleNamespace(
**{_classname: _provider_stub_cls},
)
# The fake ``plugins.browser`` package has an empty __path__, so the shared
# base the real vendor modules import must be loaded by path as well.
_load_plugin_module("plugins.browser._common", "browser/_common.py")
sys.modules["tools.managed_tool_gateway"] = _load_tool_module(
"tools.managed_tool_gateway",