{"repo":"NousResearch/hermes-agent","public_id":"6341","display_label":"Gateway process watcher user identity propagation bugs","summary":"A cluster of bugs and fixes centered on the gateway's process watcher failing to carry user identity through notification pipelines, causing authorization failures and false pairing requests when subprocesses complete.","subsystem":null,"counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":7908},"resolution":{"state":"partially_resolved","by":"pr-7908"},"freshness":{"state":"stale","last_verified_at":"2026-04-11T20:46:16+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.31466666666666665},"fix_edges":[{"issue_number":6341,"pr_number":7908,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":6718,"pr_number":7908,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":7643,"pr_number":7908,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":6341,"title":"Bug: Process watcher injects synthetic message with empty user_id, triggering false pairing","url":null,"state":"closed"},{"kind":"issue","number":6485,"title":"Bug: Subprocess completion triggers unauthorized user pairing request","url":null,"state":"closed"},{"kind":"issue","number":7643,"title":"notify_on_complete synthetic message fails authorization (missing user_id)","url":null,"state":"closed"},{"kind":"pr","number":6540,"title":"fix(gateway): preserve user identity in process watcher notifications","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7908","number":7908}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7664,"title":"fix(gateway): propagate user identity to notify_on_complete watchers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7908","number":7908}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7908,"title":"fix(gateway): propagate user identity through process watcher pipeline & guard None user_id","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","gateway/session_context.py","tests/gateway/test_internal_event_bypass_pairing.py","tests/gateway/test_session_env.py","tests/tools/test_notify_on_complete.py","tests/tools/test_process_registry.py","tools/process_registry.py","tools/terminal_tool.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -2221,6 +2221,13 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n         # are system-generated and must skip user authorization.\n         if getattr(event, \"internal\", False):\n             pass\n+        elif source.user_id is None:\n+            # Messages with no user identity (Telegram service messages,\n+            # channel forwards, anonymous admin actions) cannot be\n+            # authorized — drop silently instead of triggering the pairing\n+            # flow with a None user_id.\n+            logger.debug(\"Ignoring message with no user_id from %s\", source.platform.value)\n+            return None\n         elif not self._is_user_authorized(source):\n             logger.warning(\"Unauthorized user: %s (%s) on %s\", source.user_id, source.user_name, source.platform.value)\n             # In DMs: offer pairing code. In groups: silently ignore.","@@ -6597,6 +6604,8 @@ def _set_session_env(self, context: SessionContext) -> list:\n             chat_id=context.source.chat_id,\n             chat_name=context.source.chat_name or \"\",\n             thread_id=str(context.source.thread_id) if context.source.thread_id else \"\",\n+            user_id=str(context.source.user_id) if context.source.user_id else \"\",\n+            user_name=str(context.source.user_name) if context.source.user_name else \"\",\n         )\n \n     def _clear_session_env(self, tokens: list) -> None:","@@ -6809,6 +6818,8 @@ async def _run_process_watcher(self, watcher: dict) -> None:\n         platform_name = watcher.get(\"platform\", \"\")\n         chat_id = watcher.get(\"chat_id\", \"\")\n         thread_id = watcher.get(\"thread_id\", \"\")\n+        user_id = watcher.get(\"user_id\", \"\")\n+        user_name = watcher.get(\"user_name\", \"\")\n         agent_notify = watcher.get(\"notify_on_complete\", False)\n         notify_mode = self._load_background_notifications_mode()\n ","@@ -6864,6 +6875,8 @@ async def _run_process_watcher(self, watcher: dict) -> None:\n                                 platform=_platform_enum,\n                                 chat_id=chat_id,\n                                 thread_id=thread_id or None,\n+                                user_id=user_id or None,\n+                                user_name=user_name or None,\n                             )\n                             synth_event = MessageEvent(\n                                 text=synth_text,"]},{"file":"gateway/session_context.py","hunks":["@@ -46,12 +46,16 @@\n _SESSION_CHAT_ID: ContextVar[str] = ContextVar(\"HERMES_SESSION_CHAT_ID\", default=\"\")\n _SESSION_CHAT_NAME: ContextVar[str] = ContextVar(\"HERMES_SESSION_CHAT_NAME\", default=\"\")\n _SESSION_THREAD_ID: ContextVar[str] = ContextVar(\"HERMES_SESSION_THREAD_ID\", default=\"\")\n+_SESSION_USER_ID: ContextVar[str] = ContextVar(\"HERMES_SESSION_USER_ID\", default=\"\")\n+_SESSION_USER_NAME: ContextVar[str] = ContextVar(\"HERMES_SESSION_USER_NAME\", default=\"\")\n \n _VAR_MAP = {\n     \"HERMES_SESSION_PLATFORM\": _SESSION_PLATFORM,\n     \"HERMES_SESSION_CHAT_ID\": _SESSION_CHAT_ID,\n     \"HERMES_SESSION_CHAT_NAME\": _SESSION_CHAT_NAME,\n     \"HERMES_SESSION_THREAD_ID\": _SESSION_THREAD_ID,\n+    \"HERMES_SESSION_USER_ID\": _SESSION_USER_ID,\n+    \"HERMES_SESSION_USER_NAME\": _SESSION_USER_NAME,\n }\n \n ","@@ -60,6 +64,8 @@ def set_session_vars(\n     chat_id: str = \"\",\n     chat_name: str = \"\",\n     thread_id: str = \"\",\n+    user_id: str = \"\",\n+    user_name: str = \"\",\n ) -> list:\n     \"\"\"Set all session context variables and return reset tokens.\n ","@@ -74,6 +80,8 @@ def set_session_vars(\n         _SESSION_CHAT_ID.set(chat_id),\n         _SESSION_CHAT_NAME.set(chat_name),\n         _SESSION_THREAD_ID.set(thread_id),\n+        _SESSION_USER_ID.set(user_id),\n+        _SESSION_USER_NAME.set(user_name),\n     ]\n     return tokens\n ","@@ -87,6 +95,8 @@ def clear_session_vars(tokens: list) -> None:\n         _SESSION_CHAT_ID,\n         _SESSION_CHAT_NAME,\n         _SESSION_THREAD_ID,\n+        _SESSION_USER_ID,\n+        _SESSION_USER_NAME,\n     ]\n     for var, token in zip(vars_in_order, tokens):\n         var.reset(token)"]},{"file":"tests/gateway/test_internal_event_bypass_pairing.py","hunks":["@@ -195,6 +195,105 @@ async def _raise(*_a, **_kw):\n     )\n \n \n[redacted:email]\n+async def test_notify_on_complete_preserves_user_identity(monkeypatch, tmp_path):\n+    \"\"\"Synthetic completion event should carry user_id and user_name from the watcher.\"\"\"\n+    import tools.process_registry as pr_module\n+\n+    sessions = [\n+        SimpleNamespace(\n+            output_buffer=\"done\\n\", exited=True, exit_code=0, command=\"echo test\"\n+        ),\n+    ]\n+    monkeypatch.setattr(pr_module, \"process_registry\", _FakeRegistry(sessions))\n+\n+    async def _instant_sleep(*_a, **_kw):\n+        pass\n+    monkeypatch.setattr(asyncio, \"sleep\", _instant_sleep)\n+\n+    runner = _build_runner(monkeypatch, tmp_path)\n+    adapter = runner.adapters[Platform.DISCORD]\n+\n+    watcher = _watcher_dict_with_notify()\n+    watcher[\"user_id\"] = \"user-42\"\n+    watcher[\"user_name\"] = \"alice\"\n+\n+    await runner._run_process_watcher(watcher)\n+\n+    assert adapter.handle_message.await_count == 1\n+    event = adapter.handle_message.await_args.args[0]\n+    assert event.source.user_id == \"user-42\"\n+    assert event.source.user_name == \"alice\"\n+\n+\n[redacted:email]\n+async def test_none_user_id_skips_pairing(monkeypatch, tmp_path):\n+    \"\"\"A non-internal event with user_id=None should be silently dropped.\"\"\"\n+    import gateway.run as gateway_run\n+\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+    (tmp_path / \"config.yaml\").write_text(\"\", encoding=\"utf-8\")\n+\n+    runner = GatewayRunner(GatewayConfig())\n+    adapter = SimpleNamespace(send=AsyncMock())\n+    runner.adapters[Platform.TELEGRAM] = adapter\n+\n+    source = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"123\",\n+        chat_type=\"dm\",\n+        user_id=None,\n+    )\n+    event = MessageEvent(\n+        text=\"service message\",\n+        source=source,\n+        internal=False,\n+    )\n+\n+    result = await runner._handle_message(event)\n+\n+    # Should return None (dropped) and NOT send any pairing message\n+    assert result is None\n+    assert adapter.send.await_count == 0\n+\n+\n[redacted:email]\n+async def test_none_user_id_does_not_generate_pairing_code(monkeypatch, tmp_path):\n+    \"\"\"A message with user_id=None must never call generate_code.\"\"\"\n+    import gateway.run as gateway_run\n+\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+    (tmp_path / \"config.yaml\").write_text(\"\", encoding=\"utf-8\")\n+\n+    runner = GatewayRunner(GatewayConfig())\n+    adapter = SimpleNamespace(send=AsyncMock())\n+    runner.adapters[Platform.DISCORD] = adapter\n+\n+    generate_called = False\n+    original_generate = runner.pairing_store.generate_code\n+\n+    def tracking_generate(*args, **kwargs):\n+        nonlocal generate_called\n+        generate_called = True\n+        return original_generate(*args, **kwargs)\n+\n+    runner.pairing_store.generate_code = tracking_generate\n+\n+    source = SessionSource(\n+        platform=Platform.DISCORD,\n+        chat_id=\"456\",\n+        chat_type=\"dm\",\n+        user_id=None,\n+    )\n+    event = MessageEvent(text=\"anonymous\", source=source, internal=False)\n+\n+    await runner._handle_message(event)\n+\n+    assert not generate_called, (\n+        \"Pairing code should NOT be generated for messages with user_id=None\"\n+    )\n+\n+\n @pytest.mark.asyncio\n async def test_non_internal_event_without_user_triggers_pairing(monkeypatch, tmp_path):\n     \"\"\"Verify the normal (non-internal) path still triggers pairing for unknown users.\"\"\""]},{"file":"tests/gateway/test_session_env.py","hunks":["@@ -18,6 +18,8 @@ def test_set_session_env_sets_contextvars(monkeypatch):\n         chat_id=\"-1001\",\n         chat_name=\"Group\",\n         chat_type=\"group\",\n+        user_id=\"123456\",\n+        user_name=\"alice\",\n         thread_id=\"17585\",\n     )\n     context = SessionContext(source=source, connected_platforms=[], home_channels={})","@@ -25,6 +27,8 @@ def test_set_session_env_sets_contextvars(monkeypatch):\n     monkeypatch.delenv(\"HERMES_SESSION_PLATFORM\", raising=False)\n     monkeypatch.delenv(\"HERMES_SESSION_CHAT_ID\", raising=False)\n     monkeypatch.delenv(\"HERMES_SESSION_CHAT_NAME\", raising=False)\n+    monkeypatch.delenv(\"HERMES_SESSION_USER_ID\", raising=False)\n+    monkeypatch.delenv(\"HERMES_SESSION_USER_NAME\", raising=False)\n     monkeypatch.delenv(\"HERMES_SESSION_THREAD_ID\", raising=False)\n \n     tokens = [redacted:secret-assignment]","@@ -33,6 +37,8 @@ def test_set_session_env_sets_contextvars(monkeypatch):\n     assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"telegram\"\n     assert get_session_env(\"HERMES_SESSION_CHAT_ID\") == \"-1001\"\n     assert get_session_env(\"HERMES_SESSION_CHAT_NAME\") == \"Group\"\n+    assert get_session_env(\"HERMES_SESSION_USER_ID\") == \"123456\"\n+    assert get_session_env(\"HERMES_SESSION_USER_NAME\") == \"alice\"\n     assert get_session_env(\"HERMES_SESSION_THREAD_ID\") == \"17585\"\n \n     # os.environ should NOT be touched","@@ -50,6 +56,8 @@ def test_clear_session_env_restores_previous_state(monkeypatch):\n     monkeypatch.delenv(\"HERMES_SESSION_PLATFORM\", raising=False)\n     monkeypatch.delenv(\"HERMES_SESSION_CHAT_ID\", raising=False)\n     monkeypatch.delenv(\"HERMES_SESSION_CHAT_NAME\", raising=False)\n+    monkeypatch.delenv(\"HERMES_SESSION_USER_ID\", raising=False)\n+    monkeypatch.delenv(\"HERMES_SESSION_USER_NAME\", raising=False)\n     monkeypatch.delenv(\"HERMES_SESSION_THREAD_ID\", raising=False)\n \n     source = SessionSource(","@@ -57,12 +65,15 @@ def test_clear_session_env_restores_previous_state(monkeypatch):\n         chat_id=\"-1001\",\n         chat_name=\"Group\",\n         chat_type=\"group\",\n+        user_id=\"123456\",\n+        user_name=\"alice\",\n         thread_id=\"17585\",\n     )\n     context = SessionContext(source=source, connected_platforms=[], home_channels={})\n \n     tokens = [redacted:secret-assignment]\n     assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"telegram\"\n+    assert get_session_env(\"HERMES_SESSION_USER_ID\") == \"123456\"\n \n     runner._clear_session_env(tokens)\n ","@@ -70,6 +81,8 @@ def test_clear_session_env_restores_previous_state(monkeypatch):\n     assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"\"\n     assert get_session_env(\"HERMES_SESSION_CHAT_ID\") == \"\"\n     assert get_session_env(\"HERMES_SESSION_CHAT_NAME\") == \"\"\n+    assert get_session_env(\"HERMES_SESSION_USER_ID\") == \"\"\n+    assert get_session_env(\"HERMES_SESSION_USER_NAME\") == \"\"\n     assert get_session_env(\"HERMES_SESSION_THREAD_ID\") == \"\"\n \n "]},{"file":"tests/tools/test_notify_on_complete.py","hunks":["@@ -227,6 +227,8 @@ def test_recover_requeues_notify_watchers(self, registry, tmp_path):\n             \"session_key\": \"sk1\",\n             \"watcher_platform\": \"telegram\",\n             \"watcher_chat_id\": \"123\",\n+            \"watcher_user_id\": \"u123\",\n+            \"watcher_user_name\": \"alice\",\n             \"watcher_thread_id\": \"42\",\n             \"watcher_interval\": 5,\n             \"notify_on_complete\": True,","@@ -236,6 +238,8 @@ def test_recover_requeues_notify_watchers(self, registry, tmp_path):\n             assert recovered == 1\n             assert len(registry.pending_watchers) == 1\n             assert registry.pending_watchers[0][\"notify_on_complete\"] is True\n+            assert registry.pending_watchers[0][\"user_id\"] == \"u123\"\n+            assert registry.pending_watchers[0][\"user_name\"] == \"alice\"\n \n     def test_recover_defaults_false(self, registry, tmp_path):\n         \"\"\"Old checkpoint entries without the field default to False.\"\"\""]},{"file":"tests/tools/test_process_registry.py","hunks":["@@ -438,6 +438,8 @@ def test_write_checkpoint_includes_watcher_metadata(self, registry, tmp_path):\n             s = _make_session()\n             s.watcher_platform = \"telegram\"\n             s.watcher_chat_id = \"999\"\n+            s.watcher_user_id = \"u123\"\n+            s.watcher_user_name = \"alice\"\n             s.watcher_thread_id = \"42\"\n             s.watcher_interval = 60\n             registry._running[s.id] = s","@@ -447,6 +449,8 @@ def test_write_checkpoint_includes_watcher_metadata(self, registry, tmp_path):\n             assert len(data) == 1\n             assert data[0][\"watcher_platform\"] == \"telegram\"\n             assert data[0][\"watcher_chat_id\"] == \"999\"\n+            assert data[0][\"watcher_user_id\"] == \"u123\"\n+            assert data[0][\"watcher_user_name\"] == \"alice\"\n             assert data[0][\"watcher_thread_id\"] == \"42\"\n             assert data[0][\"watcher_interval\"] == 60\n ","@@ -460,6 +464,8 @@ def test_recover_enqueues_watchers(self, registry, tmp_path):\n             \"session_key\": \"sk1\",\n             \"watcher_platform\": \"telegram\",\n             \"watcher_chat_id\": \"123\",\n+            \"watcher_user_id\": \"u123\",\n+            \"watcher_user_name\": \"alice\",\n             \"watcher_thread_id\": \"42\",\n             \"watcher_interval\": 60,\n         }]))","@@ -471,6 +477,8 @@ def test_recover_enqueues_watchers(self, registry, tmp_path):\n             assert w[\"session_id\"] == \"proc_live\"\n             assert w[\"platform\"] == \"telegram\"\n             assert w[\"chat_id\"] == \"123\"\n+            assert w[\"user_id\"] == \"u123\"\n+            assert w[\"user_name\"] == \"alice\"\n             assert w[\"thread_id\"] == \"42\"\n             assert w[\"check_interval\"] == 60\n "]},{"file":"tools/process_registry.py","hunks":["@@ -85,6 +85,8 @@ class ProcessSession:\n     # Watcher/notification metadata (persisted for crash recovery)\n     watcher_platform: str = \"\"\n     watcher_chat_id: str = \"\"\n+    watcher_user_id: str = \"\"\n+    watcher_user_name: str = \"\"\n     watcher_thread_id: str = \"\"\n     watcher_interval: int = 0                   # 0 = no watcher configured\n     notify_on_complete: bool = False             # Queue agent notification on exit","@@ -970,6 +972,8 @@ def _write_checkpoint(self):\n                             \"session_key\": s.session_key,\n                             \"watcher_platform\": s.watcher_platform,\n                             \"watcher_chat_id\": s.watcher_chat_id,\n+                            \"watcher_user_id\": s.watcher_user_id,\n+                            \"watcher_user_name\": s.watcher_user_name,\n                             \"watcher_thread_id\": s.watcher_thread_id,\n                             \"watcher_interval\": s.watcher_interval,\n                             \"notify_on_complete\": s.notify_on_complete,","@@ -1031,6 +1035,8 @@ def recover_from_checkpoint(self) -> int:\n                     detached=True,  # Can't read output, but can report status + kill\n                     watcher_platform=entry.get(\"watcher_platform\", \"\"),\n                     watcher_chat_id=entry.get(\"watcher_chat_id\", \"\"),\n+                    watcher_user_id=entry.get(\"watcher_user_id\", \"\"),\n+                    watcher_user_name=entry.get(\"watcher_user_name\", \"\"),\n                     watcher_thread_id=entry.get(\"watcher_thread_id\", \"\"),\n                     watcher_interval=entry.get(\"watcher_interval\", 0),\n                     notify_on_complete=entry.get(\"notify_on_complete\", False),","@@ -1049,6 +1055,8 @@ def recover_from_checkpoint(self) -> int:\n                         \"session_key\": session.session_key,\n                         \"platform\": session.watcher_platform,\n                         \"chat_id\": session.watcher_chat_id,\n+                        \"user_id\": session.watcher_user_id,\n+                        \"user_name\": session.watcher_user_name,\n                         \"thread_id\": session.watcher_thread_id,\n                         \"notify_on_complete\": session.notify_on_complete,\n                     })"]},{"file":"tools/terminal_tool.py","hunks":["@@ -1427,8 +1427,12 @@ def terminal_tool(\n                     if _gw_platform and not check_interval:\n                         _gw_chat_id = _gse(\"HERMES_SESSION_CHAT_ID\", \"\")\n                         _gw_thread_id = _gse(\"HERMES_SESSION_THREAD_ID\", \"\")\n+                        _gw_user_id = _gse(\"HERMES_SESSION_USER_ID\", \"\")\n+                        _gw_user_name = _gse(\"HERMES_SESSION_USER_NAME\", \"\")\n                         proc_session.watcher_platform = _gw_platform\n                         proc_session.watcher_chat_id = _gw_chat_id\n+                        proc_session.watcher_user_id = _gw_user_id\n+                        proc_session.watcher_user_name = _gw_user_name\n                         proc_session.watcher_thread_id = _gw_thread_id\n                         proc_session.watcher_interval = 5\n                         process_registry.pending_watchers.append({","@@ -1437,6 +1441,8 @@ def terminal_tool(\n                             \"session_key\": session_key,\n                             \"platform\": _gw_platform,\n                             \"chat_id\": _gw_chat_id,\n+                            \"user_id\": _gw_user_id,\n+                            \"user_name\": _gw_user_name,\n                             \"thread_id\": _gw_thread_id,\n                             \"notify_on_complete\": True,\n                         })","@@ -1457,10 +1463,14 @@ def terminal_tool(\n                     watcher_platform = _gse2(\"HERMES_SESSION_PLATFORM\", \"\")\n                     watcher_chat_id = _gse2(\"HERMES_SESSION_CHAT_ID\", \"\")\n                     watcher_thread_id = _gse2(\"HERMES_SESSION_THREAD_ID\", \"\")\n+                    watcher_user_id = _gse2(\"HERMES_SESSION_USER_ID\", \"\")\n+                    watcher_user_name = _gse2(\"HERMES_SESSION_USER_NAME\", \"\")\n \n                     # Store on session for checkpoint persistence\n                     proc_session.watcher_platform = watcher_platform\n                     proc_session.watcher_chat_id = watcher_chat_id\n+                    proc_session.watcher_user_id = watcher_user_id\n+                    proc_session.watcher_user_name = watcher_user_name\n                     proc_session.watcher_thread_id = watcher_thread_id\n                     proc_session.watcher_interval = effective_interval\n ","@@ -1470,6 +1480,8 @@ def terminal_tool(\n                         \"session_key\": session_key,\n                         \"platform\": watcher_platform,\n                         \"chat_id\": watcher_chat_id,\n+                        \"user_id\": watcher_user_id,\n+                        \"user_name\": watcher_user_name,\n                         \"thread_id\": watcher_thread_id,\n                     })\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}