From a844ff0ddcb8c24decf2f1d5f2530150bdc870ef Mon Sep 17 00:00:00 2001 From: linux2010 Date: Tue, 14 Apr 2026 11:46:21 +0000 Subject: [PATCH] fix(gateway): use PROJECT_ROOT/venv/bin/python as fallback instead of sys.executable ## What broke After `hermes gateway install --system`, the systemd service ExecStart uses the uv-managed Python path instead of the venv Python path. This causes immediate crash with `ModuleNotFoundError: No module named 'yaml'` because the uv Python has no packages installed. ExecStart=/root/.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/bin/python3.11 Should be: ExecStart=/root/.hermes/hermes-agent/venv/bin/python3.11 ## Root cause `get_python_path()` in hermes_cli/gateway.py fell back to `sys.executable` when `_detect_venv_dir()` returned None. In uv-managed environments, `sys.executable` returns the uv Python path which has no packages. Meanwhile, `generate_systemd_unit()` already uses `PROJECT_ROOT/venv` as fallback for `venv_dir` (line 819), creating an inconsistency: - `venv_dir` = PROJECT_ROOT/venv (correct) - `python_path` = sys.executable (wrong - uv path) ## Why this fix is minimal Added 11 lines to `get_python_path()` to add a middle fallback tier: 1. detected_venv/bin/python (existing - highest priority) 2. PROJECT_ROOT/venv/bin/python (NEW - matches generate_systemd_unit) 3. sys.executable (existing - final fallback) This makes `get_python_path()` consistent with `venv_dir` calculation in `generate_systemd_unit()`. No behavior change when venv is detected. ## What I tested Added test suite tests/hermes_cli/test_gateway_python_path.py with 8 tests: - test_fallback_to_project_root_venv_when_detect_fails - test_detect_venv_dir_returns_valid_venv - test_fallback_chain_order - test_consistency_with_venv_dir_in_systemd_unit - test_windows_fallback_uses_scripts_python_exe - test_execstart_not_uv_python_path - test_system_unit_execstart_uses_venv_python - test_user_unit_execstart_uses_venv_python All verify ExecStart uses venv/bin/python, not uv Python path. ## What I intentionally did not change - No changes to _detect_venv_dir() logic - No changes to generate_systemd_unit() venv_dir calculation - No opportunistic refactoring - No changes to launchd plist generation (similar pattern) Fixes #9201 --- hermes_cli/gateway.py | 11 ++ tests/hermes_cli/test_gateway_python_path.py | 152 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 tests/hermes_cli/test_gateway_python_path.py diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index fe7bb9bd8e4..b0ca475a665 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -742,6 +742,17 @@ def get_python_path() -> str: venv_python = venv / "bin" / "python" if venv_python.exists(): return str(venv_python) + + # Fallback: use PROJECT_ROOT/venv/bin/python (same logic as generate_systemd_unit) + # This is more reliable than sys.executable when running under uv. + fallback_venv = PROJECT_ROOT / "venv" + if is_windows(): + fallback_python = fallback_venv / "Scripts" / "python.exe" + else: + fallback_python = fallback_venv / "bin" / "python" + if fallback_python.exists(): + return str(fallback_python) + return sys.executable diff --git a/tests/hermes_cli/test_gateway_python_path.py b/tests/hermes_cli/test_gateway_python_path.py new file mode 100644 index 00000000000..952ce90127f --- /dev/null +++ b/tests/hermes_cli/test_gateway_python_path.py @@ -0,0 +1,152 @@ +"""Test for get_python_path() fallback to PROJECT_ROOT/venv/bin/python. + +Issue: #9201 - systemd service ExecStart uses uv Python instead of venv Python +when uv manages the Python environment. + +Root cause: get_python_path() fell back to sys.executable when _detect_venv_dir() +returned None, but this returns uv's Python path which has no packages installed. +""" + +import sys +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + + +class TestGetPythonPathFallback: + """Test that get_python_path() correctly falls back to PROJECT_ROOT/venv.""" + + def test_fallback_to_project_root_venv_when_detect_fails(self): + """When _detect_venv_dir() returns None, should use PROJECT_ROOT/venv.""" + from hermes_cli.gateway import get_python_path, PROJECT_ROOT + + # Mock _detect_venv_dir to return None (simulate uv environment) + with patch("hermes_cli.gateway._detect_venv_dir", return_value=None): + # Mock the venv python to exist + with patch.object(Path, "exists", return_value=True): + result = get_python_path() + + # Should be PROJECT_ROOT/venv/bin/python, not sys.executable + expected = str(PROJECT_ROOT / "venv" / "bin" / "python") + assert result == expected + + def test_detect_venv_dir_returns_valid_venv(self): + """When _detect_venv_dir() returns a valid venv, should use it.""" + from hermes_cli.gateway import get_python_path + + # Mock _detect_venv_dir to return a venv + mock_venv = MagicMock(spec=Path) + mock_venv.is_dir.return_value = True + + with patch("hermes_cli.gateway._detect_venv_dir", return_value=mock_venv): + # Mock the venv python to exist + mock_python = MagicMock(spec=Path) + mock_python.exists.return_value = True + + with patch.object(Path, "__truediv__", return_value=mock_python): + result = get_python_path() + + # Should return the venv python path + assert result is not None + + def test_fallback_chain_order(self): + """Fallback order should be: detected_venv -> PROJECT_ROOT/venv -> sys.executable.""" + from hermes_cli.gateway import get_python_path, PROJECT_ROOT + + # Test 1: detected_venv has priority + mock_venv = MagicMock(spec=Path) + mock_venv_python = MagicMock(spec=Path) + mock_venv_python.exists.return_value = True + + with patch("hermes_cli.gateway._detect_venv_dir", return_value=mock_venv): + with patch.object(Path, "__truediv__", return_value=mock_venv_python): + result = get_python_path() + # Should use detected_venv, not PROJECT_ROOT fallback + + # Test 2: PROJECT_ROOT/venv fallback when detect fails but venv exists + with patch("hermes_cli.gateway._detect_venv_dir", return_value=None): + with patch.object(Path, "exists", return_value=True): + result = get_python_path() + expected = str(PROJECT_ROOT / "venv" / "bin" / "python") + assert result == expected + + # Test 3: sys.executable when all fallbacks fail + with patch("hermes_cli.gateway._detect_venv_dir", return_value=None): + with patch.object(Path, "exists", return_value=False): + result = get_python_path() + assert result == sys.executable + + def test_consistency_with_venv_dir_in_systemd_unit(self): + """python_path should be consistent with venv_dir in generate_systemd_unit.""" + from hermes_cli.gateway import ( + get_python_path, + generate_systemd_unit, + PROJECT_ROOT, + _detect_venv_dir, + ) + + # Simulate scenario where _detect_venv_dir returns None + with patch("hermes_cli.gateway._detect_venv_dir", return_value=None): + with patch.object(Path, "exists", return_value=True): + python_path = get_python_path() + unit = generate_systemd_unit(system=False) + + # python_path should match venv/bin/python in ExecStart + assert "venv/bin/python" in python_path + assert python_path in unit + + def test_windows_fallback_uses_scripts_python_exe(self): + """On Windows, fallback should use venv/Scripts/python.exe.""" + from hermes_cli.gateway import get_python_path, PROJECT_ROOT + + with patch("hermes_cli.gateway._detect_venv_dir", return_value=None): + with patch("hermes_cli.gateway.is_windows", return_value=True): + with patch.object(Path, "exists", return_value=True): + result = get_python_path() + expected = str(PROJECT_ROOT / "venv" / "Scripts" / "python.exe") + assert result == expected + + def test_execstart_not_uv_python_path(self): + """ExecStart should NOT contain uv Python path patterns.""" + from hermes_cli.gateway import generate_systemd_unit + + with patch("hermes_cli.gateway._detect_venv_dir", return_value=None): + with patch.object(Path, "exists", return_value=True): + unit = generate_systemd_unit(system=False) + + # Should NOT have uv-specific paths + assert ".local/share/uv/python" not in unit + assert "cpython-" not in unit + # Should have venv path + assert "venv/bin/python" in unit + + +class TestSystemdUnitGeneration: + """Test systemd unit file generation with correct Python path.""" + + def test_system_unit_execstart_uses_venv_python(self): + """System unit ExecStart should use venv/bin/python.""" + from hermes_cli.gateway import generate_systemd_unit, PROJECT_ROOT + + with patch("hermes_cli.gateway._detect_venv_dir", return_value=None): + with patch.object(Path, "exists", return_value=True): + unit = generate_systemd_unit(system=True, run_as_user="root") + + # ExecStart should contain venv/bin/python + assert "venv/bin/python" in unit + assert "ExecStart=" in unit + + def test_user_unit_execstart_uses_venv_python(self): + """User unit ExecStart should use venv/bin/python.""" + from hermes_cli.gateway import generate_systemd_unit, PROJECT_ROOT + + with patch("hermes_cli.gateway._detect_venv_dir", return_value=None): + with patch.object(Path, "exists", return_value=True): + unit = generate_systemd_unit(system=False) + + # ExecStart should contain venv/bin/python + assert "venv/bin/python" in unit + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file