{"repo":"NousResearch/hermes-agent","public_id":"20476","display_label":"Camofox API key auth header fixes and support","summary":"These issues and pull requests address missing or broken Authorization header handling in Camofox browser integration when CAMOFOX_API_KEY is set. Changes span health checks, session requests, tab creation, and related fixes such as listItemId and stale-tab handling.","subsystem":"agent","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54729},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-29T08:26:25+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":20476,"pr_number":54729,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":37960,"pr_number":54729,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":40843,"pr_number":54729,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":20476,"title":"Camofox browser: all operations fail with 403 when CAMOFOX_API_KEY is set","url":null,"state":"closed"},{"kind":"pr","number":20479,"title":"fix(browser): send Authorization header in Camofox HTTP calls when CAMOFOX_API_KEY is set","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"babd9168b","number":0},{"kind":"pr","value":"#54729","number":54729}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":29686,"title":"fix(browser): send Authorization Bearer header in _ensure_tab() first-tab POST","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":33264,"title":"feat(tools): add CAMOFOX_API_KEY support for remote Camofox authentication","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54729","number":54729},{"kind":"pr","value":"#20479","number":20479}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":44954,"title":"fix(camofox): add auth header to Camofox health and session requests","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54729","number":54729},{"kind":"pr","value":"#20479","number":20479}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":54729,"title":"fix(camofox): repair broken browser backend (listItemId + auth + timeout + stale-tab)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","tests/tools/test_browser_camofox_auth.py","tests/tools/test_browser_camofox_ensure_tab.py","tests/tools/test_browser_camofox_persistence.py","tests/tools/test_browser_camofox_timeout.py","tools/browser_camofox.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -3549,6 +3549,15 @@ def _ensure_hermes_home_managed(home: Path):\n         \"password\": False,\n         \"category\": \"tool\",\n     },\n+    \"CAMOFOX_API_KEY\": {\n+        \"description\": \"Optional bearer token sent as Authorization header to a remote/authenticated Camofox server\",\n+        \"prompt\": \"Camofox API key\",\n+        \"url\": \"https://github.com/jo-inc/camofox-browser\",\n+        \"tools\": [\"browser_navigate\", \"browser_click\"],\n+        \"password\": True,\n+        \"category\": \"tool\",\n+        \"advanced\": True,\n+    },\n     \"FAL_KEY\": {\n         \"description\": \"FAL API key for image and video generation\",\n         \"prompt\": \"FAL API key\","]},{"file":"tests/tools/test_browser_camofox_auth.py","hunks":["@@ -0,0 +1,111 @@\n+\"\"\"Tests that Camofox browser sends Authorization header when CAMOFOX_API_KEY is set.\n+\n+Regression test for https://github.com/NousResearch/hermes-agent/issues/20476\n+\"\"\"\n+\n+import json\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+from tools.browser_camofox import (\n+    _auth_headers,\n+    camofox_back,\n+    camofox_click,\n+    camofox_close,\n+    camofox_navigate,\n+    camofox_press,\n+    camofox_scroll,\n+    camofox_snapshot,\n+    camofox_type,\n+)\n+\n+\n+def _mock_response(status=200, json_data=None):\n+    resp = MagicMock()\n+    resp.status_code = status\n+    resp.json.return_value = json_data or {}\n+    resp.content = b\"\\x89PNG\\r\\n\\x1a\\nfake\"\n+    resp.raise_for_status = MagicMock()\n+    return resp\n+\n+\n+class TestAuthHeaders:\n+    \"\"\"Unit tests for _auth_headers() helper.\"\"\"\n+\n+    def test_empty_when_no_key(self, monkeypatch):\n+        monkeypatch.delenv(\"CAMOFOX_API_KEY\", raising=False)\n+        assert _auth_headers() == {}\n+\n+    def test_bearer_when_key_set(self, monkeypatch):\n+        monkeypatch.setenv(\"CAMOFOX_API_KEY\", \"test-secret-123\")\n+        assert _auth_headers() == {\"Authorization\": \"Bearer test-secret-123\"}\n+\n+    def test_empty_when_key_blank(self, monkeypatch):\n+        monkeypatch.setenv(\"CAMOFOX_API_KEY\", \"   \")\n+        assert _auth_headers() == {}\n+\n+\n+class TestAuthHeadersSent:\n+    \"\"\"Verify all HTTP call sites include auth headers when CAMOFOX_API_KEY is set.\"\"\"\n+\n+    @pytest.fixture(autouse=True)\n+    def _set_key(self, monkeypatch):\n+        monkeypatch.setenv(\"CAMOFOX_URL\", \"http://localhost:9377\")\n+        monkeypatch.setenv(\"CAMOFOX_API_KEY\", \"my-api-key\")\n+\n+    @patch(\"tools.browser_camofox.requests.post\")\n+    def test_ensure_tab_sends_auth(self, mock_post):\n+        mock_post.return_value = _mock_response(json_data={\"tabId\": \"t1\"})\n+        camofox_navigate(\"https://example.com\", task_id=\"auth_test_1\")\n+        _, kwargs = mock_post.call_args\n+        assert kwargs[\"headers\"] == {\"Authorization\": \"Bearer my-api-key\"}\n+\n+    @patch(\"tools.browser_camofox.requests.post\")\n+    def test_post_sends_auth(self, mock_post):\n+        mock_post.return_value = _mock_response(json_data={\"tabId\": \"t2\"})\n+        camofox_navigate(\"https://example.com\", task_id=\"auth_test_2\")\n+        mock_post.return_value = _mock_response(json_data={\"ok\": True, \"url\": \"https://x.com\"})\n+        camofox_navigate(\"https://x.com\", task_id=\"auth_test_2\")\n+        # The second call is a POST to /tabs/{tabId}/navigate\n+        last_call = mock_post.call_args_list[-1]\n+        assert last_call.kwargs.get(\"headers\") == {\"Authorization\": \"Bearer my-api-key\"}\n+\n+    @patch(\"tools.browser_camofox.requests.post\")\n+    @patch(\"tools.browser_camofox.requests.get\")\n+    def test_get_sends_auth(self, mock_get, mock_post):\n+        mock_post.return_value = _mock_response(json_data={\"tabId\": \"t3\"})\n+        camofox_navigate(\"https://example.com\", task_id=\"auth_test_3\")\n+        mock_get.return_value = _mock_response(json_data={\n+            \"snapshot\": '- heading \"Hello\"',\n+            \"refsCount\": 1,\n+        })\n+        camofox_snapshot(task_id=\"auth_test_3\")\n+        _, kwargs = mock_get.call_args\n+        assert kwargs[\"headers\"] == {\"Authorization\": \"Bearer my-api-key\"}\n+\n+    @patch(\"tools.browser_camofox.requests.post\")\n+    @patch(\"tools.browser_camofox.requests.delete\")\n+    def test_delete_sends_auth(self, mock_delete, mock_post):\n+        mock_post.return_value = _mock_response(json_data={\"tabId\": \"t4\"})\n+        camofox_navigate(\"https://example.com\", task_id=\"auth_test_4\")\n+        mock_delete.return_value = _mock_response(json_data={\"ok\": True})\n+        camofox_close(task_id=\"auth_test_4\")\n+        _, kwargs = mock_delete.call_args\n+        assert kwargs[\"headers\"] == {\"Authorization\": \"Bearer my-api-key\"}\n+\n+\n+class TestNoAuthHeadersWhenKeyUnset:\n+    \"\"\"Verify HTTP calls send empty headers when CAMOFOX_API_KEY is not set.\"\"\"\n+\n+    @pytest.fixture(autouse=True)\n+    def _unset_key(self, monkeypatch):\n+        monkeypatch.setenv(\"CAMOFOX_URL\", \"http://localhost:9377\")\n+        monkeypatch.delenv(\"CAMOFOX_API_KEY\", raising=False)\n+\n+    @patch(\"tools.browser_camofox.requests.post\")\n+    def test_no_auth_on_tab_creation(self, mock_post):\n+        mock_post.return_value = _mock_response(json_data={\"tabId\": \"t5\"})\n+        camofox_navigate(\"https://example.com\", task_id=\"noauth_test_1\")\n+        _, kwargs = mock_post.call_args\n+        assert kwargs.get(\"headers\") == {}"]},{"file":"tests/tools/test_browser_camofox_ensure_tab.py","hunks":["@@ -0,0 +1,66 @@\n+\"\"\"Regression test: _ensure_tab must send ``listItemId`` (not ``sessionKey``).\n+\n+The Camoufox REST API server requires ``listItemId`` in the ``POST /tabs``\n+body.  A previous version sent ``sessionKey`` which caused a 400 Bad Request\n+on every ``browser_navigate`` call.  See issue #37960.\n+\"\"\"\n+\n+from unittest.mock import patch, MagicMock\n+\n+\n+def test_ensure_tab_sends_list_item_id():\n+    \"\"\"POST /tabs body must contain ``listItemId``, not ``sessionKey``.\"\"\"\n+    # Import the module under test\n+    from tools import browser_camofox as mod\n+\n+    fake_session = {\n+        \"user_id\": \"hermes_test123\",\n+        \"tab_id\": None,\n+        \"session_key\": \"task_my-session\",\n+        \"managed\": False,\n+        \"adopt_existing_tab\": False,\n+    }\n+\n+    mock_response = MagicMock()\n+    mock_response.json.return_value = {\"tabId\": \"tab-42\"}\n+    mock_response.raise_for_status = MagicMock()\n+\n+    with patch.object(mod, \"_get_session\", return_value=fake_session), \\\n+         patch.object(mod, \"get_camofox_url\", return_value=\"http://localhost:9377\"), \\\n+         patch(\"tools.browser_camofox.requests.post\", return_value=mock_response) as mock_post:\n+        result = mod._ensure_tab(\"test-task\", url=\"https://example.com\")\n+\n+    # Verify the POST was called\n+    mock_post.assert_called_once()\n+    call_kwargs = mock_post.call_args\n+    body = call_kwargs.kwargs.get(\"json\") or call_kwargs[1].get(\"json\")\n+\n+    # Core assertion: listItemId present, sessionKey absent\n+    assert \"listItemId\" in body, f\"Expected 'listItemId' in POST body, got: {body}\"\n+    assert \"sessionKey\" not in body, f\"'sessionKey' should not be in POST body: {body}\"\n+    assert body[\"listItemId\"] == \"task_my-session\"\n+    assert body[\"userId\"] == \"hermes_test123\"\n+    assert body[\"url\"] == \"https://example.com\"\n+\n+    # Verify tab_id was set from response\n+    assert result[\"tab_id\"] == \"tab-42\"\n+\n+\n+def test_ensure_tab_skips_creation_when_tab_exists():\n+    \"\"\"If session already has a tab_id, no POST should be made.\"\"\"\n+    from tools import browser_camofox as mod\n+\n+    fake_session = {\n+        \"user_id\": \"hermes_test123\",\n+        \"tab_id\": \"existing-tab\",\n+        \"session_key\": \"task_my-session\",\n+        \"managed\": False,\n+    }\n+\n+    with patch.object(mod, \"_get_session\", return_value=fake_session), \\\n+         patch(\"tools.browser_camofox.requests.post\") as mock_post:\n+        result = mod._ensure_tab(\"test-task\")\n+\n+    # No POST should be made — tab already exists\n+    mock_post.assert_not_called()\n+    assert result[\"tab_id\"] == \"existing-tab\""]},{"file":"tests/tools/test_browser_camofox_persistence.py","hunks":["@@ -151,7 +151,7 @@ def test_navigate_uses_stable_identity(self, tmp_path, monkeypatch):\n \n         requests_seen = []\n \n-        def _capture_post(url, json=None, timeout=None):\n+        def _capture_post(url, json=None, timeout=None, headers=None):\n             requests_seen.append(json)\n             return _mock_response(\n                 json_data={\"tabId\": \"tab-1\", \"url\": \"https://example.com\"}","@@ -171,7 +171,7 @@ def test_navigate_reuses_identity_after_close(self, tmp_path, monkeypatch):\n \n         requests_seen = []\n \n-        def _capture_post(url, json=None, timeout=None):\n+        def _capture_post(url, json=None, timeout=None, headers=None):\n             requests_seen.append(json)\n             return _mock_response(\n                 json_data={\"tabId\": f\"tab-{len(requests_seen)}\", \"url\": \"https://example.com\"}"]},{"file":"tests/tools/test_browser_camofox_timeout.py","hunks":["@@ -0,0 +1,69 @@\n+\"\"\"Tests for browser_camofox._get_command_timeout — config-driven timeout.\"\"\"\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n+class TestCamofoxCommandTimeout:\n+    \"\"\"Verify that the Camofox HTTP backend reads browser.command_timeout.\"\"\"\n+\n+    def test_default_is_30(self):\n+        \"\"\"When config has no browser.command_timeout, default to 30s.\"\"\"\n+        from tools.browser_camofox import _get_command_timeout\n+\n+        # Clear cache\n+        import tools.browser_camofox as mod\n+        mod._cmd_timeout_resolved = False\n+        mod._cached_cmd_timeout = None\n+\n+        with patch(\"tools.browser_camofox.read_raw_config\", return_value={}):\n+            assert _get_command_timeout() == 30\n+\n+    def test_reads_from_config(self):\n+        \"\"\"Read browser.command_timeout from config.yaml.\"\"\"\n+        from tools.browser_camofox import _get_command_timeout\n+\n+        import tools.browser_camofox as mod\n+        mod._cmd_timeout_resolved = False\n+        mod._cached_cmd_timeout = None\n+\n+        cfg = {\"browser\": {\"command_timeout\": 90}}\n+        with patch(\"tools.browser_camofox.read_raw_config\", return_value=cfg):\n+            assert _get_command_timeout() == 90\n+\n+    def test_floor_at_5s(self):\n+        \"\"\"Config values below 5 are clamped to 5.\"\"\"\n+        from tools.browser_camofox import _get_command_timeout\n+\n+        import tools.browser_camofox as mod\n+        mod._cmd_timeout_resolved = False\n+        mod._cached_cmd_timeout = None\n+\n+        cfg = {\"browser\": {\"command_timeout\": 1}}\n+        with patch(\"tools.browser_camofox.read_raw_config\", return_value=cfg):\n+            assert _get_command_timeout() == 5\n+\n+    def test_cached_after_first_call(self):\n+        \"\"\"Config is read only once; subsequent calls use cached value.\"\"\"\n+        from tools.browser_camofox import _get_command_timeout\n+\n+        import tools.browser_camofox as mod\n+        mod._cmd_timeout_resolved = False\n+        mod._cached_cmd_timeout = None\n+\n+        mock_read = MagicMock(return_value={\"browser\": {\"command_timeout\": 45}})\n+        with patch(\"tools.browser_camofox.read_raw_config\", mock_read):\n+            _get_command_timeout()\n+            _get_command_timeout()\n+        mock_read.assert_called_once()\n+\n+    def test_config_read_error_falls_back(self):\n+        \"\"\"If config read raises, fall back to 30s.\"\"\"\n+        from tools.browser_camofox import _get_command_timeout\n+\n+        import tools.browser_camofox as mod\n+        mod._cmd_timeout_resolved = False\n+        mod._cached_cmd_timeout = None\n+\n+        with patch(\"tools.browser_camofox.read_raw_config\", side_effect=Exception(\"no config\")):\n+            assert _get_command_timeout() == 30"]},{"file":"tools/browser_camofox.py","hunks":["@@ -36,7 +36,7 @@\n \n import requests\n \n-from hermes_cli.config import cfg_get, load_config\n+from hermes_cli.config import cfg_get, load_config, read_raw_config\n from tools.browser_camofox_state import get_camofox_identity\n from tools.registry import tool_error\n ","@@ -46,11 +46,47 @@\n # Configuration\n # ---------------------------------------------------------------------------\n \n-_DEFAULT_TIMEOUT = 30  # seconds per HTTP request\n+_DEFAULT_TIMEOUT = 30  # fallback when config is unreadable\n _SNAPSHOT_MAX_CHARS = 80_000  # camofox paginates at this limit\n _vnc_url: Optional[str] = None  # cached from /health response\n _vnc_url_checked = False  # only probe once per process\n \n+# Cached command timeout from config (resolved lazily, like browser_tool)\n+_cached_cmd_timeout: Optional[int] = None\n+_cmd_timeout_resolved = False\n+\n+\n+def _get_command_timeout() -> int:\n+    \"\"\"Return ``browser.command_timeout`` from config, falling back to 30s.\n+\n+    Mirrors :func:`tools.browser_tool._get_command_timeout` so both the\n+    local browser path and the Camofox path honour the same config knob.\n+    Result is cached after the first call.\n+    \"\"\"\n+    global _cached_cmd_timeout, _cmd_timeout_resolved\n+    if _cmd_timeout_resolved:\n+        return _cached_cmd_timeout  # type: ignore[return-value]\n+\n+    _cmd_timeout_resolved = True\n+    result = _DEFAULT_TIMEOUT\n+    try:\n+        cfg = read_raw_config()\n+        val = cfg_get(cfg, \"browser\", \"command_timeout\")\n+        if val is not None:\n+            result = max(int(val), 5)  # floor at 5s\n+    except Exception as exc:\n+        logger.debug(\"Could not read browser.command_timeout: %s\", exc)\n+    _cached_cmd_timeout = result\n+    return result\n+\n+\n+def _auth_headers() -> Dict[str, str]:\n+    \"\"\"Return Authorization header when CAMOFOX_API_KEY is set.\"\"\"\n+    key = os.getenv(\"CAMOFOX_API_KEY\", \"\").strip()\n+    if key:\n+        return {\"Authorization\": f\"Bearer {key}\"}\n+    return {}\n+\n \n def get_camofox_url() -> str:\n     \"\"\"Return the configured Camofox server URL, or empty string.\"\"\"","@@ -345,10 +381,11 @@ def _ensure_tab(task_id: Optional[str], url: str = \"about:blank\") -> Dict[str, A\n         f\"{base}/tabs\",\n         json={\n             \"userId\": session[\"user_id\"],\n-            \"sessionKey\": session[\"session_key\"],\n+            \"listItemId\": session[\"session_key\"],\n             \"url\": url,\n         },\n-        timeout=_DEFAULT_TIMEOUT,\n+        timeout=_get_command_timeout(),\n+        headers=_auth_headers(),\n     )\n     resp.raise_for_status()\n     data = resp.json()","@@ -384,34 +421,42 @@ def camofox_soft_cleanup(task_id: Optional[str] = None) -> bool:\n # HTTP helpers\n # ---------------------------------------------------------------------------\n \n-def _post(path: str, body: dict, timeout: int = _DEFAULT_TIMEOUT) -> dict:\n+def _post(path: str, body: dict, timeout: Optional[int] = None) -> dict:\n     \"\"\"POST JSON to camofox and return parsed response.\"\"\"\n+    if timeout is None:\n+        timeout = _get_command_timeout()\n     url = f\"{get_camofox_url()}{path}\"\n-    resp = requests.post(url, json=body, timeout=timeout)\n+    resp = requests.post(url, json=body, timeout=timeout, headers=_auth_headers())\n     resp.raise_for_status()\n     return resp.json()\n \n \n-def _get(path: str, params: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> dict:\n+def _get(path: str, params: dict = None, timeout: Optional[int] = None) -> dict:\n     \"\"\"GET from camofox and return parsed response.\"\"\"\n+    if timeout is None:\n+        timeout = _get_command_timeout()\n     url = f\"{get_camofox_url()}{path}\"\n-    resp = requests.get(url, params=params, timeout=timeout)\n+    resp = requests.get(url, params=params, timeout=timeout, headers=_auth_headers())\n     resp.raise_for_status()\n     return resp.json()\n \n \n-def _get_raw(path: str, params: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> requests.Response:\n+def _get_raw(path: str, params: dict = None, timeout: Optional[int] = None) -> requests.Response:\n     \"\"\"GET from camofox and return raw response (for binary data).\"\"\"\n+    if timeout is None:\n+        timeout = _get_command_timeout()\n     url = f\"{get_camofox_url()}{path}\"\n-    resp = requests.get(url, params=params, timeout=timeout)\n+    resp = requests.get(url, params=params, timeout=timeout, headers=_auth_headers())\n     resp.raise_for_status()\n     return resp\n \n \n-def _delete(path: str, body: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> dict:\n+def _delete(path: str, body: dict = None, timeout: Optional[int] = None) -> dict:\n     \"\"\"DELETE to camofox and return parsed response.\"\"\"\n+    if timeout is None:\n+        timeout = _get_command_timeout()\n     url = f\"{get_camofox_url()}{path}\"\n-    resp = requests.delete(url, json=body, timeout=timeout)\n+    resp = requests.delete(url, json=body, timeout=timeout, headers=_auth_headers())\n     resp.raise_for_status()\n     return resp.json()\n ","@@ -430,12 +475,25 @@ def camofox_navigate(url: str, task_id: Optional[str] = None) -> str:\n             session = _ensure_tab(task_id, browser_url)\n             data = {\"ok\": True, \"url\": browser_url}\n         else:\n-            # Navigate existing tab\n-            data = _post(\n-                f\"/tabs/{session['tab_id']}/navigate\",\n-                {\"userId\": session[\"user_id\"], \"url\": browser_url},\n-                timeout=60,\n-            )\n+            # Navigate existing tab — recover from stale tab 404\n+            try:\n+                data = _post(\n+                    f\"/tabs/{session['tab_id']}/navigate\",\n+                    {\"userId\": session[\"user_id\"], \"url\": browser_url},\n+                    timeout=60,\n+                )\n+            except requests.HTTPError as e:\n+                if e.response is not None and e.response.status_code == 404:\n+                    logger.warning(\n+                        \"Camofox tab %s returned 404 — tab was garbage collected. \"\n+                        \"Creating a fresh tab.\",\n+                        session[\"tab_id\"],\n+                    )\n+                    session[\"tab_id\"] = None\n+                    session = _ensure_tab(task_id, browser_url)\n+                    data = {\"ok\": True, \"url\": browser_url}\n+                else:\n+                    raise\n         result = {\n             \"success\": True,\n             \"url\": data.get(\"url\", browser_url),\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}