Gateway process watcher user identity propagation bugs
NousResearch/hermes-agent · cluster 6341
Data as of
Nothing open on GitHub. Fix merged in #7908. Check it covers your case.
The short answer
- Known?
- Yes. 6 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #7908. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #7908. That is separate from the fix event. - Still open
- Nothing open on GitHub.
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.
Open issues0
Open PRs0
Fixes on record
-
#7908 fix(gateway): propagate user identity through process watcher pipeline & guard None user_id
files:
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.pyShow the diff
-
gateway/run.py@@ -2221,6 +2221,13 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # are system-generated and must skip user authorization. if getattr(event, "internal", False): pass + elif source.user_id is None: + # Messages with no user identity (Telegram service messages, + # channel forwards, anonymous admin actions) cannot be + # authorized — drop silently instead of triggering the pairing + # flow with a None user_id. + logger.debug("Ignoring message with no user_id from %s", source.platform.value) + return None elif not self._is_user_authorized(source): logger.warning("Unauthorized user: %s (%s) on %s", source.user_id, source.user_name, source.platform.value) # In DMs: offer pairing code. In groups: silently ignore. @@ -6597,6 +6604,8 @@ def _set_session_env(self, context: SessionContext) -> list: chat_id=context.source.chat_id, chat_name=context.source.chat_name or "", thread_id=str(context.source.thread_id) if context.source.thread_id else "", + user_id=str(context.source.user_id) if context.source.user_id else "", + user_name=str(context.source.user_name) if context.source.user_name else "", ) def _clear_session_env(self, tokens: list) -> None: @@ -6809,6 +6818,8 @@ async def _run_process_watcher(self, watcher: dict) -> None: platform_name = watcher.get("platform", "") chat_id = watcher.get("chat_id", "") thread_id = watcher.get("thread_id", "") + user_id = watcher.get("user_id", "") + user_name = watcher.get("user_name", "") agent_notify = watcher.get("notify_on_complete", False) notify_mode = self._load_background_notifications_mode() @@ -6864,6 +6875,8 @@ async def _run_process_watcher(self, watcher: dict) -> None: platform=_platform_enum, chat_id=chat_id, thread_id=thread_id or None, + user_id=user_id or None, + user_name=user_name or None, ) synth_event = MessageEvent( text=synth_text, -
gateway/session_context.py@@ -46,12 +46,16 @@ _SESSION_CHAT_ID: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_ID", default="") _SESSION_CHAT_NAME: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_NAME", default="") _SESSION_THREAD_ID: ContextVar[str] = ContextVar("HERMES_SESSION_THREAD_ID", default="") +_SESSION_USER_ID: ContextVar[str] = ContextVar("HERMES_SESSION_USER_ID", default="") +_SESSION_USER_NAME: ContextVar[str] = ContextVar("HERMES_SESSION_USER_NAME", default="") _VAR_MAP = { "HERMES_SESSION_PLATFORM": _SESSION_PLATFORM, "HERMES_SESSION_CHAT_ID": _SESSION_CHAT_ID, "HERMES_SESSION_CHAT_NAME": _SESSION_CHAT_NAME, "HERMES_SESSION_THREAD_ID": _SESSION_THREAD_ID, + "HERMES_SESSION_USER_ID": _SESSION_USER_ID, + "HERMES_SESSION_USER_NAME": _SESSION_USER_NAME, } @@ -60,6 +64,8 @@ def set_session_vars( chat_id: str = "", chat_name: str = "", thread_id: str = "", + user_id: str = "", + user_name: str = "", ) -> list: """Set all session context variables and return reset tokens. @@ -74,6 +80,8 @@ def set_session_vars( _SESSION_CHAT_ID.set(chat_id), _SESSION_CHAT_NAME.set(chat_name), _SESSION_THREAD_ID.set(thread_id), + _SESSION_USER_ID.set(user_id), + _SESSION_USER_NAME.set(user_name), ] return tokens @@ -87,6 +95,8 @@ def clear_session_vars(tokens: list) -> None: _SESSION_CHAT_ID, _SESSION_CHAT_NAME, _SESSION_THREAD_ID, + _SESSION_USER_ID, + _SESSION_USER_NAME, ] for var, token in zip(vars_in_order, tokens): var.reset(token) -
tests/gateway/test_internal_event_bypass_pairing.py@@ -195,6 +195,105 @@ async def _raise(*_a, **_kw): ) [redacted:email] +async def test_notify_on_complete_preserves_user_identity(monkeypatch, tmp_path): + """Synthetic completion event should carry user_id and user_name from the watcher.""" + import tools.process_registry as pr_module + + sessions = [ + SimpleNamespace( + output_buffer="done\n", exited=True, exit_code=0, command="echo test" + ), + ] + monkeypatch.setattr(pr_module, "process_registry", _FakeRegistry(sessions)) + + async def _instant_sleep(*_a, **_kw): + pass + monkeypatch.setattr(asyncio, "sleep", _instant_sleep) + + runner = _build_runner(monkeypatch, tmp_path) + adapter = runner.adapters[Platform.DISCORD] + + watcher = _watcher_dict_with_notify() + watcher["user_id"] = "user-42" + watcher["user_name"] = "alice" + + await runner._run_process_watcher(watcher) + + assert adapter.handle_message.await_count == 1 + event = adapter.handle_message.await_args.args[0] + assert event.source.user_id == "user-42" + assert event.source.user_name == "alice" + + [redacted:email] +async def test_none_user_id_skips_pairing(monkeypatch, tmp_path): + """A non-internal event with user_id=None should be silently dropped.""" + import gateway.run as gateway_run + + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text("", encoding="utf-8") + + runner = GatewayRunner(GatewayConfig()) + adapter = SimpleNamespace(send=AsyncMock()) + runner.adapters[Platform.TELEGRAM] = adapter + + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="123", + chat_type="dm", + user_id=None, + ) + event = MessageEvent( + text="service message", + source=source, + internal=False, + ) + + result = await runner._handle_message(event) + + # Should return None (dropped) and NOT send any pairing message + assert result is None + assert adapter.send.await_count == 0 + + [redacted:email] +async def test_none_user_id_does_not_generate_pairing_code(monkeypatch, tmp_path): + """A message with user_id=None must never call generate_code.""" + import gateway.run as gateway_run + + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text("", encoding="utf-8") + + runner = GatewayRunner(GatewayConfig()) + adapter = SimpleNamespace(send=AsyncMock()) + runner.adapters[Platform.DISCORD] = adapter + + generate_called = False + original_generate = runner.pairing_store.generate_code + + def tracking_generate(*args, **kwargs): + nonlocal generate_called + generate_called = True + return original_generate(*args, **kwargs) + + runner.pairing_store.generate_code = tracking_generate + + source = SessionSource( + platform=Platform.DISCORD, + chat_id="456", + chat_type="dm", + user_id=None, + ) + event = MessageEvent(text="anonymous", source=source, internal=False) + + await runner._handle_message(event) + + assert not generate_called, ( + "Pairing code should NOT be generated for messages with user_id=None" + ) + + @pytest.mark.asyncio async def test_non_internal_event_without_user_triggers_pairing(monkeypatch, tmp_path): """Verify the normal (non-internal) path still triggers pairing for unknown users.""" -
tests/gateway/test_session_env.py@@ -18,6 +18,8 @@ def test_set_session_env_sets_contextvars(monkeypatch): chat_id="-1001", chat_name="Group", chat_type="group", + user_id="123456", + user_name="alice", thread_id="17585", ) context = SessionContext(source=source, connected_platforms=[], home_channels={}) @@ -25,6 +27,8 @@ def test_set_session_env_sets_contextvars(monkeypatch): monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False) monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False) + monkeypatch.delenv("HERMES_SESSION_USER_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_USER_NAME", raising=False) monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False) tokens = [redacted:secret-assignment] @@ -33,6 +37,8 @@ def test_set_session_env_sets_contextvars(monkeypatch): assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram" assert get_session_env("HERMES_SESSION_CHAT_ID") == "-1001" assert get_session_env("HERMES_SESSION_CHAT_NAME") == "Group" + assert get_session_env("HERMES_SESSION_USER_ID") == "123456" + assert get_session_env("HERMES_SESSION_USER_NAME") == "alice" assert get_session_env("HERMES_SESSION_THREAD_ID") == "17585" # os.environ should NOT be touched @@ -50,6 +56,8 @@ def test_clear_session_env_restores_previous_state(monkeypatch): monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False) monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False) + monkeypatch.delenv("HERMES_SESSION_USER_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_USER_NAME", raising=False) monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False) source = SessionSource( @@ -57,12 +65,15 @@ def test_clear_session_env_restores_previous_state(monkeypatch): chat_id="-1001", chat_name="Group", chat_type="group", + user_id="123456", + user_name="alice", thread_id="17585", ) context = SessionContext(source=source, connected_platforms=[], home_channels={}) tokens = [redacted:secret-assignment] assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram" + assert get_session_env("HERMES_SESSION_USER_ID") == "123456" runner._clear_session_env(tokens) @@ -70,6 +81,8 @@ def test_clear_session_env_restores_previous_state(monkeypatch): assert get_session_env("HERMES_SESSION_PLATFORM") == "" assert get_session_env("HERMES_SESSION_CHAT_ID") == "" assert get_session_env("HERMES_SESSION_CHAT_NAME") == "" + assert get_session_env("HERMES_SESSION_USER_ID") == "" + assert get_session_env("HERMES_SESSION_USER_NAME") == "" assert get_session_env("HERMES_SESSION_THREAD_ID") == "" -
tests/tools/test_notify_on_complete.py@@ -227,6 +227,8 @@ def test_recover_requeues_notify_watchers(self, registry, tmp_path): "session_key": "sk1", "watcher_platform": "telegram", "watcher_chat_id": "123", + "watcher_user_id": "u123", + "watcher_user_name": "alice", "watcher_thread_id": "42", "watcher_interval": 5, "notify_on_complete": True, @@ -236,6 +238,8 @@ def test_recover_requeues_notify_watchers(self, registry, tmp_path): assert recovered == 1 assert len(registry.pending_watchers) == 1 assert registry.pending_watchers[0]["notify_on_complete"] is True + assert registry.pending_watchers[0]["user_id"] == "u123" + assert registry.pending_watchers[0]["user_name"] == "alice" def test_recover_defaults_false(self, registry, tmp_path): """Old checkpoint entries without the field default to False.""" -
tests/tools/test_process_registry.py@@ -438,6 +438,8 @@ def test_write_checkpoint_includes_watcher_metadata(self, registry, tmp_path): s = _make_session() s.watcher_platform = "telegram" s.watcher_chat_id = "999" + s.watcher_user_id = "u123" + s.watcher_user_name = "alice" s.watcher_thread_id = "42" s.watcher_interval = 60 registry._running[s.id] = s @@ -447,6 +449,8 @@ def test_write_checkpoint_includes_watcher_metadata(self, registry, tmp_path): assert len(data) == 1 assert data[0]["watcher_platform"] == "telegram" assert data[0]["watcher_chat_id"] == "999" + assert data[0]["watcher_user_id"] == "u123" + assert data[0]["watcher_user_name"] == "alice" assert data[0]["watcher_thread_id"] == "42" assert data[0]["watcher_interval"] == 60 @@ -460,6 +464,8 @@ def test_recover_enqueues_watchers(self, registry, tmp_path): "session_key": "sk1", "watcher_platform": "telegram", "watcher_chat_id": "123", + "watcher_user_id": "u123", + "watcher_user_name": "alice", "watcher_thread_id": "42", "watcher_interval": 60, }])) @@ -471,6 +477,8 @@ def test_recover_enqueues_watchers(self, registry, tmp_path): assert w["session_id"] == "proc_live" assert w["platform"] == "telegram" assert w["chat_id"] == "123" + assert w["user_id"] == "u123" + assert w["user_name"] == "alice" assert w["thread_id"] == "42" assert w["check_interval"] == 60 -
tools/process_registry.py@@ -85,6 +85,8 @@ class ProcessSession: # Watcher/notification metadata (persisted for crash recovery) watcher_platform: str = "" watcher_chat_id: str = "" + watcher_user_id: str = "" + watcher_user_name: str = "" watcher_thread_id: str = "" watcher_interval: int = 0 # 0 = no watcher configured notify_on_complete: bool = False # Queue agent notification on exit @@ -970,6 +972,8 @@ def _write_checkpoint(self): "session_key": s.session_key, "watcher_platform": s.watcher_platform, "watcher_chat_id": s.watcher_chat_id, + "watcher_user_id": s.watcher_user_id, + "watcher_user_name": s.watcher_user_name, "watcher_thread_id": s.watcher_thread_id, "watcher_interval": s.watcher_interval, "notify_on_complete": s.notify_on_complete, @@ -1031,6 +1035,8 @@ def recover_from_checkpoint(self) -> int: detached=True, # Can't read output, but can report status + kill watcher_platform=entry.get("watcher_platform", ""), watcher_chat_id=entry.get("watcher_chat_id", ""), + watcher_user_id=entry.get("watcher_user_id", ""), + watcher_user_name=entry.get("watcher_user_name", ""), watcher_thread_id=entry.get("watcher_thread_id", ""), watcher_interval=entry.get("watcher_interval", 0), notify_on_complete=entry.get("notify_on_complete", False), @@ -1049,6 +1055,8 @@ def recover_from_checkpoint(self) -> int: "session_key": session.session_key, "platform": session.watcher_platform, "chat_id": session.watcher_chat_id, + "user_id": session.watcher_user_id, + "user_name": session.watcher_user_name, "thread_id": session.watcher_thread_id, "notify_on_complete": session.notify_on_complete, }) -
tools/terminal_tool.py@@ -1427,8 +1427,12 @@ def terminal_tool( if _gw_platform and not check_interval: _gw_chat_id = _gse("HERMES_SESSION_CHAT_ID", "") _gw_thread_id = _gse("HERMES_SESSION_THREAD_ID", "") + _gw_user_id = _gse("HERMES_SESSION_USER_ID", "") + _gw_user_name = _gse("HERMES_SESSION_USER_NAME", "") proc_session.watcher_platform = _gw_platform proc_session.watcher_chat_id = _gw_chat_id + proc_session.watcher_user_id = _gw_user_id + proc_session.watcher_user_name = _gw_user_name proc_session.watcher_thread_id = _gw_thread_id proc_session.watcher_interval = 5 process_registry.pending_watchers.append({ @@ -1437,6 +1441,8 @@ def terminal_tool( "session_key": session_key, "platform": _gw_platform, "chat_id": _gw_chat_id, + "user_id": _gw_user_id, + "user_name": _gw_user_name, "thread_id": _gw_thread_id, "notify_on_complete": True, }) @@ -1457,10 +1463,14 @@ def terminal_tool( watcher_platform = _gse2("HERMES_SESSION_PLATFORM", "") watcher_chat_id = _gse2("HERMES_SESSION_CHAT_ID", "") watcher_thread_id = _gse2("HERMES_SESSION_THREAD_ID", "") + watcher_user_id = _gse2("HERMES_SESSION_USER_ID", "") + watcher_user_name = _gse2("HERMES_SESSION_USER_NAME", "") # Store on session for checkpoint persistence proc_session.watcher_platform = watcher_platform proc_session.watcher_chat_id = watcher_chat_id + proc_session.watcher_user_id = watcher_user_id + proc_session.watcher_user_name = watcher_user_name proc_session.watcher_thread_id = watcher_thread_id proc_session.watcher_interval = effective_interval @@ -1470,6 +1480,8 @@ def terminal_tool( "session_key": session_key, "platform": watcher_platform, "chat_id": watcher_chat_id, + "user_id": watcher_user_id, + "user_name": watcher_user_name, "thread_id": watcher_thread_id, })
-
2 PRs closed without merging
From the close comments: merged via another change 2
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.31: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #7908
- Closed issues
- 3
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 11 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.