diff --git a/gateway/run.py b/gateway/run.py index df69a498c..2bd493005 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2221,6 +2221,13 @@ class GatewayRunner: # 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 @@ class GatewayRunner: 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 @@ class GatewayRunner: 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 @@ class GatewayRunner: 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, diff --git a/gateway/session_context.py b/gateway/session_context.py index 775cd8698..6d676dc1e 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -46,12 +46,16 @@ _SESSION_PLATFORM: ContextVar[str] = ContextVar("HERMES_SESSION_PLATFORM", defau _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) diff --git a/tests/gateway/test_internal_event_bypass_pairing.py b/tests/gateway/test_internal_event_bypass_pairing.py index 05b093b04..46a96e5aa 100644 --- a/tests/gateway/test_internal_event_bypass_pairing.py +++ b/tests/gateway/test_internal_event_bypass_pairing.py @@ -195,6 +195,105 @@ async def test_internal_event_does_not_trigger_pairing(monkeypatch, tmp_path): ) +@pytest.mark.asyncio +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" + + +@pytest.mark.asyncio +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 + + +@pytest.mark.asyncio +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.""" diff --git a/tests/gateway/test_session_env.py b/tests/gateway/test_session_env.py index a7f1345b7..b75e267f1 100644 --- a/tests/gateway/test_session_env.py +++ b/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 = runner._set_session_env(context) @@ -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 = runner._set_session_env(context) 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") == "" diff --git a/tests/tools/test_notify_on_complete.py b/tests/tools/test_notify_on_complete.py index ff6f14922..411f95f7e 100644 --- a/tests/tools/test_notify_on_complete.py +++ b/tests/tools/test_notify_on_complete.py @@ -227,6 +227,8 @@ class TestCheckpointNotify: "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 @@ class TestCheckpointNotify: 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.""" diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index a61da9dd3..d981878a3 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -438,6 +438,8 @@ class TestCheckpoint: 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 @@ class TestCheckpoint: 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 @@ class TestCheckpoint: "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 @@ class TestCheckpoint: 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 diff --git a/tools/process_registry.py b/tools/process_registry.py index 1be9b89f6..1761221f0 100644 --- a/tools/process_registry.py +++ b/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 @@ class ProcessRegistry: "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 @@ class ProcessRegistry: 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 @@ class ProcessRegistry: "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, }) diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 859f0f1f3..f0cbff0f4 100644 --- a/tools/terminal_tool.py +++ b/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, })