{"repo":"NousResearch/hermes-agent","public_id":"50627","display_label":"Discord component button authorization and pairing fixes","summary":"These items relate to authorization issues with Discord MESSAGE_COMPONENT button interactions, specifically around pairing-approved users and requester permissions for component prompts. The work involves checking pairing store state and granting appropriate access for button clicks.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":51730},"resolution":{"state":"resolved","by":"pr-51730"},"freshness":{"state":"verified","last_verified_at":"2026-06-24T06:55:19+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":50627,"pr_number":51730,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":50627,"title":"v0.17.0 (v2026.6.19): Discord MESSAGE_COMPONENT button interactions broken","url":null,"state":"closed"},{"kind":"pr","number":50628,"title":"fix(discord): authorize requester on component prompts","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7ff48a629","number":0},{"kind":"pr","value":"#51730","number":51730},{"kind":"pr","value":"#50628","number":50628},{"kind":"pr","value":"#50633","number":50633},{"kind":"pr","value":"#50714","number":50714},{"kind":"pr","value":"#50633","number":50633}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":50633,"title":"fix(discord): check pairing store for component button auth","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7ff48a629","number":0},{"kind":"pr","value":"#51730","number":51730}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":50714,"title":"fix(discord): authorize pairing-approved users for component button clicks (#50627)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7ff48a629","number":0},{"kind":"pr","value":"#51730","number":51730},{"kind":"pr","value":"#50628","number":50628},{"kind":"pr","value":"#50633","number":50633},{"kind":"pr","value":"#50714","number":50714},{"kind":"pr","value":"#50633","number":50633}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":51730,"title":"fix(discord): authorize pairing-approved users for component button clicks (#50627)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/discord/adapter.py","tests/gateway/test_discord_component_auth.py"],"fix_hunks":[{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -5709,7 +5709,8 @@ def _component_check_auth(\n \n     Mirrors the gateway's external-surface authorization model: component\n     button clicks must be explicitly authorized by a Discord user/role\n-    allowlist, a global user allowlist, or an explicit allow-all flag.\n+    allowlist, a global user allowlist, an explicit allow-all flag, or\n+    the pairing store (``hermes pairing approve``).\n \n     Behavior:\n ","@@ -5719,6 +5720,7 @@ def _component_check_auth(\n       - role allowlist set + interaction.user has no resolvable\n         ``roles`` attribute (e.g. DM context with a role policy active)\n         -> reject (fail closed)\n+      - user is approved in the pairing store -> allow\n       - otherwise -> reject\n     \"\"\"\n     if os.getenv(\"DISCORD_ALLOW_ALL_USERS\", \"\").strip().lower() in {\"true\", \"1\", \"yes\"}:","@@ -5740,11 +5742,13 @@ def _component_check_auth(\n     if user is None:\n         return False\n \n+    # Resolve user ID once for both allowlist and pairing checks.\n+    try:\n+        uid = str(user.id)\n+    except AttributeError:\n+        uid = \"\"\n+\n     if has_users:\n-        try:\n-            uid = str(user.id)\n-        except AttributeError:\n-            uid = \"\"\n         if \"*\" in user_set or (uid and uid in user_set):\n             return True\n ","@@ -5763,6 +5767,18 @@ def _component_check_auth(\n         if user_role_ids & role_set:\n             return True\n \n+    # Check pairing store — mirrors ``authz_mixin._check_authorization``\n+    # so users approved via ``hermes pairing approve`` can interact with\n+    # component buttons even without DISCORD_ALLOWED_USERS set.\n+    if uid:\n+        try:\n+            from gateway.pairing import PairingStore\n+            store = PairingStore()\n+            if store.is_approved(\"discord\", uid):\n+                return True\n+        except Exception:\n+            pass\n+\n     return False\n \n "]},{"file":"tests/gateway/test_discord_component_auth.py","hunks":["@@ -30,6 +30,8 @@\n \n @pytest.fixture(autouse=True)\n def _clear_component_auth_env(monkeypatch):\n+    from unittest.mock import MagicMock, patch\n+\n     for name in (\n         \"DISCORD_ALLOW_ALL_USERS\",\n         \"GATEWAY_ALLOW_ALL_USERS\",","@@ -37,6 +39,13 @@ def _clear_component_auth_env(monkeypatch):\n     ):\n         monkeypatch.delenv(name, raising=False)\n \n+    # Default-mock PairingStore so tests don't hit the filesystem.\n+    # Pairing-specific tests override this with explicit mock values.\n+    mock_store = MagicMock()\n+    mock_store.is_approved.return_value = False\n+    with patch(\"gateway.pairing.PairingStore\", return_value=mock_store):\n+        yield\n+\n \n # ---------------------------------------------------------------------------\n # Direct helper coverage -- the views all delegate to this helper, so","@@ -304,3 +313,39 @@ def test_view_empty_allowlists_allow_with_explicit_allow_all(monkeypatch):\n     monkeypatch.setenv(\"DISCORD_ALLOW_ALL_USERS\", \"true\")\n     view = ExecApprovalView(session_key=\"s\", allowed_user_ids=set())\n     assert view._check_auth(_interaction(99999)) is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# Pairing store: users approved via ``hermes pairing approve`` must be\n+# authorized even without DISCORD_ALLOWED_USERS / DISCORD_ALLOWED_ROLES.\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_component_check_pairing_approved_user_passes(monkeypatch):\n+    \"\"\"User approved in pairing store passes even without allowlists.\"\"\"\n+    from unittest.mock import MagicMock, patch\n+\n+    mock_store = MagicMock()\n+    mock_store.is_approved.return_value = True\n+    # Override the autouse fixture's mock with approved=True\n+    with patch(\"gateway.pairing.PairingStore\", return_value=mock_store):\n+        interaction = _interaction(11111)\n+        assert _component_check_auth(interaction, set(), set()) is True\n+    mock_store.is_approved.assert_called_once_with(\"discord\", \"11111\")\n+\n+\n+def test_component_check_pairing_not_approved_user_rejected(monkeypatch):\n+    \"\"\"User NOT in pairing store is still rejected (fail-closed).\"\"\"\n+    # The autouse fixture already mocks is_approved=False, so this\n+    # just verifies the fail-closed path still works.\n+    interaction = _interaction(99999)\n+    assert _component_check_auth(interaction, set(), set()) is False\n+\n+\n+def test_component_check_pairing_import_error_graceful(monkeypatch):\n+    \"\"\"If PairingStore import fails, fall through to fail-closed.\"\"\"\n+    from unittest.mock import patch\n+\n+    with patch(\"gateway.pairing.PairingStore\", side_effect=ImportError(\"simulated\")):\n+        interaction = _interaction(11111)\n+        assert _component_check_auth(interaction, set(), set()) is False\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}