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:
@@ -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)
|
||||
Reference in New Issue
Block a user