{"repo":"NousResearch/hermes-agent","public_id":"40190","display_label":"Firecrawl config/env resolution bug and fixes","summary":"A bug where the Firecrawl web provider ignored configuration values from config.yaml and Hermes config, reading only raw environment variables. Multiple fix attempts address config-aware environment resolution for Firecrawl and other web providers.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":59518},"resolution":{"state":"resolved","by":"pr-59518"},"freshness":{"state":"verified","last_verified_at":"2026-07-06T09:42:25+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.43599999999999994},"fix_edges":[{"issue_number":40190,"pr_number":59518,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":55065,"pr_number":59518,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":40190,"title":"Bug: Firecrawl web provider ignores Hermes config env values","url":null,"state":"closed"},{"kind":"issue","number":55065,"title":"web.extract.firecrawl_api_url in config.yaml ignored - plugin reads only env vars","url":null,"state":"closed"},{"kind":"pr","number":40192,"title":"fix(web): use get_env_value for Firecrawl config resolution","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"026ab4737","number":0},{"kind":"pr","value":"#59518","number":59518}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":55069,"title":"fix(firecrawl): read firecrawl_api_url from config.yaml as fallback","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#40192","number":40192},{"kind":"pr","value":"#40190","number":40190},{"kind":"pr","value":"#40192","number":40192}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":59518,"title":"fix(web): config-aware env resolution for all web providers (salvage of #40192 by @liuhao1024)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/web_search_provider.py","plugins/web/brave_free/provider.py","plugins/web/exa/provider.py","plugins/web/firecrawl/provider.py","plugins/web/parallel/provider.py","plugins/web/tavily/provider.py","tests/tools/test_web_tools_config.py","tools/web_tools.py"],"fix_hunks":[{"file":"agent/web_search_provider.py","hunks":["@@ -52,7 +52,33 @@\n from __future__ import annotations\n \n import abc\n-from typing import Any, Dict, List\n+import os\n+from typing import Any, Dict, List, Optional\n+\n+\n+def get_provider_env(name: str) -> str:\n+    \"\"\"Config-aware env lookup for web providers.\n+\n+    Resolves *name* via :func:`hermes_cli.config.get_env_value` (checks\n+    ``os.environ`` first, then ``~/.hermes/.env``) so credentials set\n+    through Hermes' config layer are visible even when they were never\n+    exported into the process environment — gateway sessions, delegate\n+    children, and subprocess agent runs (issue #40190). Falls back to a\n+    bare ``os.getenv`` when the config module is unavailable (stripped\n+    installs, early import contexts).\n+\n+    Returns the stripped value, or ``\"\"`` when unset.\n+    \"\"\"\n+    val: Optional[str] = None\n+    try:\n+        from hermes_cli.config import get_env_value\n+\n+        val = get_env_value(name)\n+    except Exception:  # noqa: BLE001 — config layer optional here\n+        val = None\n+    if val is None:\n+        val = os.getenv(name, \"\")\n+    return (val or \"\").strip()\n \n \n # ---------------------------------------------------------------------------"]},{"file":"plugins/web/brave_free/provider.py","hunks":["@@ -49,7 +49,9 @@ def display_name(self) -> str:\n \n     def is_available(self) -> bool:\n         \"\"\"Return True when ``BRAVE_SEARCH_API_KEY`` is set to a non-empty value.\"\"\"\n-        return bool(os.getenv(\"BRAVE_SEARCH_API_KEY\", \"\").strip())\n+        from agent.web_search_provider import get_provider_env\n+\n+        return bool(get_provider_env(\"BRAVE_SEARCH_API_KEY\"))\n \n     def supports_search(self) -> bool:\n         return True","@@ -65,7 +67,9 @@ def search(self, query: str, limit: int = 5) -> Dict[str, Any]:\n         \"\"\"\n         import httpx\n \n-        api_key = [redacted:secret-assignment]\"BRAVE_SEARCH_API_KEY\", \"\").strip()\n+        from agent.web_search_provider import get_provider_env\n+\n+        api_key = [redacted:secret-assignment]\"BRAVE_SEARCH_API_KEY\")\n         if not api_key:\n             return {\"success\": False, \"error\": \"BRAVE_SEARCH_API_KEY is not set\"}\n "]},{"file":"plugins/web/exa/provider.py","hunks":["@@ -51,7 +51,9 @@ def _get_exa_client() -> Any:\n     if cached is not None:\n         return cached\n \n-    api_key = [redacted:secret-assignment]\"EXA_API_KEY\")\n+    from agent.web_search_provider import get_provider_env\n+\n+    api_key = [redacted:secret-assignment]\"EXA_API_KEY\")\n     if not api_key:\n         raise ValueError(\n             \"EXA_API_KEY environment variable not set. \"","@@ -100,7 +102,9 @@ def display_name(self) -> str:\n \n     def is_available(self) -> bool:\n         \"\"\"Return True when ``EXA_API_KEY`` is set to a non-empty value.\"\"\"\n-        return bool(os.getenv(\"EXA_API_KEY\", \"\").strip())\n+        from agent.web_search_provider import get_provider_env\n+\n+        return bool(get_provider_env(\"EXA_API_KEY\"))\n \n     def supports_search(self) -> bool:\n         return True"]},{"file":"plugins/web/firecrawl/provider.py","hunks":["@@ -122,8 +122,10 @@ def __repr__(self) -> str:\n \n def _get_direct_firecrawl_config() -> Optional[tuple]:\n     \"\"\"Return explicit direct Firecrawl kwargs + cache key, or None when unset.\"\"\"\n-    api_key = [redacted:secret-assignment]\"FIRECRAWL_API_KEY\", \"\").strip()\n-    api_url = os.getenv(\"FIRECRAWL_API_URL\", \"\").strip().rstrip(\"/\")\n+    from hermes_cli.config import get_env_value\n+\n+    api_key = [redacted:secret-assignment]\"FIRECRAWL_API_KEY\") or \"\").strip()\n+    api_url = (get_env_value(\"FIRECRAWL_API_URL\") or \"\").strip().rstrip(\"/\")\n \n     if not api_key and not api_url:\n         return None"]},{"file":"plugins/web/parallel/provider.py","hunks":["@@ -73,7 +73,9 @@ def _get_sync_client() -> Any:\n     if cached is not None:\n         return cached\n \n-    api_key = [redacted:secret-assignment]\"PARALLEL_API_KEY\")\n+    from agent.web_search_provider import get_provider_env\n+\n+    api_key = [redacted:secret-assignment]\"PARALLEL_API_KEY\")\n     if not api_key:\n         raise ValueError(\n             \"PARALLEL_API_KEY environment variable not set. \"","@@ -99,7 +101,9 @@ def _get_async_client() -> Any:\n     if cached is not None:\n         return cached\n \n-    api_key = [redacted:secret-assignment]\"PARALLEL_API_KEY\")\n+    from agent.web_search_provider import get_provider_env\n+\n+    api_key = [redacted:secret-assignment]\"PARALLEL_API_KEY\")\n     if not api_key:\n         raise ValueError(\n             \"PARALLEL_API_KEY environment variable not set. \"","@@ -153,7 +157,9 @@ def display_name(self) -> str:\n \n     def is_available(self) -> bool:\n         \"\"\"Return True when ``PARALLEL_API_KEY`` is set to a non-empty value.\"\"\"\n-        return bool(os.getenv(\"PARALLEL_API_KEY\", \"\").strip())\n+        from agent.web_search_provider import get_provider_env\n+\n+        return bool(get_provider_env(\"PARALLEL_API_KEY\"))\n \n     def supports_search(self) -> bool:\n         return True"]},{"file":"plugins/web/tavily/provider.py","hunks":["@@ -41,14 +41,16 @@ def _tavily_request(endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:\n     \"\"\"\n     import httpx\n \n-    api_key = [redacted:secret-assignment]\"TAVILY_API_KEY\")\n+    from agent.web_search_provider import get_provider_env\n+\n+    api_key = [redacted:secret-assignment]\"TAVILY_API_KEY\")\n     if not api_key:\n         raise ValueError(\n             \"TAVILY_API_KEY environment variable not set. \"\n             \"Get your API key at https://app.tavily.com/home\"\n         )\n \n-    base_url = os.getenv(\"TAVILY_BASE_URL\", \"https://api.tavily.com\")\n+    base_url = get_provider_env(\"TAVILY_BASE_URL\") or \"https://api.tavily.com\"\n     payload = dict(payload)  # don't mutate caller's dict\n     payload[\"api_key\"] = api_key\n     url = f\"{base_url}/{endpoint.lstrip('/')}\"","@@ -138,7 +140,9 @@ def display_name(self) -> str:\n \n     def is_available(self) -> bool:\n         \"\"\"Return True when ``TAVILY_API_KEY`` is set to a non-empty value.\"\"\"\n-        return bool(os.getenv(\"TAVILY_API_KEY\", \"\").strip())\n+        from agent.web_search_provider import get_provider_env\n+\n+        return bool(get_provider_env(\"TAVILY_API_KEY\"))\n \n     def supports_search(self) -> bool:\n         return True"]},{"file":"tests/tools/test_web_tools_config.py","hunks":["@@ -787,3 +787,95 @@ def test_tool_registry_entries_not_filtered_out(self):\n                 \"web_search tool was filtered out despite custom provider being available\"\n             assert web_extract_entry is not None, \\\n                 \"web_extract tool was filtered out despite custom provider being available\"\n+\n+\n+class TestFirecrawlEnvResolution:\n+    \"\"\"Verify Firecrawl reads env values from hermes_cli.config.get_env_value,\n+    not just os.getenv.  This catches the regression reported in #40190 where\n+    values stored in ~/.hermes/.env were invisible to the provider.\"\"\"\n+\n+    def test_direct_config_reads_via_get_env_value(self, monkeypatch: pytest.MonkeyPatch) -> None:\n+        \"\"\"_get_direct_firecrawl_config() must use get_env_value, not os.getenv.\"\"\"\n+        # Ensure os.environ does NOT carry the key\n+        monkeypatch.delenv(\"FIRECRAWL_API_KEY\", raising=False)\n+        monkeypatch.delenv(\"FIRECRAWL_API_URL\", raising=False)\n+\n+        fake_key = \"fc-test-key-from-dotenv\"\n+        with patch(\n+            \"hermes_cli.config.get_env_value\",\n+            side_effect=lambda k: fake_key if k == \"FIRECRAWL_API_KEY\" else None,\n+        ):\n+            from plugins.web.firecrawl.provider import _get_direct_firecrawl_config\n+\n+            result = _get_direct_firecrawl_config()\n+            assert result is not None, \"get_env_value fallback should find the key\"\n+            kwargs, _cache_key = result\n+            assert kwargs[\"api_key\"] == fake_key\n+\n+    def test_direct_config_reads_url_via_get_env_value(self, monkeypatch: pytest.MonkeyPatch) -> None:\n+        \"\"\"Self-hosted URL from .env must be picked up.\"\"\"\n+        monkeypatch.delenv(\"FIRECRAWL_API_KEY\", raising=False)\n+        monkeypatch.delenv(\"FIRECRAWL_API_URL\", raising=False)\n+\n+        fake_url = \"https://firecrawl.internal.example.com\"\n+        with patch(\n+            \"hermes_cli.config.get_env_value\",\n+            side_effect=lambda k: fake_url if k == \"FIRECRAWL_API_URL\" else None,\n+        ):\n+            from plugins.web.firecrawl.provider import _get_direct_firecrawl_config\n+\n+            result = _get_direct_firecrawl_config()\n+            assert result is not None\n+            kwargs, _cache_key = result\n+            assert kwargs[\"api_url\"] == fake_url.rstrip(\"/\")\n+\n+\n+class TestSiblingProvidersEnvResolution:\n+    \"\"\"The same #40190 bug class widened: every keyed web provider must\n+    resolve its credential through the config-aware lookup (os.environ OR\n+    ~/.hermes/.env), not bare os.getenv. Parametrized over the four\n+    providers that previously read only the process environment.\"\"\"\n+\n+    _CASES = [\n+        (\"plugins.web.exa.provider\", \"ExaWebSearchProvider\", \"EXA_API_KEY\"),\n+        (\"plugins.web.parallel.provider\", \"ParallelWebSearchProvider\", \"PARALLEL_API_KEY\"),\n+        (\"plugins.web.tavily.provider\", \"TavilyWebSearchProvider\", \"TAVILY_API_KEY\"),\n+        (\"plugins.web.brave_free.provider\", \"BraveFreeWebSearchProvider\", \"BRAVE_SEARCH_API_KEY\"),\n+    ]\n+\n+    @pytest.mark.parametrize(\"module_path,cls_name,env_key\", _CASES)\n+    def test_is_available_reads_via_get_env_value(\n+        self, monkeypatch, module_path, cls_name, env_key\n+    ):\n+        \"\"\"is_available() must see a key that lives only in the .env layer.\"\"\"\n+        monkeypatch.delenv(env_key, raising=False)\n+\n+        import importlib\n+        module = importlib.import_module(module_path)\n+        provider = getattr(module, cls_name)()\n+\n+        assert provider.is_available() is False\n+\n+        with patch(\n+            \"hermes_cli.config.get_env_value\",\n+            side_effect=lambda k: \"test-key-from-dotenv\" if k == env_key else None,\n+        ):\n+            assert provider.is_available() is True, (\n+                f\"{cls_name}.is_available() ignored {env_key} from the \"\n+                \"config-aware env layer (get_env_value)\"\n+            )\n+\n+    def test_get_provider_env_falls_back_to_os_environ(self, monkeypatch):\n+        \"\"\"When the config layer has no value, process env still wins.\"\"\"\n+        from agent.web_search_provider import get_provider_env\n+\n+        monkeypatch.setenv(\"WSP_TEST_FALLBACK_KEY\", \"  from-process-env  \")\n+        with patch(\"hermes_cli.config.get_env_value\", return_value=None):\n+            assert get_provider_env(\"WSP_TEST_FALLBACK_KEY\") == \"from-process-env\"\n+\n+    def test_get_provider_env_unset_returns_empty(self, monkeypatch):\n+        monkeypatch.delenv(\"WSP_TEST_UNSET_KEY\", raising=False)\n+        with patch(\"hermes_cli.config.get_env_value\", return_value=None):\n+            from agent.web_search_provider import get_provider_env\n+\n+            assert get_provider_env(\"WSP_TEST_UNSET_KEY\") == \"\""]},{"file":"tools/web_tools.py","hunks":["@@ -1001,8 +1001,9 @@ def check_web_api_key() -> bool:\n     # Check if API keys are available\n     web_available = check_web_api_key()\n     tool_gateway_available = _is_tool_gateway_ready()\n-    firecrawl_key_available = bool(os.getenv(\"FIRECRAWL_API_KEY\", \"\").strip())\n-    firecrawl_url_available = bool(os.getenv(\"FIRECRAWL_API_URL\", \"\").strip())\n+    from hermes_cli.config import get_env_value as _gev\n+    firecrawl_key_available = bool((_gev(\"FIRECRAWL_API_KEY\") or \"\").strip())\n+    firecrawl_url_available = bool((_gev(\"FIRECRAWL_API_URL\") or \"\").strip())\n \n     if web_available:\n         backend = _get_backend()","@@ -1020,7 +1021,7 @@ def check_web_api_key() -> bool:\n         elif backend == \"ddgs\":\n             print(\"   Using DuckDuckGo via ddgs package (search only)\")\n         elif firecrawl_url_available:\n-            print(f\"   Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}\")\n+            print(f\"   Using self-hosted Firecrawl: {(_gev('FIRECRAWL_API_URL') or '').strip().rstrip('/')}\")\n         elif firecrawl_key_available:\n             print(\"   Using direct Firecrawl cloud API\")\n         elif tool_gateway_available:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}