diff --git a/gateway/run.py b/gateway/run.py index 708106ad8..ff82fe435 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -32,6 +32,7 @@ import logging import os import re import shlex +import site import sys import signal import tempfile @@ -135,6 +136,60 @@ _GATEWAY_SECRET_PATTERNS = ( ) +def _ensure_windows_gateway_venv_imports() -> None: + """Make detached Windows gateway runs see the Hermes venv packages. + + Some Windows restart paths run the gateway under uv's base ``pythonw.exe`` + to avoid the venv launcher respawning a visible console interpreter. That + mode can import the source tree via cwd/PYTHONPATH but still miss optional + packages installed only in ``venv/Lib/site-packages`` (notably the MCP SDK). + Patch the live process before MCP discovery so tool injection does not + depend on every launcher preserving PYTHONPATH perfectly. + """ + if sys.platform != "win32": + return + + project_root = Path(__file__).resolve().parent.parent + candidates: list[Path] = [] + if os.environ.get("VIRTUAL_ENV"): + candidates.append(Path(os.environ["VIRTUAL_ENV"])) + candidates.append(project_root / "venv") + + seen: set[str] = set() + for venv_dir in candidates: + try: + resolved_venv = venv_dir.resolve() + except OSError: + resolved_venv = venv_dir + venv_key = str(resolved_venv).lower() + if venv_key in seen: + continue + seen.add(venv_key) + + site_packages = resolved_venv / "Lib" / "site-packages" + if not site_packages.exists(): + continue + + project_entry = str(project_root) + site_entry = str(site_packages) + if project_entry not in sys.path: + sys.path.insert(0, project_entry) + # addsitepackages() semantics matter here: pywin32, used by the MCP + # SDK on Windows, relies on .pth processing to expose pywintypes. + site.addsitedir(site_entry) + if site_entry in sys.path: + sys.path.remove(site_entry) + insert_at = 1 if sys.path and sys.path[0] == project_entry else 0 + sys.path.insert(insert_at, site_entry) + + os.environ["VIRTUAL_ENV"] = str(resolved_venv) + pythonpath = [project_entry, site_entry] + if os.environ.get("PYTHONPATH"): + pythonpath.append(os.environ["PYTHONPATH"]) + os.environ["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath)) + return + + def _gateway_platform_value(platform: Any) -> str: """Return a normalized gateway platform value for enums or raw strings.""" return str(getattr(platform, "value", platform) or "").strip().lower() @@ -4255,10 +4310,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) """ ).strip() + watcher_env = os.environ.copy() + # This watcher is intentionally outside the running gateway. If it + # inherits the gateway marker, `hermes gateway restart` refuses to + # run as a self-restart loop guard and the gateway stays stopped. + watcher_env.pop("_HERMES_GATEWAY", None) + project_root = Path(__file__).resolve().parent.parent + venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv") + site_packages = venv_dir / "Lib" / "site-packages" + if site_packages.exists(): + watcher_env["VIRTUAL_ENV"] = str(venv_dir) + pythonpath = [str(project_root), str(site_packages)] + if watcher_env.get("PYTHONPATH"): + pythonpath.append(watcher_env["PYTHONPATH"]) + watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath)) subprocess.Popen( [sys.executable, "-c", watcher, str(current_pid), *cmd_argv], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=watcher_env, **windows_detach_popen_kwargs(), ) return @@ -15910,6 +15980,8 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = atexit.register(remove_pid_file) atexit.register(release_gateway_runtime_lock) + _ensure_windows_gateway_venv_imports() + # MCP tool discovery — run in an executor so the asyncio event loop # stays responsive even when a configured MCP server is slow or # unreachable. discover_mcp_tools() uses a blocking 120s wait diff --git a/tests/gateway/test_restart_drain.py b/tests/gateway/test_restart_drain.py index a48e5f737..32710fdb8 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -197,6 +197,7 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch): runner, _adapter = make_restart_runner() popen_calls = [] + monkeypatch.setattr(gateway_run.sys, "platform", "linux") monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["/usr/bin/hermes"]) monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321) monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/setsid" if cmd == "setsid" else None) @@ -219,6 +220,69 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch): assert kwargs["stderr"] is subprocess.DEVNULL +def test_windows_gateway_venv_imports_add_site_packages(monkeypatch, tmp_path): + venv_dir = tmp_path / "venv" + site_packages = venv_dir / "Lib" / "site-packages" + pth_extra = tmp_path / "pywin32_system32" + site_packages.mkdir(parents=True) + pth_extra.mkdir() + (site_packages / "pywin32.pth").write_text(str(pth_extra), encoding="utf-8") + project_root = str(gateway_run.Path(gateway_run.__file__).resolve().parent.parent) + + monkeypatch.setattr(gateway_run.sys, "platform", "win32") + monkeypatch.setattr(gateway_run.sys, "path", ["existing"]) + monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir)) + monkeypatch.setenv("PYTHONPATH", "already-there") + + gateway_run._ensure_windows_gateway_venv_imports() + + assert gateway_run.sys.path[:2] == [project_root, str(site_packages)] + assert str(pth_extra) in gateway_run.sys.path + assert gateway_run.os.environ["VIRTUAL_ENV"] == str(venv_dir.resolve()) + pythonpath = gateway_run.os.environ["PYTHONPATH"].split(gateway_run.os.pathsep) + assert pythonpath[:3] == [project_root, str(site_packages), "already-there"] + + +@pytest.mark.asyncio +async def test_windows_detached_restart_scrubs_gateway_marker(monkeypatch, tmp_path): + runner, _adapter = make_restart_runner() + popen_calls = [] + venv_dir = tmp_path / "venv" + site_packages = venv_dir / "Lib" / "site-packages" + site_packages.mkdir(parents=True) + + monkeypatch.setattr(gateway_run.sys, "platform", "win32") + monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["hermes"]) + monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321) + monkeypatch.setenv("_HERMES_GATEWAY", "1") + monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir)) + + import hermes_cli._subprocess_compat as subprocess_compat + + monkeypatch.setattr( + subprocess_compat, + "windows_detach_popen_kwargs", + lambda: {}, + ) + + def fake_popen(cmd, **kwargs): + popen_calls.append((cmd, kwargs)) + return MagicMock() + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + await runner._launch_detached_restart_command() + + assert len(popen_calls) == 1 + cmd, kwargs = popen_calls[0] + assert cmd[-3:] == ["hermes", "gateway", "restart"] + assert kwargs["env"].get("_HERMES_GATEWAY") is None + assert kwargs["env"]["VIRTUAL_ENV"] == str(venv_dir) + assert str(site_packages) in kwargs["env"]["PYTHONPATH"].split(gateway_run.os.pathsep) + assert kwargs["stdout"] is subprocess.DEVNULL + assert kwargs["stderr"] is subprocess.DEVNULL + + # ── Shutdown notification tests ──────────────────────────────────────