{"repo":"NousResearch/hermes-agent","public_id":"36111","display_label":"Gateway resource/FD leaks during reconnect and disconnect","summary":"Multiple fixes targeting file descriptor and SQLite connection leaks in the gateway's ResponseStore and adapter resources, particularly during disconnect and unsuccessful reconnect scenarios. One related change adds a requirement for API_SERVER_KEY when loading the api_server platform.","subsystem":"gateway","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":3,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":56260},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-23T18:54:06+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.2857142857142857},"fix_edges":[{"issue_number":36111,"pr_number":56260,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":36111,"pr_number":69838,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":36111,"pr_number":70166,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":36111,"title":"File descriptor leak in api_server platform: ResponseStore SQLite connections not closed on retry","url":null,"state":"closed"},{"kind":"pr","number":36180,"title":"fix(gateway): require API_SERVER_KEY to load the api_server platform","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#70166","number":70166},{"kind":"pr","value":"#36111","number":36111}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":36242,"title":"fix(gateway): close api server response store leak","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4b06c98fe4","number":0},{"kind":"pr","value":"#56260","number":56260},{"kind":"pr","value":"#69838","number":69838},{"kind":"pr","value":"#36180","number":36180},{"kind":"pr","value":"#36111","number":36111}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":37389,"title":"fix: ResponseStore SQLite connection pooling to prevent FD leak (#36111, #37369)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4b06c98fe4f4686f89873933ac616cb6e76b298d","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":56260,"title":"fix(api-server): require auth for /health/detailed and fail closed on weak keys","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/api_server.py","tests/gateway/test_api_server.py","tests/gateway/test_api_server_bind_guard.py"],"fix_hunks":[{"file":"gateway/platforms/api_server.py","hunks":["@@ -1165,8 +1165,12 @@ async def _handle_health_detailed(self, request: \"web.Request\") -> \"web.Response\n \n         Returns gateway state, connected platforms, PID, and uptime so the\n         dashboard can display full status without needing a shared PID file or\n-        /proc access.  No authentication required.\n+        /proc access.  Requires the same Bearer auth as other API routes.\n         \"\"\"\n+        auth_err = self._check_auth(request)\n+        if auth_err:\n+            return auth_err\n+\n         from gateway.status import (\n             derive_gateway_busy,\n             derive_gateway_drainable,","@@ -4454,12 +4458,60 @@ async def _sweep_orphaned_runs(self) -> None:\n     # BasePlatformAdapter interface\n     # ------------------------------------------------------------------\n \n+    def _api_key_passes_startup_guard(self) -> bool:\n+        \"\"\"Return True when API_SERVER_KEY is present and strong enough to start.\"\"\"\n+        if not self._api_key:\n+            logger.error(\n+                \"[%s] Refusing to start: API_SERVER_KEY is required for the API server, \"\n+                \"including loopback-only binds on %s.\",\n+                self.name, self._host,\n+            )\n+            return False\n+\n+        try:\n+            from hermes_cli.auth import has_usable_secret\n+            if not has_usable_secret(self._api_key, min_length=16):\n+                logger.error(\n+                    \"[%s] Refusing to start: API_SERVER_KEY is a \"\n+                    \"placeholder or too short (<16 chars). This endpoint \"\n+                    \"dispatches terminal-capable agent work — a guessable \"\n+                    \"key is remote code execution. Generate a strong secret \"\n+                    \"(e.g. `openssl rand -hex 32`) and set API_SERVER_KEY \"\n+                    \"before starting the API server on %s.\",\n+                    self.name, self._host,\n+                )\n+                return False\n+        except ImportError:\n+            pass\n+        return True\n+\n+    def _port_is_available(self) -> bool:\n+        \"\"\"Return True when the configured listen port is free.\"\"\"\n+        try:\n+            with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s:\n+                _s.settimeout(1)\n+                _s.connect(('127.0.0.1', self._port))\n+            logger.error(\n+                \"[%s] Port %d already in use. Set a different port in config.yaml: \"\n+                \"platforms.api_server.port\",\n+                self.name, self._port,\n+            )\n+            return False\n+        except (ConnectionRefusedError, OSError):\n+            return True\n+\n     async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Start the aiohttp web server.\"\"\"\n         if not AIOHTTP_AVAILABLE:\n             logger.warning(\"[%s] aiohttp not installed\", self.name)\n             return False\n \n+        if not self._api_key_passes_startup_guard():\n+            return False\n+\n+        if not self._port_is_available():\n+            return False\n+\n         try:\n             mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]\n             self._app = web.Application(middlewares=mws, client_max_size=MAX_REQUEST_BYTES)","@@ -4520,39 +4572,6 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:\n             if hasattr(sweep_task, \"add_done_callback\"):\n                 sweep_task.add_done_callback(self._background_tasks.discard)\n \n-            # Refuse to start without authentication. The API server can\n-            # dispatch terminal-capable agent work, so every deployment needs\n-            # an explicit API_SERVER_KEY regardless of bind address.\n-            if not self._api_key:\n-                logger.error(\n-                    \"[%s] Refusing to start: API_SERVER_KEY is required for the API server, \"\n-                    \"including loopback-only binds on %s.\",\n-                    self.name, self._host,\n-                )\n-                return False\n-\n-            # Refuse to start network-accessible with a placeholder or weak key.\n-            # Ported from openclaw/openclaw#64586; entropy floor raised to 16 in\n-            # the June 2026 hermes-0day hardening (an 8-char key dispatching\n-            # terminal-capable agent work on a public bind is brute-forceable).\n-            if is_network_accessible(self._host) and self._api_key:\n-                try:\n-                    from hermes_cli.auth import has_usable_secret\n-                    if not has_usable_secret(self._api_key, min_length=16):\n-                        logger.error(\n-                            \"[%s] Refusing to start: API_SERVER_KEY is a \"\n-                            \"placeholder or too short (<16 chars) for a \"\n-                            \"network-accessible bind. This endpoint dispatches \"\n-                            \"terminal-capable agent work — a guessable key is \"\n-                            \"remote code execution. Generate a strong secret \"\n-                            \"(e.g. `openssl rand -hex 32`) and set \"\n-                            \"API_SERVER_KEY before exposing it on %s.\",\n-                            self.name, self._host,\n-                        )\n-                        return False\n-                except ImportError:\n-                    pass\n-\n             # Loud warning when a network-accessible API server runs against an\n             # unsandboxed local terminal backend. The API server can drive the\n             # agent's terminal/file tools as the host user; on a public bind","@@ -4581,16 +4600,6 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:\n                         self.name, self._host,\n                     )\n \n-            # Port conflict detection — fail fast if port is already in use\n-            try:\n-                with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s:\n-                    _s.settimeout(1)\n-                    _s.connect(('127.0.0.1', self._port))\n-                logger.error('[%s] Port %d already in use. Set a different port in config.yaml: platforms.api_server.port', self.name, self._port)\n-                return False\n-            except (ConnectionRefusedError, OSError):\n-                pass  # port is free\n-\n             self._runner = web.AppRunner(self._app)\n             await self._runner.setup()\n             self._site = web.TCPSite(self._runner, self._host, self._port)"]},{"file":"tests/gateway/test_api_server.py","hunks":["@@ -772,12 +772,21 @@ async def test_health_detailed_no_runtime_status(self, adapter):\n                 assert data[\"gateway_drainable\"] is False\n \n     @pytest.mark.asyncio\n-    async def test_health_detailed_does_not_require_auth(self, auth_adapter):\n-        \"\"\"Health detailed endpoint should be accessible without auth, like /health.\"\"\"\n+    async def test_health_detailed_requires_auth(self, auth_adapter):\n+        \"\"\"Detailed health must not leak runtime state without Bearer auth.\"\"\"\n         app = _create_app(auth_adapter)\n         with patch(\"gateway.status.read_runtime_status\", return_value=None):\n             async with TestClient(TestServer(app)) as cli:\n                 resp = await cli.get(\"/health/detailed\")\n+                assert resp.status == 401\n+\n+    @pytest.mark.asyncio\n+    async def test_health_detailed_allows_authenticated_request(self, auth_adapter):\n+        app = _create_app(auth_adapter)\n+        headers = {\"Authorization\": f\"Bearer {auth_adapter._api_key}\"}\n+        with patch(\"gateway.status.read_runtime_status\", return_value={\"gateway_state\": \"running\"}):\n+            async with TestClient(TestServer(app)) as cli:\n+                resp = await cli.get(\"/health/detailed\", headers=headers)\n                 assert resp.status == 200\n \n "]},{"file":"tests/gateway/test_api_server_bind_guard.py","hunks":["@@ -119,6 +119,19 @@ async def test_refuses_loopback_without_key(self):\n         assert is_network_accessible(adapter._host) is False\n         result = await adapter.connect()\n         assert result is False\n+        assert adapter._app is None\n+        assert adapter._background_tasks == set()\n+\n+    @pytest.mark.asyncio\n+    async def test_refuses_weak_key_without_partial_startup(self):\n+        \"\"\"Weak API_SERVER_KEY rejection must not create app or background tasks.\"\"\"\n+        adapter = APIServerAdapter(\n+            PlatformConfig(enabled=True, extra={\"host\": \"127.0.0.1\", \"key\": \"short\"}),\n+        )\n+        result = await adapter.connect()\n+        assert result is False\n+        assert adapter._app is None\n+        assert adapter._background_tasks == set()\n \n     @pytest.mark.asyncio\n     async def test_allows_wildcard_with_key(self):\n"]}]},{"kind":"pr","number":69838,"title":"fix(api_server): fail closed when API_SERVER_KEY strength can't be verified","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/api_server.py","tests/gateway/test_api_server.py"]},{"kind":"pr","number":70166,"title":"fix(gateway): require usable API_SERVER_KEY to enroll the api_server platform (#36180 salvage)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["contributors/emails/[redacted:email]","gateway/config.py","tests/gateway/test_api_server.py","tests/gateway/test_platform_connected_checkers.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}