diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 25df4b3e2f3..4bd1a2bb2f9 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1307,6 +1307,7 @@ ENV_VARS_BY_VERSION: Dict[int, List[str]] = { "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"], 10: ["TAVILY_API_KEY"], 11: ["TERMINAL_MODAL_MODE"], + 23: ["TINYFISH_API_KEY", "TINYFISH_API_URL", "TINYFISH_BROWSER_TIMEOUT"], } # Required environment variables with metadata for migration prompts. @@ -1818,6 +1819,29 @@ OPTIONAL_ENV_VARS = { "password": False, "category": "tool", }, + "TINYFISH_API_KEY": { + "description": "TinyFish API key for cloud browser, search, fetch, and agent", + "prompt": "TinyFish API key", + "url": "https://agent.tinyfish.ai/api-keys", + "tools": ["browser_navigate", "browser_click"], + "password": True, + "category": "tool", + }, + "TINYFISH_API_URL": { + "description": "TinyFish browser API URL override (optional, for staging/dev)", + "prompt": "TinyFish API URL (leave empty for default)", + "url": None, + "tools": ["browser_navigate", "browser_click"], + "password": False, + "category": "tool", + }, + "TINYFISH_BROWSER_TIMEOUT": { + "description": "TinyFish browser session inactivity timeout in seconds (optional, default 300)", + "prompt": "Browser session timeout (seconds)", + "tools": ["browser_navigate", "browser_click"], + "password": False, + "category": "tool", + }, "CAMOFOX_URL": { "description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)", "prompt": "Camofox server URL", @@ -4433,6 +4457,7 @@ def show_config(): ("TAVILY_API_KEY", "Tavily"), ("BROWSERBASE_API_KEY", "Browserbase"), ("BROWSER_USE_API_KEY", "Browser Use"), + ("TINYFISH_API_KEY", "TinyFish"), ("FAL_KEY", "FAL"), ] @@ -4617,6 +4642,7 @@ def set_config_value(key: str, value: str): 'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME', 'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY', + 'TINYFISH_API_KEY', 'TINYFISH_API_URL', 'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN', 'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY', 'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index c83844901f1..541877bc266 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -141,6 +141,7 @@ def _browser_label(current_provider: str) -> str: "browserbase": "Browserbase", "browser-use": "Browser Use", "firecrawl": "Firecrawl", + "tinyfish": "TinyFish", "camofox": "Camofox", "local": "Local browser", } @@ -169,6 +170,7 @@ def _resolve_browser_feature_state( direct_browserbase: bool, direct_browser_use: bool, direct_firecrawl: bool, + direct_tinyfish: bool, managed_browser_available: bool, ) -> tuple[str, bool, bool, bool]: """Resolve browser availability using the same precedence as runtime.""" @@ -196,6 +198,10 @@ def _resolve_browser_feature_state( available = bool(browser_local_available and direct_firecrawl) active = bool(browser_tool_enabled and available) return current_provider, available, active, False + if current_provider == "tinyfish": + available = bool(browser_local_available and direct_tinyfish) + active = bool(browser_tool_enabled and available) + return current_provider, available, active, False if current_provider == "camofox": return current_provider, False, False, False @@ -286,6 +292,7 @@ def get_nous_subscription_features( direct_camofox = bool(get_env_value("CAMOFOX_URL")) direct_browserbase = bool(get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID")) direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY")) + direct_tinyfish = bool(get_env_value("TINYFISH_API_KEY")) direct_modal = has_direct_modal_credentials() # When use_gateway is set, suppress direct credentials for managed detection @@ -363,6 +370,7 @@ def get_nous_subscription_features( direct_browserbase=direct_browserbase, direct_browser_use=direct_browser_use, direct_firecrawl=direct_firecrawl, + direct_tinyfish=direct_tinyfish, managed_browser_available=managed_browser_available, ) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index b3df18d9321..096ded6a3bf 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -379,6 +379,15 @@ TOOL_CATEGORIES = { "browser_provider": "firecrawl", "post_setup": "agent_browser", }, + { + "name": "TinyFish", + "tag": "Low latency browser with stealth & proxies", + "env_vars": [ + {"key": "TINYFISH_API_KEY", "prompt": "TinyFish API key", "url": "https://agent.tinyfish.ai/api-keys"}, + ], + "browser_provider": "tinyfish", + "post_setup": "agent_browser", + }, { "name": "Camofox", "badge": "free · local", diff --git a/tests/tools/test_browser_camofox_state.py b/tests/tools/test_browser_camofox_state.py index 9ce3d132028..820ba9e2182 100644 --- a/tests/tools/test_browser_camofox_state.py +++ b/tests/tools/test_browser_camofox_state.py @@ -58,3 +58,10 @@ class TestCamofoxConfigDefaults: browser_cfg = DEFAULT_CONFIG["browser"] assert browser_cfg["camofox"]["managed_persistence"] is False + + def test_config_version_matches_current_schema(self): + from hermes_cli.config import DEFAULT_CONFIG + + # The current schema version is tracked globally; unrelated default + # options may bump it after browser defaults are added. + assert DEFAULT_CONFIG["_config_version"] == 23 diff --git a/tools/browser_providers/tinyfish.py b/tools/browser_providers/tinyfish.py new file mode 100644 index 00000000000..7ed07f9b596 --- /dev/null +++ b/tools/browser_providers/tinyfish.py @@ -0,0 +1,129 @@ +"""TinyFish cloud browser provider.""" + +import logging +import os +import uuid +from typing import Any, Dict, Optional + +import requests + +from tools.browser_providers.base import CloudBrowserProvider +from tools.managed_tool_gateway import resolve_managed_tool_gateway + +logger = logging.getLogger(__name__) + +_DEFAULT_BASE_URL = "https://api.browser.tinyfish.ai" +_DEFAULT_TIMEOUT_SECONDS = 300 + + +class TinyFishBrowserProvider(CloudBrowserProvider): + """TinyFish (https://tinyfish.ai) cloud browser backend.""" + + def provider_name(self) -> str: + return "TinyFish" + + # ------------------------------------------------------------------ + # Config resolution (direct API key OR managed Nous gateway) + # ------------------------------------------------------------------ + + def _get_config_or_none(self) -> Optional[Dict[str, Any]]: + api_key = os.environ.get("TINYFISH_API_KEY") + if api_key: + return { + "api_key": api_key, + "base_url": os.environ.get("TINYFISH_API_URL", _DEFAULT_BASE_URL).rstrip("/"), + "managed_mode": False, + } + + managed = resolve_managed_tool_gateway("tinyfish") + if managed is None: + return None + + return { + "api_key": managed.nous_user_token, + "base_url": managed.gateway_origin.rstrip("/"), + "managed_mode": True, + } + + def _get_config(self) -> Dict[str, Any]: + config = self._get_config_or_none() + if config is None: + raise ValueError( + "TinyFish requires a TINYFISH_API_KEY environment variable. " + "Get your API key at https://agent.tinyfish.ai/api-keys" + ) + return config + + def is_configured(self) -> bool: + return self._get_config_or_none() is not None + + # ------------------------------------------------------------------ + # Session lifecycle + # ------------------------------------------------------------------ + + def _headers(self, config: Dict[str, Any]) -> Dict[str, str]: + return { + "X-API-Key": config["api_key"], + "Content-Type": "application/json", + } + + def create_session(self, task_id: str) -> Dict[str, object]: + config = self._get_config() + + timeout_seconds = _DEFAULT_TIMEOUT_SECONDS + try: + timeout_seconds = int(os.environ.get("TINYFISH_BROWSER_TIMEOUT", str(_DEFAULT_TIMEOUT_SECONDS))) + except (ValueError, TypeError): + pass + + response = requests.post( + config["base_url"], + headers=self._headers(config), + json={"timeout_seconds": timeout_seconds}, + timeout=30, + ) + + if response.status_code in (401, 403): + raise ValueError( + f"TinyFish authentication failed (HTTP {response.status_code}). " + "Check your TINYFISH_API_KEY at https://agent.tinyfish.ai/api-keys" + ) + if response.status_code == 402: + raise ValueError( + "TinyFish browser session failed: insufficient credits or no active subscription. " + "Check your account at https://agent.tinyfish.ai" + ) + if response.status_code == 404: + raise ValueError( + "TinyFish browser API is not enabled on your plan. " + "Contact support or upgrade at https://agent.tinyfish.ai" + ) + if not response.ok: + raise RuntimeError( + f"Failed to create TinyFish browser session: " + f"{response.status_code} {response.text[:200]}" + ) + + data = response.json() + session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}" + + logger.info("Created TinyFish browser session %s", session_name) + + return { + "session_name": session_name, + "bb_session_id": data["session_id"], + "cdp_url": data["cdp_url"], + "features": {"tinyfish": True}, + } + + def close_session(self, session_id: str) -> bool: + # TinyFish has no explicit delete endpoint — sessions auto-expire on inactivity timeout. + logger.debug( + "TinyFish sessions expire automatically on inactivity — no close call needed for %s", + session_id, + ) + return True + + def emergency_cleanup(self, session_id: str) -> None: + # No-op: TinyFish sessions are cleaned up server-side on inactivity. + logger.debug("TinyFish emergency_cleanup skipped for %s — auto-expiry handles cleanup", session_id) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 5cd431de317..c2eae8ab1f5 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -83,6 +83,7 @@ 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 +from tools.browser_providers.tinyfish import TinyFishBrowserProvider from tools.tool_backend_helpers import normalize_browser_cloud_provider # Camofox local anti-detection browser backend (optional). @@ -391,6 +392,7 @@ _PROVIDER_REGISTRY: Dict[str, type] = { "browserbase": BrowserbaseProvider, "browser-use": BrowserUseProvider, "firecrawl": FirecrawlProvider, + "tinyfish": TinyFishBrowserProvider, } _cached_cloud_provider: Optional[CloudBrowserProvider] = None diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 955f4600146..88e2f325e64 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -118,6 +118,9 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `BROWSERBASE_PROJECT_ID` | Browserbase project ID | | `BROWSER_USE_API_KEY` | Browser Use cloud browser API key ([browser-use.com](https://browser-use.com/)) | | `FIRECRAWL_BROWSER_TTL` | Firecrawl browser session TTL in seconds (default: 300) | +| `TINYFISH_API_KEY` | TinyFish API key for cloud browser ([agent.tinyfish.ai](https://agent.tinyfish.ai/api-keys)) | +| `TINYFISH_API_URL` | TinyFish browser API URL override for staging/dev (optional) | +| `TINYFISH_BROWSER_TIMEOUT` | TinyFish browser session inactivity timeout in seconds (default: 300) | | `BROWSER_CDP_URL` | Chrome DevTools Protocol URL for local browser (set via `/browser connect`, e.g. `ws://localhost:9222`) | | `CAMOFOX_URL` | Camofox local anti-detection browser URL (default: `http://localhost:9377`) | | `BROWSER_INACTIVITY_TIMEOUT` | Browser session inactivity timeout in seconds | diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 3bc1b0bb72a..07db564c64d 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -12,6 +12,7 @@ Hermes Agent includes a full browser automation toolset with multiple backend op - **Browserbase cloud mode** via [Browserbase](https://browserbase.com) for managed cloud browsers and anti-bot tooling - **Browser Use cloud mode** via [Browser Use](https://browser-use.com) as an alternative cloud browser provider - **Firecrawl cloud mode** via [Firecrawl](https://firecrawl.dev) for cloud browsers with built-in scraping +- **TinyFish cloud mode** via [TinyFish](https://tinyfish.ai) for fast cloud CDP browsers - **Camofox local mode** via [Camofox](https://github.com/jo-inc/camofox-browser) for local anti-detection browsing (Firefox-based fingerprint spoofing) - **Local Chrome via CDP** — connect browser tools to your own Chrome instance using `/browser connect` - **Local browser mode** via the `agent-browser` CLI and a local Chromium installation @@ -86,6 +87,29 @@ FIRECRAWL_API_URL=http://localhost:3002 FIRECRAWL_BROWSER_TTL=600 ``` +### TinyFish cloud mode + +To use TinyFish as your cloud browser provider, add: + +```bash +# Add to ~/.hermes/.env +TINYFISH_API_KEY=your_key_here +``` + +Get your API key at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys). Then select TinyFish as your browser provider: + +```bash +hermes setup tools +# → Browser Automation → TinyFish +``` + +Optional settings: + +```bash +# Session inactivity timeout in seconds (default: 300, capped to your plan maximum) +TINYFISH_BROWSER_TIMEOUT=600 +``` + ### Hybrid routing: cloud for public URLs, local for LAN/localhost When a cloud provider is configured, Hermes auto-spawns a **local Chromium sidecar**