{"repo":"NousResearch/hermes-agent","public_id":"9438","display_label":"ACP authenticate() method_id validation against provider","summary":"These issues and PRs concern a bug where ACP's authenticate() function accepts method_id values not advertised by the configured provider, along with fixes and refactoring to add proper validation.","subsystem":"acp","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":13468},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-21T10:39:55+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.43600000000000005},"fix_edges":[{"issue_number":9438,"pr_number":13468,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":13452,"pr_number":13468,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":9438,"title":"ACP authenticate() accepts any method_id when any provider is configured","url":null,"state":"closed"},{"kind":"issue","number":13452,"title":"ACP authenticate() accepts unadvertised method_id values","url":null,"state":"closed"},{"kind":"pr","number":9470,"title":"fix(acp): validate method_id in authenticate against advertised provider (#9438)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c6974043eff246274a8079466a3a4d22ab13ee6c","number":0},{"kind":"pr","value":"#13468","number":13468},{"kind":"pr","value":"#13468","number":13468}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":13468,"title":"refactor(acp): validate method_id against advertised provider in authenticate()","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["acp_adapter/server.py","hermes_cli/models.py","tests/acp/test_server.py","tests/hermes_cli/test_models.py"],"fix_hunks":[{"file":"acp_adapter/server.py","hunks":["@@ -51,7 +51,7 @@\n except ImportError:\n     from acp.schema import AuthMethod as AuthMethodAgent  # type: ignore[attr-defined]\n \n-from acp_adapter.auth import detect_provider, has_provider\n+from acp_adapter.auth import detect_provider\n from acp_adapter.events import (\n     make_message_cb,\n     make_step_cb,","@@ -351,9 +351,18 @@ async def initialize(\n         )\n \n     async def authenticate(self, method_id: str, **kwargs: Any) -> AuthenticateResponse | None:\n-        if has_provider():\n-            return AuthenticateResponse()\n-        return None\n+        # Only accept authenticate() calls whose method_id matches the\n+        # provider we advertised in initialize(). Without this check,\n+        # authenticate() would acknowledge any method_id as long as the\n+        # server has provider credentials configured — harmless under\n+        # Hermes' threat model (ACP is stdio-only, local-trust), but poor\n+        # API hygiene and confusing if ACP ever grows multi-method auth.\n+        provider = detect_provider()\n+        if not provider:\n+            return None\n+        if not isinstance(method_id, str) or method_id.strip().lower() != provider:\n+            return None\n+        return AuthenticateResponse()\n \n     # ---- Session management -------------------------------------------------\n "]},{"file":"hermes_cli/models.py","hunks":["@@ -670,6 +670,31 @@ def _openrouter_model_is_free(pricing: Any) -> bool:\n         return False\n \n \n+def _openrouter_model_supports_tools(item: Any) -> bool:\n+    \"\"\"Return True when the model's ``supported_parameters`` advertise tool calling.\n+\n+    hermes-agent is tool-calling-first — every provider path assumes the model\n+    can invoke tools. Models that don't advertise ``tools`` in their\n+    ``supported_parameters`` (e.g. image-only or completion-only models) cannot\n+    be driven by the agent loop and would fail at the first tool call.\n+\n+    **Permissive when the field is missing.** Some OpenRouter-compatible gateways\n+    (Nous Portal, private mirrors, older catalog snapshots) don't populate\n+    ``supported_parameters`` at all. Treat that as \"unknown capability → allow\"\n+    so the picker doesn't silently empty for those users. Only hide models\n+    whose ``supported_parameters`` is an explicit list that omits ``tools``.\n+\n+    Ported from Kilo-Org/kilocode#9068.\n+    \"\"\"\n+    if not isinstance(item, dict):\n+        return True\n+    params = item.get(\"supported_parameters\")\n+    if not isinstance(params, list):\n+        # Field absent / malformed / None — be permissive.\n+        return True\n+    return \"tools\" in params\n+\n+\n def fetch_openrouter_models(\n     timeout: float = 8.0,\n     *,","@@ -712,6 +737,11 @@ def fetch_openrouter_models(\n         live_item = live_by_id.get(preferred_id)\n         if live_item is None:\n             continue\n+        # Hide models that don't advertise tool-calling support — hermes-agent\n+        # requires it and surfacing them leads to immediate runtime failures\n+        # when the user selects them. Ported from Kilo-Org/kilocode#9068.\n+        if not _openrouter_model_supports_tools(live_item):\n+            continue\n         desc = \"free\" if _openrouter_model_is_free(live_item.get(\"pricing\")) else \"\"\n         curated.append((preferred_id, desc))\n "]},{"file":"tests/acp/test_server.py","hunks":["@@ -95,19 +95,37 @@ async def test_initialize_capabilities_wire_format(self, agent):\n \n class TestAuthenticate:\n     @pytest.mark.asyncio\n-    async def test_authenticate_with_provider_configured(self, agent, monkeypatch):\n+    async def test_authenticate_with_matching_method_id(self, agent, monkeypatch):\n         monkeypatch.setattr(\n-            \"acp_adapter.server.has_provider\",\n-            lambda: True,\n+            \"acp_adapter.server.detect_provider\",\n+            lambda: \"openrouter\",\n         )\n         resp = await agent.authenticate(method_id=\"openrouter\")\n         assert isinstance(resp, AuthenticateResponse)\n \n+    @pytest.mark.asyncio\n+    async def test_authenticate_is_case_insensitive(self, agent, monkeypatch):\n+        monkeypatch.setattr(\n+            \"acp_adapter.server.detect_provider\",\n+            lambda: \"openrouter\",\n+        )\n+        resp = await agent.authenticate(method_id=\"OpenRouter\")\n+        assert isinstance(resp, AuthenticateResponse)\n+\n+    @pytest.mark.asyncio\n+    async def test_authenticate_rejects_mismatched_method_id(self, agent, monkeypatch):\n+        monkeypatch.setattr(\n+            \"acp_adapter.server.detect_provider\",\n+            lambda: \"openrouter\",\n+        )\n+        resp = await agent.authenticate(method_id=\"totally-invalid-method\")\n+        assert resp is None\n+\n     @pytest.mark.asyncio\n     async def test_authenticate_without_provider(self, agent, monkeypatch):\n         monkeypatch.setattr(\n-            \"acp_adapter.server.has_provider\",\n-            lambda: False,\n+            \"acp_adapter.server.detect_provider\",\n+            lambda: None,\n         )\n         resp = await agent.authenticate(method_id=\"openrouter\")\n         assert resp is None"]},{"file":"tests/hermes_cli/test_models.py","hunks":["@@ -88,6 +88,131 @@ def test_falls_back_to_static_snapshot_on_fetch_failure(self, monkeypatch):\n \n         assert models == OPENROUTER_MODELS\n \n+    def test_filters_out_models_without_tool_support(self, monkeypatch):\n+        \"\"\"Models whose supported_parameters omits 'tools' must not appear in the picker.\n+\n+        hermes-agent is tool-calling-first — surfacing a non-tool model leads to\n+        immediate runtime failures when the user selects it. Ported from\n+        Kilo-Org/kilocode#9068.\n+        \"\"\"\n+        class _Resp:\n+            def __enter__(self):\n+                return self\n+\n+            def __exit__(self, exc_type, exc, tb):\n+                return False\n+\n+            def read(self):\n+                # opus-4.6 advertises tools → kept\n+                # nano-image has explicit supported_parameters that OMITS tools → dropped\n+                # qwen3.6-plus advertises tools → kept\n+                return (\n+                    b'{\"data\":['\n+                    b'{\"id\":\"anthropic/claude-opus-4.6\",\"pricing\":{\"prompt\":\"0.000015\",\"completion\":\"0.000075\"},'\n+                    b'\"supported_parameters\":[\"temperature\",\"tools\",\"tool_choice\"]},'\n+                    b'{\"id\":\"google/gemini-3-pro-image-preview\",\"pricing\":{\"prompt\":\"0.00001\",\"completion\":\"0.00003\"},'\n+                    b'\"supported_parameters\":[\"temperature\",\"response_format\"]},'\n+                    b'{\"id\":\"qwen/qwen3.6-plus\",\"pricing\":{\"prompt\":\"0.000000325\",\"completion\":\"0.00000195\"},'\n+                    b'\"supported_parameters\":[\"tools\",\"temperature\"]}'\n+                    b']}'\n+                )\n+\n+        # Include the image-only id in the curated list so it has a chance to be surfaced.\n+        monkeypatch.setattr(\n+            _models_mod,\n+            \"OPENROUTER_MODELS\",\n+            [\n+                (\"anthropic/claude-opus-4.6\", \"\"),\n+                (\"google/gemini-3-pro-image-preview\", \"\"),\n+                (\"qwen/qwen3.6-plus\", \"\"),\n+            ],\n+        )\n+        monkeypatch.setattr(_models_mod, \"_openrouter_catalog_cache\", None)\n+        with patch(\"hermes_cli.models.urllib.request.urlopen\", return_value=_Resp()):\n+            models = fetch_openrouter_models(force_refresh=True)\n+\n+        ids = [mid for mid, _ in models]\n+        assert \"anthropic/claude-opus-4.6\" in ids\n+        assert \"qwen/qwen3.6-plus\" in ids\n+        # Image-only model advertised supported_parameters WITHOUT tools → must be dropped.\n+        assert \"google/gemini-3-pro-image-preview\" not in ids\n+\n+    def test_permissive_when_supported_parameters_missing(self, monkeypatch):\n+        \"\"\"Models missing the supported_parameters field keep appearing in the picker.\n+\n+        Some OpenRouter-compatible gateways (Nous Portal, private mirrors, older\n+        catalog snapshots) don't populate supported_parameters. Treating missing\n+        as 'unknown → allow' prevents the picker from silently emptying on\n+        those gateways.\n+        \"\"\"\n+        class _Resp:\n+            def __enter__(self):\n+                return self\n+\n+            def __exit__(self, exc_type, exc, tb):\n+                return False\n+\n+            def read(self):\n+                # No supported_parameters field at all on either entry.\n+                return (\n+                    b'{\"data\":['\n+                    b'{\"id\":\"anthropic/claude-opus-4.6\",\"pricing\":{\"prompt\":\"0.000015\",\"completion\":\"0.000075\"}},'\n+                    b'{\"id\":\"qwen/qwen3.6-plus\",\"pricing\":{\"prompt\":\"0.000000325\",\"completion\":\"0.00000195\"}}'\n+                    b']}'\n+                )\n+\n+        monkeypatch.setattr(_models_mod, \"_openrouter_catalog_cache\", None)\n+        with patch(\"hermes_cli.models.urllib.request.urlopen\", return_value=_Resp()):\n+            models = fetch_openrouter_models(force_refresh=True)\n+\n+        ids = [mid for mid, _ in models]\n+        assert \"anthropic/claude-opus-4.6\" in ids\n+        assert \"qwen/qwen3.6-plus\" in ids\n+\n+\n+class TestOpenRouterToolSupportHelper:\n+    \"\"\"Unit tests for _openrouter_model_supports_tools (Kilo port #9068).\"\"\"\n+\n+    def test_tools_in_supported_parameters(self):\n+        from hermes_cli.models import _openrouter_model_supports_tools\n+        assert _openrouter_model_supports_tools(\n+            {\"id\": \"x\", \"supported_parameters\": [\"temperature\", \"tools\"]}\n+        ) is True\n+\n+    def test_tools_missing_from_supported_parameters(self):\n+        from hermes_cli.models import _openrouter_model_supports_tools\n+        assert _openrouter_model_supports_tools(\n+            {\"id\": \"x\", \"supported_parameters\": [\"temperature\", \"response_format\"]}\n+        ) is False\n+\n+    def test_supported_parameters_absent_is_permissive(self):\n+        \"\"\"Missing field → allow (so older / non-OR gateways still work).\"\"\"\n+        from hermes_cli.models import _openrouter_model_supports_tools\n+        assert _openrouter_model_supports_tools({\"id\": \"x\"}) is True\n+\n+    def test_supported_parameters_none_is_permissive(self):\n+        from hermes_cli.models import _openrouter_model_supports_tools\n+        assert _openrouter_model_supports_tools({\"id\": \"x\", \"supported_parameters\": None}) is True\n+\n+    def test_supported_parameters_malformed_is_permissive(self):\n+        \"\"\"Malformed (non-list) value → allow rather than silently drop.\"\"\"\n+        from hermes_cli.models import _openrouter_model_supports_tools\n+        assert _openrouter_model_supports_tools(\n+            {\"id\": \"x\", \"supported_parameters\": \"tools,temperature\"}\n+        ) is True\n+\n+    def test_non_dict_item_is_permissive(self):\n+        from hermes_cli.models import _openrouter_model_supports_tools\n+        assert _openrouter_model_supports_tools(None) is True\n+        assert _openrouter_model_supports_tools(\"anthropic/claude-opus-4.6\") is True\n+\n+    def test_empty_supported_parameters_list_drops_model(self):\n+        \"\"\"Explicit empty list → no tools → drop.\"\"\"\n+        from hermes_cli.models import _openrouter_model_supports_tools\n+        assert _openrouter_model_supports_tools(\n+            {\"id\": \"x\", \"supported_parameters\": []}\n+        ) is False\n+\n \n class TestFindOpenrouterSlug:\n     def test_exact_match(self):\n"]}]},{"kind":"pr","number":13533,"title":"fix(acp): reject unadvertised auth methods","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c6974043e","number":0},{"kind":"pr","value":"#13468","number":13468},{"kind":"pr","value":"#13468","number":13468},{"kind":"pr","value":"#13452","number":13452}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}