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

0open issues
0open PRs
6items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #7908 fix(gateway): propagate user identity through process watcher pipeline & guard None user_id

    merged to main

    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.py

    Show 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

  • #6540 fix(gateway): preserve user identity in process watcher notifications · merged via another change → #7908
  • #7664 fix(gateway): propagate user identity to notify_on_complete watchers · merged via another change → #7908
Closed issues · 3
  • Closed#6341Bug: Process watcher injects synthetic message with empty user_id, triggering false pairing
  • Closed#6485Bug: Subprocess completion triggers unauthorized user pairing request
  • Closed#7643notify_on_complete synthetic message fails authorization (missing user_id)
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.00 means 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 Lab

At 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.

For your agent

The same facts, typed.