diff --git a/tests/tui_gateway/test_slash_worker_profile_home.py b/tests/tui_gateway/test_slash_worker_profile_home.py new file mode 100644 index 00000000000..18f321b3b14 --- /dev/null +++ b/tests/tui_gateway/test_slash_worker_profile_home.py @@ -0,0 +1,124 @@ +"""Tests for TUI gateway slash_worker profile_home propagation (#40677).""" + +import os +import subprocess +import sys +from unittest.mock import MagicMock, patch, call + +import pytest + + +def test_slash_worker_accepts_profile_home(): + """_SlashWorker.__init__ accepts profile_home parameter.""" + with patch.dict("sys.modules", { + "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")), + }): + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value.stdout = MagicMock() + mock_popen.return_value.stderr = MagicMock() + + from tui_gateway.server import _SlashWorker + + # Test initialization with profile_home + worker = _SlashWorker( + session_key="test_key", + model="test-model", + profile_home="/home/luke/.hermes/profiles/work" + ) + + # Verify Popen was called + assert mock_popen.called + + # Check that HERMES_HOME was set in the environment + call_kwargs = mock_popen.call_args[1] + assert "env" in call_kwargs + assert call_kwargs["env"]["HERMES_HOME"] == "/home/luke/.hermes/profiles/work" + + +def test_slash_worker_without_profile_home(): + """_SlashWorker works without profile_home parameter (backward compatible).""" + with patch.dict("sys.modules", { + "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")), + }): + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value.stdout = MagicMock() + mock_popen.return_value.stderr = MagicMock() + + from tui_gateway.server import _SlashWorker + + # Test initialization without profile_home (backward compatible) + worker = _SlashWorker( + session_key="test_key", + model="test-model" + ) + + # Verify Popen was called + assert mock_popen.called + + # Check that HERMES_HOME was NOT overridden + call_kwargs = mock_popen.call_args[1] + assert "env" in call_kwargs + # HERMES_HOME should be from parent env or undefined (inherited from os.environ) + # The key is that it's not explicitly set when profile_home is None + env = call_kwargs["env"] + # Verify env is a copy of os.environ + assert "PATH" in env + + +def test_slash_worker_with_none_profile_home(): + """_SlashWorker with explicit profile_home=None works.""" + with patch.dict("sys.modules", { + "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")), + }): + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value.stdout = MagicMock() + mock_popen.return_value.stderr = MagicMock() + + from tui_gateway.server import _SlashWorker + + # Test initialization with explicit None + worker = _SlashWorker( + session_key="test_key", + model="test-model", + profile_home=None + ) + + # Verify Popen was called + assert mock_popen.called + + # Check that HERMES_HOME was NOT set + call_kwargs = mock_popen.call_args[1] + env = call_kwargs["env"] + # When profile_home is None, HERMES_HOME should come from parent env only + if "HERMES_HOME" in env: + # This is from os.environ at test time, not from our code + pass + + +def test_slash_worker_inherits_argv_correctly(): + """_SlashWorker passes correct argv to Popen.""" + with patch.dict("sys.modules", { + "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")), + }): + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value.stdout = MagicMock() + mock_popen.return_value.stderr = MagicMock() + + from tui_gateway.server import _SlashWorker + + # Test that argv is correct + worker = _SlashWorker( + session_key="my_session", + model="gpt-4" + ) + + call_args = mock_popen.call_args[0][0] + + # Verify argv structure + assert sys.executable in call_args + assert "-m" in call_args + assert "tui_gateway.slash_worker" in call_args + assert "--session-key" in call_args + assert "my_session" in call_args + assert "--model" in call_args + assert "gpt-4" in call_args diff --git a/tui_gateway/server.py b/tui_gateway/server.py index db9826bbb2f..8845128f9a5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -275,7 +275,7 @@ _detached_ws_transport = _DropTransport() class _SlashWorker: """Persistent HermesCLI subprocess for slash commands.""" - def __init__(self, session_key: str, model: str): + def __init__(self, session_key: str, model: str, profile_home: str | None = None): self._lock = threading.Lock() self._seq = 0 self.stderr_tail: list[str] = [] @@ -294,6 +294,15 @@ class _SlashWorker: self._closed = False from hermes_cli._subprocess_compat import windows_hide_flags + # slash_worker runs the Hermes agent → needs provider credentials. + # Tier-1 secrets (gateway/GitHub/infra) are still stripped (#29157). + env = hermes_subprocess_env(inherit_credentials=True) + if profile_home: + # Global-remote / multi-profile sessions: the worker must resolve + # config/skills/state against the session's profile home, not the + # gateway's launch HERMES_HOME (#40677). + env["HERMES_HOME"] = str(profile_home) + # start_new_session=True detaches the slash worker into its own # process group / session. Without this, the worker inherits the # gateway's pgid (= TUI parent PID). When mcp_tool's @@ -310,9 +319,7 @@ class _SlashWorker: text=True, bufsize=1, cwd=os.getcwd(), - # slash_worker runs the Hermes agent → needs provider credentials. - # Tier-1 secrets (gateway/GitHub/infra) are still stripped (#29157). - env=hermes_subprocess_env(inherit_credentials=True), + env=env, creationflags=windows_hide_flags(), start_new_session=True, ) @@ -1299,7 +1306,11 @@ def _start_agent_build(sid: str, session: dict) -> None: current["config_model_seen"] = _config_model_target() try: - worker = _SlashWorker(key, getattr(agent, "model", _resolve_model())) + worker = _SlashWorker( + key, + getattr(agent, "model", _resolve_model()), + profile_home=current.get("profile_home"), + ) _attach_worker(sid, current, worker) except Exception: pass @@ -2628,6 +2639,7 @@ def _restart_slash_worker(sid: str, session: dict): new_worker = _SlashWorker( session["session_key"], getattr(session.get("agent"), "model", _resolve_model()), + profile_home=session.get("profile_home"), ) except Exception: session["slash_worker"] = None @@ -4514,7 +4526,11 @@ def _init_session( _attach_worker( sid, _sessions[sid], - _SlashWorker(key, getattr(agent, "model", _resolve_model())), + _SlashWorker( + key, + getattr(agent, "model", _resolve_model()), + profile_home=_sessions[sid].get("profile_home"), + ), ) except Exception: # Defer hard-failure to slash.exec; chat still works without slash worker. @@ -12710,6 +12726,7 @@ def _(rid, params: dict) -> dict: worker = _SlashWorker( session["session_key"], getattr(session.get("agent"), "model", _resolve_model()), + profile_home=session.get("profile_home"), ) _attach_worker(params.get("session_id", ""), session, worker) except Exception as e: