fix(gateway): propagate user identity through process watcher pipeline

Background process watchers (notify_on_complete, check_interval) created
synthetic SessionSource objects without user_id/user_name. While the
internal=True bypass (1d8d4f28) prevented false pairing for agent-
generated notifications, the missing identity caused:

- Garbage entries in pairing rate limiters (discord:None, telegram:None)
- 'User None' in approval messages and logs
- No user identity available for future code paths that need it

Additionally, platform messages arriving without from_user (Telegram
service messages, channel forwards, anonymous admin actions) could still
trigger false pairing because they are not internal events.

Fix:
1. Propagate user_id/user_name through the full watcher chain:
   session_context.py → gateway/run.py → terminal_tool.py →
   process_registry.py (including checkpoint persistence/recovery)

2. Add None user_id guard in _handle_message() — silently drop
   non-internal messages with no user identity instead of triggering
   the pairing flow.

Salvaged from PRs #7664 (kagura-agent, ContextVar approach),
#6540 (MestreY0d4-Uninter, tests), and #7709 (guang384, None guard).

Closes #6341, #6485, #7643
Relates to #6516, #7392
This commit is contained in:
Teknium 2026-04-11 12:09:01 -07:00
parent 3ec8809b78
commit 95e662ff6f
No known key found for this signature in database
8 changed files with 167 additions and 0 deletions

View file

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

View file

@ -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") == ""

View file

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

View file

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