fix(gateway): fall back to sys.executable -m hermes_cli.main when hermes not on PATH
When shutil.which('hermes') returns None, _resolve_hermes_bin() now tries
sys.executable -m hermes_cli.main as a fallback. This handles setups where
Hermes is launched via a venv or module invocation and the hermes symlink is
not on PATH for the gateway process.
Fixes #1049
This commit is contained in:
parent
c207a6b302
commit
f3a38c90fc
2 changed files with 107 additions and 7 deletions
|
|
@ -215,6 +215,33 @@ def _resolve_gateway_model() -> str:
|
||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_hermes_bin() -> Optional[list[str]]:
|
||||||
|
"""Resolve the Hermes update command as argv parts.
|
||||||
|
|
||||||
|
Tries in order:
|
||||||
|
1. ``shutil.which("hermes")`` — standard PATH lookup
|
||||||
|
2. ``sys.executable -m hermes_cli.main`` — fallback when Hermes is running
|
||||||
|
from a venv/module invocation and the ``hermes`` shim is not on PATH
|
||||||
|
|
||||||
|
Returns argv parts ready for quoting/joining, or ``None`` if neither works.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
hermes_bin = shutil.which("hermes")
|
||||||
|
if hermes_bin:
|
||||||
|
return [hermes_bin]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
if importlib.util.find_spec("hermes_cli") is not None:
|
||||||
|
return [sys.executable, "-m", "hermes_cli.main"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class GatewayRunner:
|
class GatewayRunner:
|
||||||
"""
|
"""
|
||||||
Main gateway controller.
|
Main gateway controller.
|
||||||
|
|
@ -3155,9 +3182,14 @@ class GatewayRunner:
|
||||||
if not git_dir.exists():
|
if not git_dir.exists():
|
||||||
return "✗ Not a git repository — cannot update."
|
return "✗ Not a git repository — cannot update."
|
||||||
|
|
||||||
hermes_bin = shutil.which("hermes")
|
hermes_cmd = _resolve_hermes_bin()
|
||||||
if not hermes_bin:
|
if not hermes_cmd:
|
||||||
return "✗ `hermes` command not found on PATH."
|
return (
|
||||||
|
"✗ Could not locate the `hermes` command. "
|
||||||
|
"Hermes is running, but the update command could not find the "
|
||||||
|
"executable on PATH or via the current Python interpreter. "
|
||||||
|
"Try running `hermes update` manually in your terminal."
|
||||||
|
)
|
||||||
|
|
||||||
pending_path = _hermes_home / ".update_pending.json"
|
pending_path = _hermes_home / ".update_pending.json"
|
||||||
output_path = _hermes_home / ".update_output.txt"
|
output_path = _hermes_home / ".update_output.txt"
|
||||||
|
|
@ -3173,8 +3205,9 @@ class GatewayRunner:
|
||||||
|
|
||||||
# Spawn `hermes update` in a separate cgroup so it survives gateway
|
# Spawn `hermes update` in a separate cgroup so it survives gateway
|
||||||
# restart. systemd-run --user --scope creates a transient scope unit.
|
# restart. systemd-run --user --scope creates a transient scope unit.
|
||||||
|
hermes_cmd_str = " ".join(shlex.quote(part) for part in hermes_cmd)
|
||||||
update_cmd = (
|
update_cmd = (
|
||||||
f"{shlex.quote(hermes_bin)} update > {shlex.quote(str(output_path))} 2>&1; "
|
f"{hermes_cmd_str} update > {shlex.quote(str(output_path))} 2>&1; "
|
||||||
f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}"
|
f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ class TestHandleUpdateCommand:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_hermes_binary(self, tmp_path):
|
async def test_no_hermes_binary(self, tmp_path):
|
||||||
"""Returns error when hermes is not on PATH."""
|
"""Returns error when hermes is not on PATH and hermes_cli is not importable."""
|
||||||
runner = _make_runner()
|
runner = _make_runner()
|
||||||
event = _make_event()
|
event = _make_event()
|
||||||
|
|
||||||
|
|
@ -102,10 +102,77 @@ class TestHandleUpdateCommand:
|
||||||
|
|
||||||
with patch("gateway.run._hermes_home", tmp_path), \
|
with patch("gateway.run._hermes_home", tmp_path), \
|
||||||
patch("gateway.run.__file__", fake_file), \
|
patch("gateway.run.__file__", fake_file), \
|
||||||
patch("shutil.which", return_value=None):
|
patch("shutil.which", return_value=None), \
|
||||||
|
patch("importlib.util.find_spec", return_value=None):
|
||||||
result = await runner._handle_update_command(event)
|
result = await runner._handle_update_command(event)
|
||||||
|
|
||||||
assert "not found on PATH" in result
|
assert "Could not locate" in result
|
||||||
|
assert "hermes update" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fallback_to_sys_executable(self, tmp_path):
|
||||||
|
"""Falls back to sys.executable -m hermes_cli.main when hermes not on PATH."""
|
||||||
|
runner = _make_runner()
|
||||||
|
event = _make_event()
|
||||||
|
|
||||||
|
fake_root = tmp_path / "project"
|
||||||
|
fake_root.mkdir()
|
||||||
|
(fake_root / ".git").mkdir()
|
||||||
|
(fake_root / "gateway").mkdir()
|
||||||
|
(fake_root / "gateway" / "run.py").touch()
|
||||||
|
fake_file = str(fake_root / "gateway" / "run.py")
|
||||||
|
hermes_home = tmp_path / "hermes"
|
||||||
|
hermes_home.mkdir()
|
||||||
|
|
||||||
|
mock_popen = MagicMock()
|
||||||
|
fake_spec = MagicMock()
|
||||||
|
|
||||||
|
with patch("gateway.run._hermes_home", hermes_home), \
|
||||||
|
patch("gateway.run.__file__", fake_file), \
|
||||||
|
patch("shutil.which", return_value=None), \
|
||||||
|
patch("importlib.util.find_spec", return_value=fake_spec), \
|
||||||
|
patch("subprocess.Popen", mock_popen):
|
||||||
|
result = await runner._handle_update_command(event)
|
||||||
|
|
||||||
|
assert "Starting Hermes update" in result
|
||||||
|
call_args = mock_popen.call_args[0][0]
|
||||||
|
# The update_cmd uses sys.executable -m hermes_cli.main
|
||||||
|
joined = " ".join(call_args) if isinstance(call_args, list) else call_args
|
||||||
|
assert "hermes_cli.main" in joined or "bash" in call_args[0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_hermes_bin_prefers_which(self, tmp_path):
|
||||||
|
"""_resolve_hermes_bin returns argv parts from shutil.which when available."""
|
||||||
|
from gateway.run import _resolve_hermes_bin
|
||||||
|
|
||||||
|
with patch("shutil.which", return_value="/custom/path/hermes"):
|
||||||
|
result = _resolve_hermes_bin()
|
||||||
|
|
||||||
|
assert result == ["/custom/path/hermes"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_hermes_bin_fallback(self):
|
||||||
|
"""_resolve_hermes_bin falls back to sys.executable argv when which fails."""
|
||||||
|
import sys
|
||||||
|
from gateway.run import _resolve_hermes_bin
|
||||||
|
|
||||||
|
fake_spec = MagicMock()
|
||||||
|
with patch("shutil.which", return_value=None), \
|
||||||
|
patch("importlib.util.find_spec", return_value=fake_spec):
|
||||||
|
result = _resolve_hermes_bin()
|
||||||
|
|
||||||
|
assert result == [sys.executable, "-m", "hermes_cli.main"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_hermes_bin_returns_none_when_both_fail(self):
|
||||||
|
"""_resolve_hermes_bin returns None when both strategies fail."""
|
||||||
|
from gateway.run import _resolve_hermes_bin
|
||||||
|
|
||||||
|
with patch("shutil.which", return_value=None), \
|
||||||
|
patch("importlib.util.find_spec", return_value=None):
|
||||||
|
result = _resolve_hermes_bin()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_writes_pending_marker(self, tmp_path):
|
async def test_writes_pending_marker(self, tmp_path):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue