# NousResearch/hermes-agent · Gateway process watcher user identity propagation bugs

## Open now

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

## TL;DR

- 6 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #7908. Verify it addresses your case.
- **Recommended:** read PR #7908 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-7908`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Closed / duplicate issues

- #6341: Bug: Process watcher injects synthetic message with empty user_id, triggering false pairing
- #6485: Bug: Subprocess completion triggers unauthorized user pairing request
- #7643: notify_on_complete synthetic message fails authorization (missing user_id)

## Prior & competing PRs

- **#7908: fix(gateway): propagate user identity through process watcher pipeline & guard None user_id**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `gateway/run.py`

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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()
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
     }
     
     
    ```

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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={})
    ```

    ```diff
    @@ -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]
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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)
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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,
             }]))
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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),
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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({
    ```

    ```diff
    @@ -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,
                             })
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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,
                         })
     
    ```
- **#6540: fix(gateway): preserve user identity in process watcher notifications**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#7908`
- **#7664: fix(gateway): propagate user identity to notify_on_complete watchers**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#7908`

## Fix status

- Merged to main in PR #7908 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-11`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
