fix(gateway): run /insights, /debug and /goal draft inside the routed profile

The multiplexed inbound handler wraps every 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, fn) starts the worker with an
EMPTY context, so neither reaches the blocking work.

GatewaySlashCommandsMixin already knows this -- /compress goes through
_run_in_executor_with_context and the call site says why. Three siblings in the
same file still used the bare hop:

  /insights   SessionDB() with no explicit path resolves get_hermes_home() at
              call time (_default_db_path), so the worker opened the DEFAULT
              profile's state.db. Under multiplexing the command reported
              another profile's conversations, session counts and sources to
              this profile's user.

  /debug      collects that home's logs/config and uploads them to a public
              paste, so it published the default profile's diagnostics from
              another profile's chat.

  /goal draft calls the auxiliary LLM, whose provider/credential resolution
              reads the profile secret scope -- unscoped it falls back to
              process-global os.environ, which under multiplexing may hold a
              different profile's keys.

Route all three through _run_in_executor_with_context.

/reload-skills is deliberately left alone: tools.skills_tool binds SKILLS_DIR
at import time, so it does not follow the contextvar either way. Fixing that
needs the module-global retarget web_server._profile_scope performs under a
lock, which is a different change from context propagation.

Single-profile gateways never enter the scope, so their behaviour is unchanged.
This commit is contained in:
Drexuxux
2026-08-04 14:26:01 +03:00
committed by Teknium
parent eed481bd34
commit fbd9730e30
2 changed files with 112 additions and 8 deletions
+18 -8
View File
@@ -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.
@@ -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)