NousResearch/hermes-agent

Refactor cloud browser providers as plugin architecture

Cluster 25214. Data as of

Nothing open on GitHub. Fix merged in #27403 on 17 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #27403. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #27403. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items concern migrating cloud browser providers to follow an image_gen-style plugin pattern. The work involves restructuring browser provider integrations similarly to how image generation providers are organized.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #27403 refactor(browser): all cloud browser providers as image_gen-style plugins (#25214, salvages #25580)

    merged to main

    files: agent/browser_provider.py, agent/browser_registry.py, hermes_cli/plugins.py, hermes_cli/tools_config.py, plugins/browser/browser_use/__init__.py, plugins/browser/browser_use/plugin.yaml, plugins/browser/browser_use/provider.py, plugins/browser/browserbase/__init__.py, plugins/browser/browserbase/plugin.yaml, plugins/browser/browserbase/provider.py, plugins/browser/firecrawl/__init__.py, plugins/browser/firecrawl/plugin.yaml, plugins/browser/firecrawl/provider.py, tests/plugins/browser/__init__.py, tests/plugins/browser/check_parity_vs_main.py, tests/plugins/browser/test_browser_provider_plugins.py, tests/tools/test_managed_browserbase_and_modal.py, tools/browser_providers/__init__.py, tools/browser_providers/base.py, tools/browser_tool.py

    Show the diff
    • agent/browser_provider.py
      @@ -0,0 +1,175 @@
      +"""
      +Browser Provider ABC
      +====================
      +
      +Defines the pluggable-backend interface for cloud browser providers
      +(Browserbase, Browser Use, Firecrawl, …). Providers register instances via
      +:meth:`PluginContext.register_browser_provider`; the active one (selected via
      +``browser.cloud_provider`` in ``config.yaml``) services every cloud-mode
      +``browser_*`` tool call.
      +
      +Providers live in ``<repo>/plugins/browser/<name>/`` (built-in, auto-loaded as
      +``kind: backend``) or ``~/.hermes/plugins/browser/<name>/`` (user, opt-in via
      +``plugins.enabled``).
      +
      +This ABC mirrors :class:`agent.web_search_provider.WebSearchProvider` (PR
      +#25182) — same shape, same registration flow, same picker integration. The
      +legacy in-tree ``tools.browser_providers.base.CloudBrowserProvider`` ABC was
      +deleted in PR #25214 (this work) along with the per-vendor inline modules in
      +``tools/browser_providers/``; the lifecycle contract documented below is
      +preserved bit-for-bit so the tool wrapper (:mod:`tools.browser_tool`) does
      +not have to translate.
      +
      +Session metadata contract (preserved from the legacy ``CloudBrowserProvider``)::
      +
      +    {
      +        "session_name": str,        # unique name for agent-browser --session
      +        "bb_session_id": str,       # provider session ID (for close/cleanup)
      +        "cdp_url": str,             # CDP websocket URL
      +        "features": dict,           # feature flags that were enabled
      +        "external_call_id": str,    # optional, managed-gateway billing key
      +    }
      +
      +``bb_session_id`` is a legacy key name kept verbatim for backward compat with
      +:mod:`tools.browser_tool` — it holds the provider's session ID regardless of
      +which provider is in use.
      +"""
      +
      +from __future__ import annotations
      +
      +import abc
      +from typing import Any, Dict
      +
      +
      +# ---------------------------------------------------------------------------
      +# ABC
      +# ---------------------------------------------------------------------------
      +
      +
      +class BrowserProvider(abc.ABC):
      +    """Abstract base class for a cloud browser backend.
      +
      +    Subclasses must implement :meth:`name`, :meth:`is_available`, and the
      +    three lifecycle methods: :meth:`create_session`, :meth:`close_session`,
      +    :meth:`emergency_cleanup`.
      +
      +    The lifecycle shape preserves the legacy ``CloudBrowserProvider`` contract
      +    bit-for-bit so the dispatcher in :mod:`tools.browser_tool` is a pure
      +    registry lookup — no per-provider conditionals, no shape translation.
      +    """
      +
      +    @property
      +    @abc.abstractmethod
      +    def name(self) -> str:
      +        """Stable short identifier used in the ``browser.cloud_provider``
      +        config key.
      +
      +        Lowercase, hyphens permitted to preserve existing user-visible names.
      +        Examples: ``browserbase``, ``browser-use``, ``firecrawl``.
      +        """
      +
      +    @property
      +    def display_name(self) -> str:
      +        """Human-readable label shown in ``hermes tools``. Defaults to ``name``."""
      +        return self.name
      +
      +    @abc.abstractmethod
      +    def is_available(self) -> bool:
      +        """Return True when this provider can service calls.
      +
      +        Typically a cheap check (env var present, managed-gateway token
      +        readable, optional Python dep importable). Must NOT make network
      +        calls — this runs at tool-registration time and on every
      +        ``hermes tools`` paint.
      +
      +        Mirrors the legacy ``CloudBrowserProvider.is_configured()`` method;
      +        renamed for parity with :class:`agent.web_search_provider.WebSearchProvider`.
      +        """
      +
      +    @abc.abstractmethod
      +    def create_session(self, task_id: str) -> Dict[str, object]:
      +        """Create a cloud browser session and return session metadata.
      +
      +        Must return a dict with at least::
      +
      +            {
      +                "session_name": str,    # unique name for agent-browser --session
      +                "bb_session_id": str,   # provider session ID (for close/cleanup)
      +                "cdp_url": str,         # CDP websocket URL
      +                "features": dict,       # feature flags that were enabled
      +            }
      +
      +        ``bb_session_id`` is a legacy key name kept for backward compat with
      +        the rest of :mod:`tools.browser_tool` — it holds the provider's
      +        session ID regardless of which provider is in use.
      +
      +        May raise ``ValueError`` (missing credentials) or ``RuntimeError``
      +        (network / API failure); the dispatcher surfaces these to the user.
      +        """
      +
      +    @abc.abstractmethod
      +    def close_session(self, session_id: str) -> bool:
      +        """Release / terminate a cloud session by its provider session ID.
      +
      +        Returns True on success, False on failure. Should not raise — log and
      +        return False on any exception so the dispatcher's cleanup loop keeps
      +        moving across sessions.
      +        """
      +
      +    @abc.abstractmethod
      +    def emergency_cleanup(self, session_id: str) -> None:
      +        """Best-effort session teardown during process exit.
      +
      +        Called from atexit / signal handlers. Must tolerate missing
      +        credentials, network errors, etc. — log and move on. Must not raise.
      +        """
      +
      +    def get_setup_schema(self) -> Dict[str, Any]:
      +        """Return provider metadata for the ``hermes tools`` picker.
      +
      +        Used by :mod:`hermes_cli.tools_config` to inject this provider as a
      +        row in the Browser Automation picker. Shape mirrors the existing
      +        hardcoded entries in ``TOOL_CATEGORIES["browser"]``::
      +
      +            {
      +                "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"},
      +                ],
      +                "post_setup": "agent_browser",
      +            }
      +
      +        Default: minimal entry derived from :attr:`display_name`. Override to
      +        expose API key prompts, badges, managed-Nous gating, and the
      +        ``post_setup`` install hook.
      +        """
      +        return {
      +            "name": self.display_name,
      +            "badge": "",
      +            "tag": "",
      +            "env_vars": [],
      +        }
      +
      +    # ------------------------------------------------------------------
      +    # Backward-compat shims for the legacy CloudBrowserProvider API
      +    # ------------------------------------------------------------------
      +    #
      +    # The pre-PR-#25214 ABC exposed ``is_configured()`` and ``provider_name()``;
      +    # ``tools.browser_tool`` has ~6 callers that still use those names. Rather
      +    # than churn every callsite (and break out-of-tree downstream code that
      +    # subclassed CloudBrowserProvider), we expose the old names as thin
      +    # delegations to the new API. Subclasses MUST implement :meth:`is_available`
      +    # and :attr:`name`; they may override ``is_configured`` / ``provider_name``
      +    # for compatibility with the legacy ABC but it is not required.
      +
      +    def is_configured(self) -> bool:
      +        """Backward-compat alias for :meth:`is_available`."""
      +        return self.is_available()
      +
      +    def provider_name(self) -> str:
      +        """Backward-compat alias returning :attr:`display_name`."""
      +        return self.display_name
    • agent/browser_registry.py
      @@ -0,0 +1,223 @@
      +"""
      +Browser Provider Registry
      +=========================
      +
      +Central map of registered cloud browser providers. Populated by plugins at
      +import-time via :meth:`PluginContext.register_browser_provider`; consumed by
      +:func:`tools.browser_tool._get_cloud_provider` to route each cloud-mode
      +``browser_*`` tool call to the active backend.
      +
      +Active selection
      +----------------
      +The active provider is chosen by configuration with this precedence:
      +
      +1. ``browser.cloud_provider`` in ``config.yaml`` (explicit override).
      +2. Legacy preference order — ``browser-use`` → ``browserbase`` — filtered by
      +   availability. Matches the historic auto-detect order in
      +   :func:`tools.browser_tool._get_cloud_provider` (Browser Use checked first
      +   because it covers both the managed Nous gateway and direct API key path;
      +   Browserbase as the older direct-credentials fallback). ``firecrawl`` is
      +   intentionally NOT in the legacy walk — users only get Firecrawl as a
      +   cloud browser when they explicitly set ``browser.cloud_provider:
      +   firecrawl``, matching pre-migration behaviour where Firecrawl was never
      +   auto-selected.
      +3. Otherwise ``None`` — the dispatcher falls back to local browser mode.
      +
      +The explicit-config branch (rule 1) intentionally ignores ``is_available()``
      +so the dispatcher surfaces a typed "X_API_KEY is not set" error to the user
      +instead of silently switching backends. Matches the legacy
      +:func:`tools.browser_tool._get_cloud_provider` behaviour for configured names.
      +
      +Note: there is no "capability" split here (unlike the web subsystem, which
      +has search/extract/crawl). Every browser provider implements the full
      +:class:`agent.browser_provider.BrowserProvider` lifecycle; the registry's
      +job is purely selection, not capability routing.
      +"""
      +
      +from __future__ import annotations
      +
      +import logging
      +import threading
      +from typing import Dict, List, Optional
      +
      +from agent.browser_provider import BrowserProvider
      +
      +logger = logging.getLogger(__name__)
      +
      +
      +_providers: Dict[str, BrowserProvider] = {}
      +_lock = threading.Lock()
      +
      +
      +def register_provider(provider: BrowserProvider) -> None:
      +    """Register a cloud browser provider.
      +
      +    Re-registration (same ``name``) overwrites the previous entry and logs
      +    a debug message — makes hot-reload scenarios (tests, dev loops) behave
      +    predictably.
      +    """
      +    if not isinstance(provider, BrowserProvider):
      +        raise TypeError(
      +            f"register_provider() expects a BrowserProvider instance, "
      +            f"got {type(provider).__name__}"
      +        )
      +    name = provider.name
      +    if not isinstance(name, str) or not name.strip():
      +        raise ValueError("Browser provider .name must be a non-empty string")
      +    with _lock:
      +        existing = _providers.get(name)
      +        _providers[name] = provider
      +    if existing is not None:
      +        logger.debug(
      +            "Browser provider '%s' re-registered (was %r)",
      +            name, type(existing).__name__,
      +        )
      +    else:
      +        logger.debug(
      +            "Registered browser provider '%s' (%s)",
      +            name, type(provider).__name__,
      +        )
      +
      +
      +def list_providers() -> List[BrowserProvider]:
      +    """Return all registered providers, sorted by name."""
      +    with _lock:
      +        items = list(_providers.values())
      +    return sorted(items, key=lambda p: p.name)
      +
      +
      +def get_provider(name: str) -> Optional[BrowserProvider]:
      +    """Return the provider registered under *name*, or None."""
      +    if not isinstance(name, str):
      +        return None
      +    with _lock:
      +        return _providers.get(name.strip())
      +
      +
      +# ---------------------------------------------------------------------------
      +# Active-provider resolution
      +# ---------------------------------------------------------------------------
      +
      +
      +# Legacy auto-detect order — used when no ``browser.cloud_provider`` is set.
      +# Matches the pre-migration walk in :func:`tools.browser_tool._get_cloud_provider`.
      +# Firecrawl is intentionally absent so users with ``FIRECRAWL_API_KEY`` set
      +# for web-extract don't get silently routed to a paid cloud browser. See
      +# :func:`_resolve` for the full rationale.
      +_LEGACY_PREFERENCE = (
      +    "browser-use",
      +    "browserbase",
      +)
      +
      +
      +def _resolve(configured: Optional[str]) -> Optional[BrowserProvider]:
      +    """Resolve the active browser provider.
      +
      +    Resolution rules (in order):
      +
      +    1. **Explicit "local".** Returns None — the dispatcher disables cloud
      +       mode entirely. Mirrors legacy short-circuit in
      +       :func:`tools.browser_tool._get_cloud_provider`.
      +    2. **Explicit config wins, ignoring availability.** If ``configured``
      +       names a registered provider, return it even if its
      +       :meth:`is_available` returns False — the dispatcher will surface a
      +       precise "X_API_KEY is not set" error instead of silently routing
      +       somewhere else.
      +    3. **Legacy preference walk, filtered by availability.** Walk
      +       :data:`_LEGACY_PREFERENCE` (``browser-use`` → ``browserbase``) looking
      +       for a provider whose ``is_available()`` is True.
      +
      +    There is intentionally NO "single-eligible shortcut" rule here (unlike
      +    :func:`agent.web_search_registry._resolve`). Pre-migration, the
      +    auto-detect branch in ``tools.browser_tool._get_cloud_provider`` only
      +    considered Browser Use and Browserbase; Firecrawl was reachable only
      +    via an explicit ``browser.cloud_provider: firecrawl`` config key.
      +    Preserving that gate matters because Firecrawl shares its API key with
      +    the *web* extract plugin (``plugins/web/firecrawl/``), so users who set
      +    ``FIRECRAWL_API_KEY`` for web extract must NOT get silently routed to a
      +    paid cloud browser on a fresh install. Third-party browser-provider
      +    plugins added under ``~/.hermes/plugins/browser/<vendor>/`` are subject
      +    to the same gate — they must be explicitly configured to take effect.
      +
      +    Returns None when no provider is configured AND no available provider
      +    matches the legacy preference; the dispatcher then falls back to local
      +    browser mode.
      +    """
      +    with _lock:
      +        snapshot = dict(_providers)
      +
      +    def _is_available_safe(p: BrowserProvider) -> bool:
      +        """Wrap ``is_available()`` so a buggy provider doesn't kill resolution."""
      +        try:
      +            return bool(p.is_available())
      +        except Exception as exc:  # noqa: BLE001
      +            logger.warning(
      +                "Browser provider %s.is_available() raised %s — treating as unavailable",
      +                p.name, exc, exc_info=True,
      +            )
      +            return False
      +
      +    # 1. Explicit "local" short-circuit.
      +    if configured == "local":
      +        return None
      +
      +    # 2. Explicit config wins — return regardless of is_available() so the
      +    #    user gets a precise downstream error message rather than a silent
      +    #    backend switch. Matches _get_cloud_provider() in browser_tool.py.
      +    if configured:
      +        provider = snapshot.get(configured)
      +        if provider is not None:
      +            return provider
      +        logger.debug(
      +            "browser cloud_provider '%s' configured but not registered; "
      +            "falling back to auto-detect",
      +            configured,
      +        )
      +
      +    # 3. Legacy preference walk — only providers in _LEGACY_PREFERENCE are
      +    #    auto-eligible. Filtered by availability so we don't surface a
      +    #    provider the user has no credentials for. See docstring for why
      +    #    we do NOT fall back to "any single-eligible registered provider".
      +    for legacy in _LEGACY_PREFERENCE:
      +        provider = snapshot.get(legacy)
      +        if provider is not None and _is_available_safe(provider):
      +            return provider
      +
      +    return None
      +
      +
      +def get_active_browser_provider() -> Optional[BrowserProvider]:
      +    """Resolve the currently-active cloud browser provider.
      +
      +    Reads ``browser.cloud_provider`` from config.yaml; falls back per the
      +    module docstring. Returns None for local mode or when no provider is
      +    available.
      +    """
      +    try:
      +        from hermes_cli.config import read_raw_config
      +
      +        cfg = read_raw_config()
      +        browser_cfg = cfg.get("browser", {})
      +    except Exception as exc:
      +        logger.debug("Could not read browser config: %s", exc)
      +        browser_cfg = {}
      +
      +    configured: Optional[str] = None
      +    if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg:
      +        try:
      +            from tools.tool_backend_helpers import normalize_browser_cloud_provider
      +
      +            configured = normalize_browser_cloud_provider(
      +                browser_cfg.get("cloud_provider")
      +            )
      +        except Exception as exc:
      +            logger.debug("normalize_browser_cloud_provider failed: %s", exc)
      +            configured = None
      +
      +    return _resolve(configured)
      +
      +
      +def _reset_for_tests() -> None:
      +    """Clear the registry. **Test-only.**"""
      +    with _lock:
      +        _providers.clear()
    • hermes_cli/plugins.py
      @@ -608,6 +608,38 @@ def register_web_search_provider(self, provider) -> None:
                   self.manifest.name, provider.name,
               )
       
      +    # -- browser provider registration ---------------------------------------
      +
      +    def register_browser_provider(self, provider) -> None:
      +        """Register a cloud browser backend.
      +
      +        ``provider`` must be an instance of
      +        :class:`agent.browser_provider.BrowserProvider`. The
      +        ``provider.name`` attribute is what ``browser.cloud_provider`` in
      +        ``config.yaml`` matches against when routing cloud-mode
      +        ``browser_*`` tool calls.
      +
      +        Mirrors :meth:`register_web_search_provider` exactly — same
      +        registration shape, same gating, same logging. The browser
      +        subsystem's dispatcher (:func:`tools.browser_tool._get_cloud_provider`)
      +        consults the registry built up by these calls.
      +        """
      +        from agent.browser_provider import BrowserProvider
      +        from agent.browser_registry import register_provider as _register_browser_provider
      +
      +        if not isinstance(provider, BrowserProvider):
      +            logger.warning(
      +                "Plugin '%s' tried to register a browser provider that does "
      +                "not inherit from BrowserProvider. Ignoring.",
      +                self.manifest.name,
      +            )
      +            return
      +        _register_browser_provider(provider)
      +        logger.info(
      +            "Plugin '%s' registered browser provider: %s",
      +            self.manifest.name, provider.name,
      +        )
      +
           # -- platform adapter registration ---------------------------------------
       
           def register_platform(
    • hermes_cli/tools_config.py
      @@ -378,6 +378,17 @@ def _get_plugin_toolset_keys() -> set:
           "browser": {
               "name": "Browser Automation",
               "icon": "🌐",
      +        # Per-provider rows for Browserbase, Browser Use, and Firecrawl are
      +        # injected at runtime from plugins.browser.<vendor>.provider via
      +        # _plugin_browser_providers() in _visible_providers(). Only
      +        # non-provider UX setup-flow rows remain here:
      +        #   - "Nous Subscription (Browser Use cloud)" — managed Browser Use
      +        #     billed via Nous subscription (requires_nous_auth +
      +        #     override_env_vars). Uses the browser-use plugin as the
      +        #     underlying backend but has a distinct setup UX.
      +        #   - "Local Browser" — non-cloud option, no CloudBrowserProvider.
      +        #   - "Camofox" — anti-detection local Firefox; short-circuits the
      +        #     cloud-provider dispatch path via _is_camofox_mode().
               "providers": [
                   {
                       "name": "Nous Subscription (Browser Use cloud)",
      @@ -398,37 +409,6 @@ def _get_plugin_toolset_keys() -> set:
                       "browser_provider": "local",
                       "post_setup": "agent_browser",
                   },
      -            {
      -                "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"},
      -                ],
      -                "browser_provider": "browserbase",
      -                "post_setup": "agent_browser",
      -            },
      -            {
      -                "name": "Browser Use",
      -                "badge": "paid",
      -                "tag": "Cloud browser with remote execution",
      -                "env_vars": [
      -                    {"key": "BROWSER_USE_API_KEY", "prompt": "Browser Use API key", "url": "https://browser-use.com"},
      -                ],
      -                "browser_provider": "browser-use",
      -                "post_setup": "agent_browser",
      -            },
      -            {
      -                "name": "Firecrawl",
      -                "badge": "paid",
      -                "tag": "Cloud browser with remote execution",
      -                "env_vars": [
      -                    {"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
      -                ],
      -                "browser_provider": "firecrawl",
      -                "post_setup": "agent_browser",
      -            },
                   {
                       "name": "Camofox",
                       "badge": "free · local",
      @@ -1662,6 +1642,61 @@ def _plugin_web_search_providers() -> list[dict]:
           return rows
       
       
      +# Mirror of _plugin_web_search_providers for cloud browser backends. After
      +# PR #25214, Browserbase / Browser Use / Firecrawl live as plugins under
      +# plugins/browser/<vendor>/; this helper is the sole source of provider rows
      +# for those three in the "Browser Automation" picker. The hardcoded
      +# ``TOOL_CATEGORIES["browser"]`` entries that drove the category before
      +# were deleted in the same PR; only non-provider UX setup-flow rows remain
      +# ("Nous Subscription", "Local Browser", "Camofox") — see the comment block
      +# in ``TOOL_CATEGORIES["browser"]`` for why each one stays hardcoded.
      +def _plugin_browser_providers() -> list[dict]:
      +    """Build picker-row dicts from plugin-registered cloud browser providers.
      +
      +    Each returned dict mirrors the legacy ``TOOL_CATEGORIES["browser"]``
      +    schema (``name`` / ``badge`` / ``tag`` / ``env_vars`` /
      +    ``browser_provider`` / ``post_setup``) so the picker behaves identically
      +    whether a provider was hardcoded or plugin-registered.
      +
      +    Populates ``browser_provider`` (the legacy config key written to
      +    ``browser.cloud_provider``) and a ``browser_plugin_name`` marker so
      +    setup / write paths can route through the registry when they want to.
      +    """
      +    try:
      +        from agent.browser_registry import list_providers as _list_browser_providers
      +        from hermes_cli.plugins import _ensure_plugins_discovered
      +
      +        _ensure_plugins_discovered()
      +        providers = _list_browser_providers()
      +    except Exception:
      +        return []
      +
      +    rows: list[dict] = []
      +    for provider in providers:
      +        name = getattr(provider, "name", None)
      +        if not name:
      +            continue
      +        try:
      +            schema = provider.get_setup_schema()
      +        except Exception:
      +            continue
      +        if not isinstance(schema, dict):
      +            continue
      +        row = {
      +            "name": schema.get("name", provider.display_name),
      +            "badge": schema.get("badge", ""),
      +            "tag": schema.get("tag", ""),
      +            "env_vars": schema.get("env_vars", []),
      +            "browser_provider": name,
      +            "browser_plugin_name": name,
      +        }
      +        # Pass-through optional fields the schema can opt into.
      +        if schema.get("post_setup"):
      +            row["post_setup"] = schema["post_setup"]
      +        rows.append(row)
      +    return rows
      +
      +
       def _visible_providers(cat: dict, config: dict) -> list[dict]:
           """Return provider entries visible for the current auth/config state."""
           features = get_nous_subscription_features(config)
      @@ -1691,6 +1726,14 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]:
           if cat.get("name") == "Web Search & Extract":
               visible.extend(_plugin_web_search_providers())
       
      +    # Inject plugin-registered cloud browser backends. After PR #25214,
      +    # Browserbase / Browser Use / Firecrawl are the plugin-supplied rows;
      +    # the hardcoded "Nous Subscription" / "Local Browser" / "Camofox" rows
      +    # stay because they're non-provider UX setup flows (subscription auth,
      +    # local fallback, and the REST-API anti-detection backend respectively).
      +    if cat.get("name") == "Browser Automation":
      +        visible.extend(_plugin_browser_providers())
      +
           return visible
       
       
    • plugins/browser/browser_use/__init__.py
      @@ -0,0 +1,14 @@
      +"""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.
      +"""
      +
      +from __future__ import annotations
      +
      +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())
    • plugins/browser/browser_use/plugin.yaml
      @@ -0,0 +1,7 @@
      +name: browser-browser-use
      +version: 1.0.0
      +description: "Browser Use (https://browser-use.com) cloud browser backend. Supports both direct BROWSER_USE_API_KEY and the managed Nous tool gateway. Also powers the 'Nous Subscription' UX flow that bills usage to a Nous subscription."
      +author: NousResearch
      +kind: backend
      +provides_browser_providers:
      +  - browser-use
    • plugins/browser/browser_use/provider.py
      @@ -1,4 +1,32 @@
      -"""Browser Use cloud browser provider."""
      +"""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 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``.
      +
      +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')
      +"""
      +
      +from __future__ import annotations
       
       import logging
       import os
      @@ -8,11 +36,14 @@
       
       import requests
       
      -from tools.browser_providers.base import CloudBrowserProvider
      -from tools.managed_tool_gateway import resolve_managed_tool_gateway
      -from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway
      +from agent.browser_provider import BrowserProvider
       
       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.
       _pending_create_keys: Dict[str, str] = {}
       _pending_create_keys_lock = threading.Lock()
       
      @@ -38,6 +69,16 @@ 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.
      +    """
           if response.status_code >= 500:
               return True
       
      @@ -60,13 +101,24 @@ def _should_preserve_pending_create_key(response: requests.Response) -> bool:
           return "already in progress" in message
       
       
      -class BrowserUseProvider(CloudBrowserProvider):
      -    """Browser Use (https://browser-use.com) cloud browser backend."""
      +class BrowserUseBrowserProvider(BrowserProvider):
      +    """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.
      +    """
       
      -    def provider_name(self) -> str:
      +    @property
      +    def name(self) -> str:
      +        return "browser-use"
      +
      +    @property
      +    def display_name(self) -> str:
               return "Browser Use"
       
      -    def is_configured(self) -> bool:
      +    def is_available(self) -> bool:
               return self._get_config_or_none() is not None
       
           # ------------------------------------------------------------------
      @@ -74,6 +126,14 @@ def is_configured(self) -> bool:
           # ------------------------------------------------------------------
       
           def _get_config_or_none(self) -> 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 resolve_managed_tool_gateway
      +        from tools.tool_backend_helpers import prefers_gateway
      +
      +        # Direct API key wins unless the user has explicitly opted into the
      +        # managed Nous gateway via ``tool_gateway.browser: gateway``.
               api_key = os.environ.get("BROWSER_USE_API_KEY")
               if api_key and not prefers_gateway("browser"):
                   return {
      @@ -93,6 +153,8 @@ def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
               }
       
           def _get_config(self) -> Dict[str, Any]:
      +        from tools.tool_backend_helpers import managed_nous_tools_enabled
      +
               config = self._get_config_or_none()
               if config is None:
                   message = (
      @@ -111,11 +173,10 @@ def _get_config(self) -> Dict[str, Any]:
           # ------------------------------------------------------------------
       
           def _headers(self, config: Dict[str, Any]) -> Dict[str, str]:
      -        headers = {
      +        return {
                   "Content-Type": "application/json",
                   "X-Browser-Use-API-Key": [redacted:secret-assignment]"api_key"],
               }
      -        return headers
       
           def create_session(self, task_id: str) -> Dict[str, object]:
               config = self._get_config()
      @@ -166,7 +227,9 @@ def create_session(self, task_id: str) -> Dict[str, object]:
               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
      +        external_call_id = (
      +            response.headers.get("x-external-call-id") if managed_mode else None
      +        )
       
               logger.info("Created Browser Use session %s", session_name)
       
      @@ -184,7 +247,9 @@ 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)
      +            logger.warning(
      +                "Cannot close Browser Use session %s — missing credentials", session_id
      +            )
                   return False
       
               try:
      @@ -212,7 +277,10 @@ def close_session(self, session_id: str) -> bool:
           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)
      +            logger.warning(
      +                "Cannot emergency-cleanup Browser Use session %s — missing credentials",
      +                session_id,
      +            )
                   return
               try:
                   requests.patch(
      @@ -222,4 +290,21 @@ def emergency_cleanup(self, session_id: str) -> None:
                       timeout=5,
                   )
               except Exception as e:
      -            logger.debug("Emergency cleanup failed for Browser Use session %s: %s", session_id, e)
      +            logger.debug(
      +                "Emergency cleanup failed for Browser Use session %s: %s", session_id, e
      +            )
      +
      +    def get_setup_schema(self) -> Dict[str, Any]:
      +        return {
      +            "name": "Browser Use",
      +            "badge": "paid",
      +            "tag": "Cloud browser with remote execution",
      +            "env_vars": [
      +                {
      +                    "key": "BROWSER_USE_API_KEY",
      +                    "prompt": "Browser Use API key",
      +                    "url": "https://browser-use.com",
      +                },
      +            ],
      +            "post_setup": "agent_browser",
      +        }
    • plugins/browser/browserbase/__init__.py
      @@ -0,0 +1,15 @@
      +"""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.
      +"""
      +
      +from __future__ import annotations
      +
      +from plugins.browser.browserbase.provider import BrowserbaseBrowserProvider
      +
      +
      +def register(ctx) -> None:
      +    """Register the Browserbase provider with the plugin context."""
      +    ctx.register_browser_provider(BrowserbaseBrowserProvider())
    • plugins/browser/browserbase/plugin.yaml
      @@ -0,0 +1,7 @@
      +name: browser-browserbase
      +version: 1.0.0
      +description: "Browserbase (https://browserbase.com) cloud browser backend. Requires BROWSERBASE_API_KEY + BROWSERBASE_PROJECT_ID. Supports stealth, proxies, and keep-alive sessions; auto-falls-back when paid features are unavailable."
      +author: NousResearch
      +kind: backend
      +provides_browser_providers:
      +  - browserbase
    • plugins/browser/browserbase/provider.py
      @@ -1,4 +1,35 @@
      -"""Browserbase cloud browser provider (direct credentials only)."""
      +"""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.
      +
      +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=... (ms, integer)
      +"""
      +
      +from __future__ import annotations
       
       import logging
       import os
      @@ -7,27 +38,31 @@
       
       import requests
       
      -from tools.browser_providers.base import CloudBrowserProvider
      +from agent.browser_provider import BrowserProvider
       
       logger = logging.getLogger(__name__)
       
       
      -class BrowserbaseProvider(CloudBrowserProvider):
      +class BrowserbaseBrowserProvider(BrowserProvider):
           """Browserbase (https://browserbase.com) cloud browser backend.
       
      -    This provider 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.
      +    Direct credentials only — managed-Nous-gateway support lives on the
      +    Browser Use provider now.
           """
       
      -    def provider_name(self) -> str:
      +    @property
      +    def name(self) -> str:
      +        return "browserbase"
      +
      +    @property
      +    def display_name(self) -> str:
               return "Browserbase"
       
      -    def is_configured(self) -> bool:
      +    def is_available(self) -> bool:
               return self._get_config_or_none() is not None
       
           # ------------------------------------------------------------------
      -    # Session lifecycle
      +    # Config resolution
           # ------------------------------------------------------------------
       
           def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
      @@ -37,7 +72,9 @@ def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
                   return {
                       "api_key": [redacted:secret-assignment]
                       "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
       
      @@ -50,13 +87,21 @@ def _get_config(self) -> Dict[str, Any]:
                   )
               return config
       
      +    # ------------------------------------------------------------------
      +    # Session lifecycle
      +    # ------------------------------------------------------------------
      +
           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 = {
      @@ -78,7 +123,9 @@ def create_session(self, task_id: str) -> Dict[str, object]:
                       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
      @@ -156,7 +203,9 @@ def create_session(self, task_id: str) -> Dict[str, object]:
                   features_enabled["custom_timeout"] = True
       
               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,
      @@ -169,7 +218,9 @@ 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)
      +            logger.warning(
      +                "Cannot close Browserbase session %s — missing credentials", session_id
      +            )
                   return False
       
               try:
      @@ -203,7 +254,10 @@ def close_session(self, session_id: str) -> bool:
           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)
      +            logger.warning(
      +                "Cannot emergency-cleanup Browserbase session %s — missing credentials",
      +                session_id,
      +            )
                   return
               try:
                   requests.post(
      @@ -219,4 +273,25 @@ def emergency_cleanup(self, session_id: str) -> None:
                       timeout=5,
                   )
               except Exception as e:
      -            logger.debug("Emergency cleanup failed for Browserbase session %s: %s", session_id, 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",
      +                },
      +            ],
      +            "post_setup": "agent_browser",
      +        }
    • plugins/browser/firecrawl/__init__.py
      @@ -0,0 +1,16 @@
      +"""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).
      +"""
      +
      +from __future__ import annotations
      +
      +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())
    • plugins/browser/firecrawl/plugin.yaml
      @@ -0,0 +1,7 @@
      +name: browser-firecrawl
      +version: 1.0.0
      +description: "Firecrawl (https://firecrawl.dev) cloud browser backend. Requires FIRECRAWL_API_KEY. Distinct from the firecrawl WEB search/extract plugin — the two share an API key but operate on different endpoints."
      +author: NousResearch
      +kind: backend
      +provides_browser_providers:
      +  - firecrawl
    • plugins/browser/firecrawl/provider.py
      @@ -1,26 +1,61 @@
      -"""Firecrawl cloud browser provider."""
      +"""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.
      +
      +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
      +"""
      +
      +from __future__ import annotations
       
       import logging
       import os
       import uuid
      -from typing import Dict
      +from typing import Any, Dict
       
       import requests
       
      -from tools.browser_providers.base import CloudBrowserProvider
      +from agent.browser_provider import BrowserProvider
       
       logger = logging.getLogger(__name__)
       
       _BASE_URL = "https://api.firecrawl.dev"
       
       
      -class FirecrawlProvider(CloudBrowserProvider):
      -    """Firecrawl (https://firecrawl.dev) cloud browser backend."""
      +class FirecrawlBrowserProvider(BrowserProvider):
      +    """Firecrawl (https://firecrawl.dev) cloud browser backend.
       
      -    def provider_name(self) -> str:
      +    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_configured(self) -> bool:
      +    def is_available(self) -> bool:
               return bool(os.environ.get("FIRECRAWL_API_KEY"))
       
           # ------------------------------------------------------------------
      @@ -100,13 +135,34 @@ def close_session(self, session_id: str) -> bool:
                   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 ValueError:
      -            logger.warning("Cannot emergency-cleanup Firecrawl session %s — missing credentials", session_id)
               except Exception as e:
      -            logger.debug("Emergency cleanup failed for Firecrawl session %s: %s", session_id, 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",
      +                },
      +            ],
      +            "post_setup": "agent_browser",
      +        }
    • tests/plugins/browser/check_parity_vs_main.py
      @@ -0,0 +1,273 @@
      +"""Behavior-parity check for the browser-provider plugin migration (#25214).
      +
      +Spawns one subprocess per (version, scenario) cell — pinned to either
      +origin/main (legacy in-tree providers + class-instantiation lookup) or
      +this PR's worktree (plugin-based registry) via `sys.path[0]`. Each
      +subprocess clears all browser-related env vars + writes a config.yaml,
      +loads `tools.browser_tool._get_cloud_provider()`, and emits a reduced
      +"shape tuple" {is_local, provider_name, is_available} as JSON.
      +
      +The parent process diffs the shapes per scenario. A diff means the
      +migration introduced an observable behaviour change vs origin/main —
      +which would be a real regression for users on the existing config keys.
      +
      +Run from the PR worktree:
      +
      +    cd ~/.hermes/hermes-agent/.worktrees/browser-providers-plugin
      +    python tests/plugins/browser/check_parity_vs_main.py
      +"""
      +from __future__ import annotations
      +
      +import json
      +import subprocess
      +import sys
      +from pathlib import Path
      +
      +
      +REPO_ROOT = Path(__file__).resolve().parents[3]
      +
      +
      +# Pin one path to current main, one to the PR worktree.
      +# ``REPO_ROOT`` is ``.../.worktrees/browser-providers-plugin``; the main
      +# checkout lives two levels up at ``~/.hermes/hermes-agent``.
      +MAIN_DIR = REPO_ROOT.parent.parent  # ~/.hermes/hermes-agent
      +PR_DIR = REPO_ROOT  # the worktree we're in
      +assert (MAIN_DIR / "tools" / "browser_tool.py").exists(), (
      +    f"MAIN_DIR={MAIN_DIR} doesn't look like a hermes-agent checkout"
      +)
      +assert (PR_DIR / "tools" / "browser_tool.py").exists(), (
      +    f"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout"
      +)
      +
      +
      +# Reduced shape comparison — exact instance addresses obviously differ
      +# between subprocesses, so we compare the parts that matter for users.
      +SUBPROCESS_SCRIPT = r"""
      +import json, os, sys, tempfile
      +sys.path.insert(0, sys.argv[1])
      +
      +# Isolated HERMES_HOME for the config write.
      +home = tempfile.mkdtemp()
      +os.environ["HERMES_HOME"] = home
      +
      +# Clear every browser-related env var so is_available() is deterministic.
      +for k in (
      +    "BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "BROWSERBASE_BASE_URL",
      +    "BROWSER_USE_API_KEY", "BROWSER_USE_GATEWAY_URL",
      +    "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_BROWSER_TTL",
      +    "TOOL_GATEWAY_DOMAIN", "TOOL_GATEWAY_USER_TOKEN",
      +):
      +    os.environ.pop(k, None)
      +
      +# Apply per-scenario env (passed as JSON via argv[2]).
      +scenario_env = json.loads(sys.argv[2])
      +os.environ.update(scenario_env)
      +
      +# Apply per-scenario config (passed as YAML body via argv[3]).
      +config_yaml = sys.argv[3]
      +config_path = os.path.join(home, "config.yaml")
      +with open(config_path, "w") as f:
      +    f.write(config_yaml)
      +
      +# Fresh import — must not have any browser modules cached.
      +for name in list(sys.modules):
      +    if name.startswith("tools.") or name.startswith("agent.") or name.startswith("plugins."):
      +        sys.modules.pop(name, None)
      +
      +from tools.browser_tool import _get_cloud_provider, _is_local_mode
      +
      +provider = _get_cloud_provider()
      +
      +# Pull the human-readable backend name via the API that exists on BOTH
      +# legacy (origin/main: CloudBrowserProvider.provider_name()) and the new
      +# ABC (BrowserProvider exposes provider_name() as a backward-compat alias
      +# returning display_name). Both shapes resolve to the same string —
      +# 'Browserbase' / 'Browser Use' / 'Firecrawl' — so we can compare safely.
      +provider_name = None
      +is_available = None
      +if provider is not None:
      +    pn = getattr(provider, "provider_name", None)
      +    if callable(pn):
      +        provider_name = pn()
      +    elif isinstance(pn, str):
      +        provider_name = pn
      +    is_conf = getattr(provider, "is_configured", None)
      +    if callable(is_conf):
      +        is_available = bool(is_conf())
      +
      +shape = {
      +    "is_local": _is_local_mode(),
      +    "provider_name": provider_name,
      +    "is_available": is_available,
      +}
      +print(json.dumps(shape))
      +"""
      +
      +
      +SCENARIOS: list[tuple[str, str, dict[str, str]]] = [
      +    # (label, config.yaml body, extra env vars)
      +    ("no-config-no-env", "", {}),
      +    ("explicit-local-no-env", "browser:\n  cloud_provider: local\n", {}),
      +    (
      +        "explicit-browserbase-no-creds",
      +        "browser:\n  cloud_provider: browserbase\n",
      +        {},
      +    ),
      +    (
      +        "explicit-browserbase-with-creds",
      +        "browser:\n  cloud_provider: browserbase\n",
      +        {"BROWSERBASE_API_KEY": "x", "BROWSERBASE_PROJECT_ID": "y"},
      +    ),
      +    (
      +        "explicit-browser-use-no-creds",
      +        "browser:\n  cloud_provider: browser-use\n",
      +        {},
      +    ),
      +    (
      +        "explicit-browser-use-with-creds",
      +        "browser:\n  cloud_provider: browser-use\n",
      +        {"BROWSER_USE_API_KEY": "k"},
      +    ),
      +    (
      +        "explicit-firecrawl-no-creds",
      +        "browser:\n  cloud_provider: firecrawl\n",
      +        {},
      +    ),
      +    (
      +        "explicit-firecrawl-with-creds",
      +        "browser:\n  cloud_provider: firecrawl\n",
      +        {"FIRECRAWL_API_KEY": "k"},
      +    ),
      +    (
      +        "no-config-bu-creds",
      +        "",
      +        {"BROWSER_USE_API_KEY": "k"},
      +    ),
      +    (
      +        "no-config-bb-creds",
      +        "",
      +        {"BROWSERBASE_API_KEY": "x", "BROWSERBASE_PROJECT_ID": "y"},
      +    ),
      +    (
      +        "no-config-both-creds",
      +        "",
      +        {
      +            "BROWSER_USE_API_KEY": "k",
      +            "BROWSERBASE_API_KEY": "x",
      +            "BROWSERBASE_PROJECT_ID": "y",
      +        },
      +    ),
      +    (
      +        "no-config-firecrawl-only",
      +        "",
      +        {"FIRECRAWL_API_KEY": "k"},
      +    ),
      +    (
      +        "no-config-firecrawl-and-bb",
      +        "",
      +        {
      +            "FIRECRAWL_API_KEY": "k",
      +            "BROWSERBASE_API_KEY": "x",
      +            "BROWSERBASE_PROJECT_ID": "y",
      +        },
      +    ),
      +]
      +
      +
      +def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict) -> dict:
      +    """Run one (version, scenario) cell. Returns the shape dict."""
      +    venv_python = repo_path / ".venv" / "bin" / "python"
      +    if not venv_python.exists():
      +        # Worktrees share the main repo's venv.
      +        venv_python = MAIN_DIR / ".venv" / "bin" / "python"
      +    if not venv_python.exists():
      +        venv_python = Path("python3")
      +
      +    out = subprocess.run(
      +        [
      +            str(venv_python),
      +            "-c",
      +            SUBPROCESS_SCRIPT,
      +            str(repo_path),
      +            json.dumps(env),
      +            config_yaml,
      +        ],
      +        capture_output=True,
      +        text=True,
      +        timeout=30,
      +    )
      +    if out.returncode != 0:
      +        return {
      +            "error": "subprocess failed",
      +            "stdout": out.stdout,
      +            "stderr": out.stderr[-500:],
      +        }
      +    try:
      +        return json.loads(out.stdout.strip().splitlines()[-1])
      +    except Exception as exc:
      +        return {"error": f"could not parse output: {exc}", "stdout": out.stdout}
      +
      +
      +def _reduce_for_comparison(shape: dict) -> dict:
      +    """Reduce a shape dict to the parts that matter for user-visible parity.
      +
      +    We compare ``(is_local, provider_name, is_available)`` — the trio that
      +    decides what the dispatcher does with each tool call. ``provider_name``
      +    is the legacy ``provider_name()`` return value ('Browserbase' / 'Browser
      +    Use' / 'Firecrawl'), which is identical between legacy and plugin
      +    classes (the plugin's ``display_name`` matches the legacy
      +    ``provider_name()`` return).
      +    """
      +    return {
      +        "is_local": shape.get("is_local"),
      +        "provider_name": shape.get("provider_name"),
      +        "is_available": shape.get("is_available"),
      +    }
      +
      +
      +def main() -> int:
      +    print(f"main:    {MAIN_DIR}")
      +    print(f"pr:      {PR_DIR}")
      +    print()
      +
      +    failures: list[str] = []
      +    errors: list[str] = []
      +    for label, config_yaml, env in SCENARIOS:
      +        main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env)
      +        pr_shape = _run_scenario(PR_DIR, label, config_yaml, env)
      +
      +        if "error" in main_shape or "error" in pr_shape:
      +            print(f"  [ERR ] {label}: subprocess failed")
      +            print(f"    main: {main_shape}")
      +            print(f"    pr:   {pr_shape}")
      +            errors.append(label)
      +            continue
      +
      +        main_reduced = _reduce_for_comparison(main_shape)
      +        pr_reduced = _reduce_for_comparison(pr_shape)
      +
      +        if main_reduced == pr_reduced:
      +            print(f"  [OK]   {label}: {main_reduced}")
      +        else:
      +            print(f"  [FAIL] {label}")
      +            print(f"    main: {main_reduced}")
      +            print(f"    pr:   {pr_reduced}")
      +            failures.append(label)
      +
      +    print()
      +    if errors:
      +        print(f"SUBPROCESS ERRORS in {len(errors)} scenario(s):")
      +        for e in errors:
      +            print(f"  - {e}")
      +    if failures:
      +        print(f"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):")
      +        for f in failures:
      +            print(f"  - {f}")
      +    if failures or errors:
      +        return 1
      +    print(f"PARITY OK across {len(SCENARIOS)} scenarios.")
      +    return 0
      +
      +
      +if __name__ == "__main__":
      +    sys.exit(main())
    • tests/plugins/browser/test_browser_provider_plugins.py
      @@ -0,0 +1,379 @@
      +"""Plugin-side tests for the browser provider migration (PR #25214).
      +
      +Covers:
      +
      +- All three bundled plugins (browserbase, browser-use, firecrawl)
      +  instantiate and self-report the expected ABC defaults.
      +- Each plugin's ``is_available()`` correctly reflects env-var presence.
      +- The browser_registry resolves an active provider in the documented
      +  scenarios:
      +    * explicit config wins ignoring availability (so dispatcher surfaces
      +      a typed credentials error)
      +    * legacy preference walk: browser-use → browserbase (filtered by
      +      availability)
      +    * firecrawl is NOT in the legacy walk — explicit-only
      +    * unknown name falls through to auto-detect
      +    * ``local`` short-circuits to None
      +
      +These tests use *real* imports from the plugin modules — no mocking of
      +provider classes themselves — so the test catches drift in the ABC
      +interface, the registry, and the plugin glue layer simultaneously.
      +Mirrors ``tests/plugins/web/test_web_search_provider_plugins.py`` from
      +PR #25182.
      +"""
      +from __future__ import annotations
      +
      +import pytest
      +
      +
      +# ---------------------------------------------------------------------------
      +# Helpers
      +# ---------------------------------------------------------------------------
      +
      +
      +def _clear_browser_env(monkeypatch: pytest.MonkeyPatch) -> None:
      +    """Strip every browser-provider env var so is_available() returns False."""
      +    for k in (
      +        "BROWSERBASE_API_KEY",
      +        "BROWSERBASE_PROJECT_ID",
      +        "BROWSERBASE_BASE_URL",
      +        "BROWSER_USE_API_KEY",
      +        "BROWSER_USE_GATEWAY_URL",
      +        "FIRECRAWL_API_KEY",
      +        "FIRECRAWL_API_URL",
      +        "FIRECRAWL_BROWSER_TTL",
      +        "TOOL_GATEWAY_DOMAIN",
      +        "TOOL_GATEWAY_USER_TOKEN",
      +    ):
      +        monkeypatch.delenv(k, raising=False)
      +
      +
      +def _ensure_plugins_loaded() -> None:
      +    """Idempotently load plugins so the registry is populated."""
      +    from hermes_cli.plugins import _ensure_plugins_discovered
      +
      +    _ensure_plugins_discovered()
      +
      +
      +# ---------------------------------------------------------------------------
      +# Per-test isolation
      +# ---------------------------------------------------------------------------
      +
      +
      [redacted:email](autouse=True)
      +def _isolate_env(monkeypatch: pytest.MonkeyPatch) -> None:
      +    """Each test starts with a clean browser-provider env."""
      +    _clear_browser_env(monkeypatch)
      +
      +
      +# ---------------------------------------------------------------------------
      +# Bundled plugins register
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestBundledPluginsRegister:
      +    """All three bundled browser plugins discover and register correctly."""
      +
      +    def test_all_three_plugins_present_in_registry(self) -> None:
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import list_providers
      +
      +        names = sorted(p.name for p in list_providers())
      +        assert names == ["browser-use", "browserbase", "firecrawl"]
      +
      +    @pytest.mark.parametrize(
      +        "plugin_name,expected_display",
      +        [
      +            ("browserbase", "Browserbase"),
      +            ("browser-use", "Browser Use"),
      +            ("firecrawl", "Firecrawl"),
      +        ],
      +    )
      +    def test_each_plugin_has_name_and_display_name(
      +        self, plugin_name: str, expected_display: str
      +    ) -> None:
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import get_provider
      +
      +        provider = get_provider(plugin_name)
      +        assert provider is not None, f"plugin {plugin_name!r} not registered"
      +        assert provider.name == plugin_name
      +        assert provider.display_name == expected_display
      +
      +    @pytest.mark.parametrize(
      +        "plugin_name",
      +        ["browserbase", "browser-use", "firecrawl"],
      +    )
      +    def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None:
      +        """``get_setup_schema()`` returns a dict the picker can consume."""
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import get_provider
      +
      +        provider = get_provider(plugin_name)
      +        assert provider is not None
      +        schema = provider.get_setup_schema()
      +        assert isinstance(schema, dict)
      +        assert "name" in schema
      +        assert "env_vars" in schema
      +        # Every cloud-browser plugin needs the agent-browser post-setup hook
      +        # so the picker auto-installs the CLI on selection.
      +        assert schema.get("post_setup") == "agent_browser"
      +
      +    @pytest.mark.parametrize(
      +        "plugin_name",
      +        ["browserbase", "browser-use", "firecrawl"],
      +    )
      +    def test_each_plugin_implements_full_lifecycle(self, plugin_name: str) -> None:
      +        """The ABC's three lifecycle methods are all overridden."""
      +        _ensure_plugins_loaded()
      +        from agent.browser_provider import BrowserProvider
      +        from agent.browser_registry import get_provider
      +
      +        provider = get_provider(plugin_name)
      +        assert provider is not None
      +        # Each method must be a real override, not the ABC's NotImplementedError
      +        # default — we check by comparing the function reference.
      +        assert type(provider).create_session is not BrowserProvider.create_session
      +        assert type(provider).close_session is not BrowserProvider.close_session
      +        assert (
      +            type(provider).emergency_cleanup is not BrowserProvider.emergency_cleanup
      +        )
      +
      +
      +# ---------------------------------------------------------------------------
      +# is_available() behavior
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestIsAvailable:
      +    """Each plugin's ``is_available()`` reflects env-var presence accurately."""
      +
      +    def test_browserbase_requires_both_api_key_and_project_id(
      +        self, monkeypatch: pytest.MonkeyPatch
      +    ) -> None:
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import get_provider
      +
      +        p = get_provider("browserbase")
      +        assert p is not None
      +        assert p.is_available() is False
      +
      +        # API key alone is insufficient.
      +        monkeypatch.setenv("BROWSERBASE_API_KEY", "key")
      +        assert p.is_available() is False
      +
      +        # Both env vars set → available.
      +        monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "proj")
      +        assert p.is_available() is True
      +
      +    def test_browserbase_project_id_alone_insufficient(
      +        self, monkeypatch: pytest.MonkeyPatch
      +    ) -> None:
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import get_provider
      +
      +        p = get_provider("browserbase")
      +        assert p is not None
      +        monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "proj")
      +        assert p.is_available() is False
      +
      +    def test_browser_use_satisfied_by_api_key(
      +        self, monkeypatch: pytest.MonkeyPatch
      +    ) -> None:
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import get_provider
      +
      +        p = get_provider("browser-use")
      +        assert p is not None
      +        assert p.is_available() is False
      +        monkeypatch.setenv("BROWSER_USE_API_KEY", "key")
      +        assert p.is_available() is True
      +
      +    def test_firecrawl_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import get_provider
      +
      +        p = get_provider("firecrawl")
      +        assert p is not None
      +        assert p.is_available() is False
      +        monkeypatch.setenv("FIRECRAWL_API_KEY", "key")
      +        assert p.is_available() is True
      +
      +
      +# ---------------------------------------------------------------------------
      +# Registry resolution semantics
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestRegistryResolution:
      +    """``_resolve()`` implements the documented three-rule precedence."""
      +
      +    def test_resolve_none_with_no_creds_returns_none(self) -> None:
      +        """No config, no env → local mode (None)."""
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import _resolve
      +
      +        assert _resolve(None) is None
      +
      +    def test_explicit_local_returns_none(self) -> None:
      +        """``cloud_provider: local`` is a positive choice; short-circuits to None."""
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import _resolve
      +
      +        assert _resolve("local") is None
      +
      +    def test_explicit_browserbase_returns_provider_even_when_unavailable(self) -> None:
      +        """Rule 1: explicit-config wins even when credentials are missing.
      +
      +        This is critical — the dispatcher needs to surface a typed
      +        credentials error rather than silently switching backends.
      +        """
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import _resolve
      +
      +        provider = _resolve("browserbase")
      +        assert provider is not None
      +        assert provider.name == "browserbase"
      +        assert provider.is_available() is False  # confirms "ignoring availability"
      +
      +    def test_explicit_firecrawl_returns_provider_even_when_unavailable(self) -> None:
      +        """Firecrawl behaves the same as browserbase under explicit config."""
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import _resolve
      +
      +        provider = _resolve("firecrawl")
      +        assert provider is not None
      +        assert provider.name == "firecrawl"
      +
      +    def test_explicit_unknown_falls_back_to_auto_detect(self) -> None:
      +        """Rule 1 miss: unknown name → fall through to legacy walk."""
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import _resolve
      +
      +        # With no credentials anywhere, auto-detect should also fail.
      +        assert _resolve("not-a-real-provider") is None
      +
      +    def test_legacy_walk_prefers_browser_use_over_browserbase(
      +        self, monkeypatch: pytest.MonkeyPatch
      +    ) -> None:
      +        """Rule 3: walk order is browser-use → browserbase."""
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import _resolve
      +
      +        # Both available — browser-use should win.
      +        monkeypatch.setenv("BROWSER_USE_API_KEY", "k1")
      +        monkeypatch.setenv("BROWSERBASE_API_KEY", "k2")
      +        monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "p")
      +
      +        provider = _resolve(None)
      +        assert provider is not None
      +        assert provider.name == "browser-use"
      +
      +    def test_legacy_walk_falls_through_to_browserbase(
      +        self, monkeypatch: pytest.MonkeyPatch
      +    ) -> None:
      +        """Rule 3: browser-use unavailable → browserbase picked."""
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import _resolve
      +
      +        monkeypatch.setenv("BROWSERBASE_API_KEY", "k")
      +        monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "p")
      +
      +        provider = _resolve(None)
      +        assert provider is not None
      +        assert provider.name == "browserbase"
      +
      +    def test_firecrawl_not_in_legacy_walk_even_when_only_one_available(
      +        self, monkeypatch: pytest.MonkeyPatch
      +    ) -> None:
      +        """Regression: firecrawl is NEVER auto-selected even when single-eligible.
      +
      +        Pre-PR-#25214, the dispatcher only auto-detected between Browser Use
      +        and Browserbase; firecrawl was reachable solely via explicit
      +        config. We preserve that gate because FIRECRAWL_API_KEY is shared
      +        with the *web* firecrawl plugin — auto-routing a web-extract user
      +        to a paid cloud browser would be a real behaviour regression.
      +        """
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import _resolve
      +
      +        monkeypatch.setenv("FIRECRAWL_API_KEY", "k")
      +
      +        # Only firecrawl is_available() — but it's not in the legacy walk.
      +        assert _resolve(None) is None
      +
      +
      +# ---------------------------------------------------------------------------
      +# Legacy ABC backward-compat aliases (is_configured / provider_name)
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestLegacyAbcAliases:
      +    """is_configured() and provider_name() delegate to the new API."""
      +
      +    @pytest.mark.parametrize(
      +        "plugin_name",
      +        ["browserbase", "browser-use", "firecrawl"],
      +    )
      +    def test_is_configured_delegates_to_is_available(self, plugin_name: str) -> None:
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import get_provider
      +
      +        p = get_provider(plugin_name)
      +        assert p is not None
      +        assert p.is_configured() is p.is_available()
      +
      +    @pytest.mark.parametrize(
      +        "plugin_name,expected_label",
      +        [
      +            ("browserbase", "Browserbase"),
      +            ("browser-use", "Browser Use"),
      +            ("firecrawl", "Firecrawl"),
      +        ],
      +    )
      +    def test_provider_name_returns_display_name(
      +        self, plugin_name: str, expected_label: str
      +    ) -> None:
      +        _ensure_plugins_loaded()
      +        from agent.browser_registry import get_provider
      +
      +        p = get_provider(plugin_name)
      +        assert p is not None
      +        assert p.provider_name() == expected_label
      +
      +
      +# ---------------------------------------------------------------------------
      +# Picker integration
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestPickerIntegration:
      +    """`_plugin_browser_providers()` exposes all three plugins as picker rows."""
      +
      +    def test_picker_rows_match_registered_plugins(self) -> None:
      +        _ensure_plugins_loaded()
      +        from hermes_cli.tools_config import _plugin_browser_providers
      +
      +        rows = _plugin_browser_providers()
      +        names = sorted(r.get("browser_provider") for r in rows)
      +        assert names == ["browser-use", "browserbase", "firecrawl"]
      +
      +    def test_picker_rows_carry_post_setup_hook(self) -> None:
      +        """Every browser plugin row has post_setup='agent_browser' so
      +        selecting it triggers the agent-browser CLI install."""
      +        _ensure_plugins_loaded()
      +        from hermes_cli.tools_config import _plugin_browser_providers
      +
      +        for row in _plugin_browser_providers():
      +            assert row.get("post_setup") == "agent_browser", (
      +                f"plugin row {row['browser_provider']!r} missing post_setup hook"
      +            )
      +
      +    def test_picker_rows_carry_browser_plugin_name_marker(self) -> None:
      +        """`browser_plugin_name` matches `browser_provider` so downstream
      +        code can route through the registry when it wants to."""
      +        _ensure_plugins_loaded()
      +        from hermes_cli.tools_config import _plugin_browser_providers
      +
      +        for row in _plugin_browser_providers():
      +            assert row.get("browser_plugin_name") == row.get("browser_provider")
    • tests/tools/test_managed_browserbase_and_modal.py
      @@ -10,7 +10,9 @@
       import pytest
       
       
      -TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
      +REPO_ROOT = Path(__file__).resolve().parents[2]
      +TOOLS_DIR = REPO_ROOT / "tools"
      +PLUGINS_DIR = REPO_ROOT / "plugins"
       
       
       def _load_tool_module(module_name: str, filename: str):
      @@ -22,6 +24,21 @@ def _load_tool_module(module_name: str, filename: str):
           return module
       
       
      +def _load_plugin_module(module_name: str, relpath: str):
      +    """Load a plugin module by file path from ``plugins/``.
      +
      +    Mirror of :func:`_load_tool_module` for the plugin tree. Used by tests
      +    that exercise the per-vendor browser plugins' session-lifecycle
      +    behaviour after the PR #25214 migration.
      +    """
      +    spec = spec_from_file_location(module_name, PLUGINS_DIR / relpath)
      +    assert spec and spec.loader
      +    module = module_from_spec(spec)
      +    sys.modules[module_name] = module
      +    spec.loader.exec_module(module)
      +    return module
      +
      +
       def _reset_modules(prefixes: tuple[str, ...]):
           for name in list(sys.modules):
               if name.startswith(prefixes):
      @@ -76,6 +93,48 @@ def _install_fake_tools_package():
               call_llm=lambda *args, **kwargs: "",
           )
       
      +    # Stubs for the browser-provider plugin layer introduced in PR #25214.
      +    # The fake `agent` package has an empty __path__ so real submodules
      +    # aren't reachable; we install just enough stand-ins to satisfy
      +    # ``tools.browser_tool``'s top-level imports. The actual lifecycle
      +    # tests instantiate the real plugin classes via _load_tool_module
      +    # below, so the stubs only need to satisfy import + isinstance.
      +    class _StubBrowserProvider:
      +        """Minimal BrowserProvider stub for ``from agent.browser_provider import BrowserProvider``."""
      +
      +    sys.modules["agent.browser_provider"] = types.SimpleNamespace(
      +        BrowserProvider=_StubBrowserProvider,
      +    )
      +    sys.modules["agent.browser_registry"] = types.SimpleNamespace(
      +        get_provider=lambda name: None,
      +        list_providers=lambda: [],
      +        register_provider=lambda provider: None,
      +        _resolve=lambda configured: None,
      +    )
      +
      +    # Plugin module stubs — the real plugin classes are loaded from disk by
      +    # the lifecycle tests below via _load_tool_module(). For the import
      +    # phase, we just need the class names to exist on the right module path.
      +    plugins_package = types.ModuleType("plugins")
      +    plugins_package.__path__ = []  # type: ignore[attr-defined]
      +    sys.modules["plugins"] = plugins_package
      +    plugins_browser_package = types.ModuleType("plugins.browser")
      +    plugins_browser_package.__path__ = []  # type: ignore[attr-defined]
      +    sys.modules["plugins.browser"] = plugins_browser_package
      +
      +    for _name, _classname in (
      +        ("browserbase", "BrowserbaseBrowserProvider"),
      +        ("browser_use", "BrowserUseBrowserProvider"),
      +        ("firecrawl", "FirecrawlBrowserProvider"),
      +    ):
      +        _vendor_pkg = types.ModuleType(f"plugins.browser.{_name}")
      +        _vendor_pkg.__path__ = []  # type: ignore[attr-defined]
      +        sys.modules[f"plugins.browser.{_name}"] = _vendor_pkg
      +        _provider_stub_cls = type(_classname, (_StubBrowserProvider,), {})
      +        sys.modules[f"plugins.browser.{_name}.provider"] = types.SimpleNamespace(
      +            **{_classname: _provider_stub_cls},
      +        )
      +
           sys.modules["tools.managed_tool_gateway"] = _load_tool_module(
               "tools.managed_tool_gateway",
               "managed_tool_gateway.py",
      @@ -157,13 +216,13 @@ def test_browserbase_does_not_use_gateway_only_configuration():
           })
       
           with patch.dict(os.environ, env, clear=True):
      -        browserbase_module = _load_tool_module(
      -            "tools.browser_providers.browserbase",
      -            "browser_providers/browserbase.py",
      +        browserbase_module = _load_plugin_module(
      +            "plugins.browser.browserbase.provider",
      +            "browser/browserbase/provider.py",
               )
      -        provider = browserbase_module.BrowserbaseProvider()
      +        provider = browserbase_module.BrowserbaseBrowserProvider()
       
      -    assert provider.is_configured() is False
      +    assert provider.is_available() is False
       
       
       def test_browser_use_managed_gateway_adds_idempotency_key_and_persists_external_call_id():
      @@ -188,13 +247,13 @@ def json(self):
                   }
       
           with patch.dict(os.environ, env, clear=True):
      -        browser_use_module = _load_tool_module(
      -            "tools.browser_providers.browser_use",
      -            "browser_providers/browser_use.py",
      +        browser_use_module = _load_plugin_module(
      +            "plugins.browser.browser_use.provider",
      +            "browser/browser_use/provider.py",
               )
       
               with patch.object(browser_use_module.requests, "post", return_value=_Response()) as post:
      -            provider = browser_use_module.BrowserUseProvider()
      +            provider = browser_use_module.BrowserUseBrowserProvider()
                   session = provider.create_session("task-browser-use-managed")
       
           sent_headers = post.call_args.kwargs["headers"]
      @@ -228,11 +287,11 @@ def json(self):
                   }
       
           with patch.dict(os.environ, env, clear=True):
      -        browser_use_module = _load_tool_module(
      -            "tools.browser_providers.browser_use",
      -            "browser_providers/browser_use.py",
      +        browser_use_module = _load_plugin_module(
      +            "plugins.browser.browser_use.provider",
      +            "browser/browser_use/provider.py",
               )
      -        provider = browser_use_module.BrowserUseProvider()
      +        provider = browser_use_module.BrowserUseBrowserProvider()
               timeout = browser_use_module.requests.Timeout("timed out")
       
               with patch.object(
      @@ -290,11 +349,11 @@ def json(self):
                   }
       
           with patch.dict(os.environ, env, clear=True):
      -        browser_use_module = _load_tool_module(
      -            "tools.browser_providers.browser_use",
      -            "browser_providers/browser_use.py",
      +        browser_use_module = _load_plugin_module(
      +            "plugins.browser.browser_use.provider",
      +            "browser/browser_use/provider.py",
               )
      -        provider = browser_use_module.BrowserUseProvider()
      +        provider = browser_use_module.BrowserUseBrowserProvider()
       
               with patch.object(
                   browser_use_module.requests,
      @@ -337,11 +396,11 @@ def json(self):
                   }
       
           with patch.dict(os.environ, env, clear=True):
      -        browser_use_module = _load_tool_module(
      -            "tools.browser_providers.browser_use",
      -            "browser_providers/browser_use.py",
      +        browser_use_module = _load_plugin_module(
      +            "plugins.browser.browser_use.provider",
      +            "browser/browser_use/provider.py",
               )
      -        provider = browser_use_module.BrowserUseProvider()
      +        provider = browser_use_module.BrowserUseBrowserProvider()
       
               with patch.object(browser_use_module.requests, "post", side_effect=[_Response(), _Response()]) as post:
                   provider.create_session("task-browser-use-new")
    • /dev/null
      @@ -1,10 +0,0 @@
      -"""Cloud browser provider abstraction.
      -
      -Import the ABC so callers can do::
      -
      -    from tools.browser_providers import CloudBrowserProvider
      -"""
      -
      -from tools.browser_providers.base import CloudBrowserProvider
      -
      -__all__ = ["CloudBrowserProvider"]
    • /dev/null
      @@ -1,59 +0,0 @@
      -"""Abstract base class for cloud browser providers."""
      -
      -from abc import ABC, abstractmethod
      -from typing import Dict
      -
      -
      -class CloudBrowserProvider(ABC):
      -    """Interface for cloud browser backends (Browserbase, Steel, etc.).
      -
      -    Implementations live in sibling modules and are registered in
      -    ``browser_tool._PROVIDER_REGISTRY``.  The user selects a provider via
      -    ``hermes setup`` / ``hermes tools``; the choice is persisted as
      -    ``config["browser"]["cloud_provider"]``.
      -    """
      -
      -    @abstractmethod
      -    def provider_name(self) -> str:
      -        """Short, human-readable name shown in logs and diagnostics."""
      -
      -    @abstractmethod
      -    def is_configured(self) -> bool:
      -        """Return True when all required env vars / credentials are present.
      -
      -        Called at tool-registration time (``check_browser_requirements``) to
      -        gate availability.  Must be cheap — no network calls.
      -        """
      -
      -    @abstractmethod
      -    def create_session(self, task_id: str) -> Dict[str, object]:
      -        """Create a cloud browser session and return session metadata.
      -
      -        Must return a dict with at least::
      -
      -            {
      -                "session_name": str,   # unique name for agent-browser --session
      -                "bb_session_id": str,  # provider session ID (for close/cleanup)
      -                "cdp_url": str,        # CDP websocket URL
      -                "features": dict,      # feature flags that were enabled
      -            }
      -
      -        ``bb_session_id`` is a legacy key name kept for backward compat with
      -        the rest of browser_tool.py — it holds the provider's session ID
      -        regardless of which provider is in use.
      -        """
      -
      -    @abstractmethod
      -    def close_session(self, session_id: str) -> bool:
      -        """Release / terminate a cloud session by its provider session ID.
      -
      -        Returns True on success, False on failure.  Should not raise.
      -        """
      -
      -    @abstractmethod
      -    def emergency_cleanup(self, session_id: str) -> None:
      -        """Best-effort session teardown during process exit.
      -
      -        Called from atexit / signal handlers.  Must tolerate missing
      -        credentials, network errors, etc. — log and move on.
      -        """
    • tools/browser_tool.py
      @@ -83,10 +83,24 @@
       except Exception:
           _is_safe_url = lambda url: False  # noqa: E731 — fail-closed: block all if safety module unavailable
           _is_always_blocked_url = lambda url: True  # noqa: E731 — fail-closed on the floor too
      -from tools.browser_providers.base import CloudBrowserProvider
      -from tools.browser_providers.browserbase import BrowserbaseProvider
      -from tools.browser_providers.browser_use import BrowserUseProvider
      -from tools.browser_providers.firecrawl import FirecrawlProvider
      +# Browser-provider ABC + registry — PR #25214 moved the per-vendor providers
      +# (Browserbase / Browser Use / Firecrawl) out of ``tools/browser_providers/``
      +# and into ``plugins/browser/<vendor>/``. The dispatcher consults the
      +# registry; the legacy class names are re-exported below as backward-compat
      +# shims for callers that import them from this module.
      +from agent.browser_provider import BrowserProvider as CloudBrowserProvider  # noqa: F401  (legacy alias)
      +from agent.browser_registry import (  # noqa: F401  (test-patchable surface)
      +    get_provider as _registry_get_browser_provider,
      +)
      +from plugins.browser.browserbase.provider import (  # noqa: F401  (legacy import surface)
      +    BrowserbaseBrowserProvider as BrowserbaseProvider,
      +)
      +from plugins.browser.browser_use.provider import (  # noqa: F401
      +    BrowserUseBrowserProvider as BrowserUseProvider,
      +)
      +from plugins.browser.firecrawl.provider import (  # noqa: F401
      +    FirecrawlBrowserProvider as FirecrawlProvider,
      +)
       from tools.tool_backend_helpers import normalize_browser_cloud_provider
       
       # Camofox local anti-detection browser backend (optional).
      @@ -391,12 +405,29 @@ def _stop_cdp_supervisor(task_id: str) -> None:
       # ============================================================================
       # Cloud Provider Registry
       # ============================================================================
      +#
      +# Per-vendor browser providers (Browserbase / Browser Use / Firecrawl) live as
      +# plugins under ``plugins/browser/<vendor>/`` and self-register through
      +# :mod:`agent.browser_registry` at plugin-discovery time. The legacy
      +# class-name registry below is preserved as a backward-compat shim so test
      +# fixtures that ``monkeypatch.setattr(browser_tool, "_PROVIDER_REGISTRY", ...)``
      +# keep working — but ``_get_cloud_provider()`` now consults
      +# :mod:`agent.browser_registry` for the actual lookup.
      +#
      +# When the test patches ``_PROVIDER_REGISTRY``, we honour it (so the cache
      +# unit tests still drive the function); otherwise the registry-backed path
      +# wins. This keeps the test surface stable while letting third-party
      +# plugins drop in under ``~/.hermes/plugins/browser/<vendor>/``.
       
       _PROVIDER_REGISTRY: Dict[str, type] = {
           "browserbase": BrowserbaseProvider,
           "browser-use": BrowserUseProvider,
           "firecrawl": FirecrawlProvider,
       }
      +# Frozen copy of the import-time _PROVIDER_REGISTRY, used by
      +# ``_is_legacy_provider_registry_overridden`` to detect test-time
      +# monkeypatching. NEVER mutate this dict.
      +_DEFAULT_PROVIDER_REGISTRY: Dict[str, type] = dict(_PROVIDER_REGISTRY)
       
       _cached_cloud_provider: Optional[CloudBrowserProvider] = None
       _cloud_provider_resolved = False
      @@ -411,13 +442,65 @@ def _stop_cdp_supervisor(task_id: str) -> None:
       _browser_engine_resolved = False
       
       
      +def _is_legacy_provider_registry_overridden() -> bool:
      +    """Return True when a test has patched ``_PROVIDER_REGISTRY`` to a custom value.
      +
      +    Detected by spotting any registered class that *isn't* the canonical
      +    plugin-backed class for that name. Tests that
      +    ``monkeypatch.setattr(browser_tool, "_PROVIDER_REGISTRY", ...)`` install
      +    custom factories (`exploding_factory`, `lambda: fake_provider`, etc.);
      +    those entries fail the canonical-class identity check below.
      +
      +    Note: a future maintainer adding a 4th built-in provider only needs to
      +    extend ``_DEFAULT_PROVIDER_REGISTRY`` below — they do NOT need to update
      +    a hardcoded set of keys here. The detection just compares each registered
      +    value against the corresponding canonical class.
      +    """
      +    try:
      +        for key, default_cls in _DEFAULT_PROVIDER_REGISTRY.items():
      +            if _PROVIDER_REGISTRY.get(key) is not default_cls:
      +                return True
      +        # Extra keys not in the default registry → also an override.
      +        return len(_PROVIDER_REGISTRY) != len(_DEFAULT_PROVIDER_REGISTRY)
      +    except Exception:
      +        return False
      +
      +
      +def _ensure_browser_plugins_loaded() -> None:
      +    """Idempotently trigger plugin discovery so the browser registry is populated.
      +
      +    Normally `model_tools` is imported early in any session and that
      +    triggers `discover_plugins()` as a side effect. But `_get_cloud_provider`
      +    can be called from contexts that haven't gone through `model_tools` —
      +    standalone scripts, certain unit-test paths, the parity-sweep harness.
      +    Make discovery idempotent and side-effect-only here so users always
      +    see registered plugins regardless of import order. Cheap: subsequent
      +    calls early-return inside `_ensure_plugins_discovered`.
      +    """
      +    try:
      +        from hermes_cli.plugins import _ensure_plugins_discovered
      +
      +        _ensure_plugins_discovered()
      +    except Exception as exc:
      +        logger.debug("Browser plugin discovery failed (non-fatal): %s", exc)
      +
      +
       def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
           """Return the configured cloud browser provider, or None for local mode.
       
           Reads ``config["browser"]["cloud_provider"]`` once and caches the result
           for the process lifetime. An explicit ``local`` provider disables cloud
      -    fallback. If unset, fall back to Browserbase when direct or managed
      -    Browserbase credentials are available.
      +    fallback. If unset, fall back to Browser Use (managed Nous gateway or
      +    direct API key) and then Browserbase (direct credentials only) — the
      +    historic auto-detect order, now expressed as the
      +    :data:`agent.browser_registry._LEGACY_PREFERENCE` walk.
      +
      +    Selection routes through :mod:`agent.browser_registry` so third-party
      +    browser plugins (``~/.hermes/plugins/browser/<vendor>/``) participate
      +    in explicit-config resolution. Test fixtures that override
      +    ``_PROVIDER_REGISTRY`` or ``BrowserUseProvider`` / ``BrowserbaseProvider``
      +    on this module still drive the function — see
      +    ``_is_legacy_provider_registry_overridden``.
           """
           global _cached_cloud_provider, _cloud_provider_resolved
           if _cloud_provider_resolved:
      @@ -437,9 +520,33 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
                       _cached_cloud_provider = None
                       _cloud_provider_resolved = True
                       return None
      -        if provider_key and provider_key in _PROVIDER_REGISTRY:
      +        if provider_key:
                   try:
      -                resolved = _PROVIDER_REGISTRY[provider_key]()
      +                if _is_legacy_provider_registry_overridden():
      +                    # Test fixture path: honour the patched dict so the
      +                    # cache-policy unit tests keep working.
      +                    factory = _PROVIDER_REGISTRY.get(provider_key)
      +                    if factory is not None:
      +                        resolved = factory()
      +                else:
      +                    # Ensure plugins are discovered so the registry is
      +                    # populated. Idempotent — cheap on subsequent calls.
      +                    _ensure_browser_plugins_loaded()
      +                    resolved = _registry_get_browser_provider(provider_key)
      +                    if resolved is None:
      +                        # Explicit config name unknown to the registry —
      +                        # might be a typo, an uninstalled plugin, or a
      +                        # registry-population failure. Warn the user
      +                        # (legacy code would have surfaced a typed
      +                        # credentials error via direct class instantiation;
      +                        # post-migration we surface this WARNING instead).
      +                        logger.warning(
      +                            "browser.cloud_provider=%r is not a registered "
      +                            "browser plugin; falling back to auto-detect "
      +                            "(install the corresponding plugin or fix the "
      +                            "config key spelling).",
      +                            provider_key,
      +                        )
                   except Exception:
                       logger.warning(
                           "Failed to instantiate explicit cloud_provider %r; will retry on next call",
      @@ -453,8 +560,15 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
               logger.debug("Could not read cloud_provider from config: %s", e)
       
           if resolved is None:
      -        # Prefer Browser Use (managed Nous gateway or direct API key),
      -        # fall back to Browserbase (direct credentials only).
      +        # Auto-detect path: Browser Use first (managed Nous gateway or
      +        # direct API key), then Browserbase (direct credentials). Uses
      +        # the legacy class names imported at the top of this module so
      +        # tests that ``monkeypatch.setattr(browser_tool, "BrowserUseProvider", ...)``
      +        # keep driving this branch deterministically. Third-party browser
      +        # plugins are intentionally NOT reachable from auto-detect — they
      +        # participate only via explicit ``browser.cloud_provider: <name>``,
      +        # mirroring the firecrawl gate documented on
      +        # :data:`agent.browser_registry._LEGACY_PREFERENCE`.
               try:
                   fallback_provider = BrowserUseProvider()
                   if fallback_provider.is_configured():

1 PR closed without merging

From the close comments: merged via another change 1

  • #25580 refactor(browser): all cloud browser providers as image_gen-style plugins (closes #25214) · merged via another change → c6e6909, c74ff2c, f36c89c, #27403, #2746, #25214
Closed issues · 1
  • Closed#25214Mirror web-provider plugin migration for browser providers
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 1.00: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#27403
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
17 May 2026

For your agent

The same facts, typed.