File size: 6,301 Bytes
674d56a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
"""Browserbase cloud browser provider: direct ``BROWSERBASE_API_KEY`` + ``BROWSERBASE_PROJECT_ID``
only (the Nous subscription routes through Browser Use). Config ``browser.cloud_provider:
"browserbase"``; knobs ``BROWSERBASE_BASE_URL``, ``BROWSERBASE_PROXIES`` (true),
``BROWSERBASE_ADVANCED_STEALTH`` (false), ``BROWSERBASE_KEEP_ALIVE`` (true),
``BROWSERBASE_SESSION_TIMEOUT`` (seconds, max 21600)."""

from __future__ import annotations

import logging
import os
from typing import Any, Dict, Optional

from agent.secret_scope import get_secret
from plugins.browser._common import CloudBrowserProvider

logger = logging.getLogger(__name__)

# 402 fallbacks, in retry order: (session_config key, warning logged when dropped).
_PAID_FEATURE_FALLBACKS = (
    ("keepAlive", "keepAlive may require paid plan (402), retrying without it. "
                  "Sessions may timeout during long operations."),
    ("proxies", "Proxies unavailable (402), retrying without proxies. "
                "Bot detection may be less effective."))


class BrowserbaseBrowserProvider(CloudBrowserProvider):
    """Browserbase (https://browserbase.com) cloud browser backend."""

    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")
        project_id = get_secret("BROWSERBASE_PROJECT_ID")
        if not (api_key and project_id):
            return None
        return {
            "api_key": api_key,
            "project_id": project_id,
            # Per-profile like the key: the scoped key must not be sent to the default profile's endpoint.
            "base_url": (get_secret("BROWSERBASE_BASE_URL", "") or "https://api.browserbase.com").rstrip("/"),
        }

    def _headers(self, config: Dict[str, Any]) -> Dict[str, str]:
        return {"Content-Type": "application/json", "X-BB-API-Key": config["api_key"]}

    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()
        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"
        custom_timeout_ms = os.environ.get("BROWSERBASE_SESSION_TIMEOUT")

        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)
        if enable_proxies:
            session_config["proxies"] = True
        if enable_advanced_stealth:
            session_config["browserSettings"] = {"advancedStealth": True}

        url = f"{config['base_url']}/v1/sessions"
        headers = self._headers(config)
        response = self._post_create(url, headers, session_config)

        # 402 — paid features unavailable: drop keepAlive, then proxies, and retry.
        dropped = set()
        for key, warning in _PAID_FEATURE_FALLBACKS:
            if response.status_code == 402 and key in session_config:
                dropped.add(key)
                logger.warning(warning)
                session_config.pop(key)
                response = self._post_create(url, headers, session_config)
        self._check_created(response)

        session_data = response.json()
        session_name = self._session_name(task_id)
        features_enabled = {
            "basic_stealth": True,
            "proxies": enable_proxies and "proxies" not in dropped,
            "advanced_stealth": enable_advanced_stealth,
            "keep_alive": enable_keep_alive and "keepAlive" not in dropped,
            "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)
        return {
            "session_name": session_name,
            "bb_session_id": session_data["id"],
            "cdp_url": session_data["connectUrl"],
            "features": features_enabled,
        }


# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import requests  # noqa: F401,E402
import uuid  # noqa: F401,E402


_PLUGIN_COMPAT_LAZY = {
    'BrowserProvider': ('agent.browser_provider', 'BrowserProvider'),
}


def __getattr__(name):  # PEP 562 — lazy so no import cycles
    target = _PLUGIN_COMPAT_LAZY.get(name)
    if target is None:
        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
    import importlib
    from hermes_cli.plugin_compat import warn_once
    warn_once(__name__, name, *target)
    return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----