diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 2e7dcbaa0e..67a9bdb353 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2874,8 +2874,12 @@ class GatewaySlashCommandsMixin: import asyncio from hermes_cli.goals import draft_contract - draft_contract_obj = await asyncio.get_running_loop().run_in_executor( - None, draft_contract, objective + # _run_in_executor_with_context, not a bare hop: drafting a + # contract calls the auxiliary LLM, whose provider/credential + # resolution reads the profile secret scope — a contextvar that + # a default-executor hop drops, leaving it unscoped. + draft_contract_obj = await self._run_in_executor_with_context( + draft_contract, objective ) except Exception as exc: logger.debug("goal draft failed: %s", exc) @@ -5912,8 +5916,6 @@ class GatewaySlashCommandsMixin: from hermes_state import get_shared_session_db, release_shared_session_db from agent.insights import InsightsEngine - loop = asyncio.get_running_loop() - def _run_insights(): db = get_shared_session_db() try: @@ -5925,7 +5927,13 @@ class GatewaySlashCommandsMixin: from hermes_state import release_or_close release_or_close(db) - return await loop.run_in_executor(None, _run_insights) + # _run_in_executor_with_context, not a bare hop: ``SessionDB()`` + # with no explicit path resolves ``get_hermes_home()`` at call + # time, and that override is a contextvar installed by + # ``_profile_runtime_scope``. A default-executor hop starts the + # worker with an EMPTY context, so /insights read the DEFAULT + # profile's state.db and reported another profile's conversations. + return await self._run_in_executor_with_context(_run_insights) except Exception as e: logger.error("Insights command error: %s", e, exc_info=True) return t("gateway.insights.error", error=e) @@ -6320,8 +6328,6 @@ class GatewaySlashCommandsMixin: _GATEWAY_PRIVACY_NOTICE, _best_effort_sweep_expired_pastes, ) - loop = asyncio.get_running_loop() - # Run blocking I/O (dump capture, log reads, uploads) in a thread. def _collect_and_upload(): _best_effort_sweep_expired_pastes() @@ -6348,7 +6354,11 @@ class GatewaySlashCommandsMixin: lines.append(t("gateway.debug.share_hint")) return "\n".join(lines) - return await loop.run_in_executor(None, _collect_and_upload) + # _run_in_executor_with_context, not a bare hop: this collects the + # profile's logs/config off ``get_hermes_home()`` and uploads them to a + # public paste. Losing the contextvar override would publish the DEFAULT + # profile's diagnostics from another profile's chat. + return await self._run_in_executor_with_context(_collect_and_upload) async def _handle_update_command(self, event: MessageEvent) -> str: """Handle /update command — update Hermes Agent to the latest version. diff --git a/tests/gateway/test_slash_command_profile_scope.py b/tests/gateway/test_slash_command_profile_scope.py new file mode 100644 index 0000000000..5a3dba7397 --- /dev/null +++ b/tests/gateway/test_slash_command_profile_scope.py @@ -0,0 +1,94 @@ +"""Gateway slash commands must do their blocking work inside the routed profile. + +The multiplexed inbound handler wraps the whole message in +``_profile_runtime_scope``, which installs the routed profile's ``HERMES_HOME`` +override and its secret scope as **contextvars**. A bare +``loop.run_in_executor(None, ...)`` starts the worker with an EMPTY context, so +``SessionDB()`` / ``get_hermes_home()`` inside the worker resolve the LAUNCH +home — /insights reported the default profile's conversations from another +profile's chat. ``/compress`` already routes through +``_run_in_executor_with_context``; every other hop in the mixin must too. + +Drives the real mixin methods and the real ``_profile_runtime_scope``: the +contextvar loss is a property of the hop, so mocking the hop away would test +nothing. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture +def profile_home(tmp_path, monkeypatch): + root = tmp_path / ".hermes" + home = root / "profiles" / "coder" + home.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(root)) + return home + + +@pytest.fixture +def runner(): + """Minimal host exposing the mixin plus the runner's executor helpers.""" + from gateway.run import GatewayRunner + from gateway.slash_commands import GatewaySlashCommandsMixin + + class _Runner(GatewaySlashCommandsMixin): + _run_in_executor_with_context = GatewayRunner._run_in_executor_with_context + _get_executor = GatewayRunner._get_executor + + r = _Runner() + r.adapters = {} + r._pending_skills_reload_notes = {} + return r + + +class _Event: + def __init__(self, args: str = ""): + self._args = args + self.source = None + + def get_command_args(self) -> str: + return self._args + + +@pytest.mark.asyncio +async def test_insights_opens_session_db_under_the_routed_home( + runner, profile_home, monkeypatch +): + import agent.insights as insights_mod + import hermes_state + from gateway.run import _profile_runtime_scope + from hermes_constants import get_hermes_home + + seen: dict = {} + + class _RecordingDB: + def __init__(self, *a, **kw): + seen["home"] = str(get_hermes_home()) + + def close(self): + pass + + class _Engine: + def __init__(self, db): + pass + + def generate(self, **kw): + return {} + + def format_gateway(self, report): + return "ok" + + monkeypatch.setattr(hermes_state, "SessionDB", _RecordingDB) + monkeypatch.setattr(insights_mod, "InsightsEngine", _Engine) + + with _profile_runtime_scope(profile_home): + result = await runner._handle_insights_command(_Event("")) + + assert result == "ok" + assert seen["home"] == str(profile_home)