Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,40 @@ def _build_user_local_paths(home: Path, path_entries: list[str]) -> list[str]:
return [p for p in candidates if p not in path_entries and Path(p).exists()]


def _hermes_home_for_target_user(target_home_dir: str) -> str:
def _probe_target_user_hermes_home(username: str) -> str | None:
"""Return the target user's explicit HERMES_HOME from their login env."""
if not is_linux():
return None

probe_commands: list[list[str]] = []
if shutil.which("su"):
probe_commands.append(["su", "-", username, "-c", 'printf "%s" "${HERMES_HOME-}"'])
if shutil.which("sudo"):
probe_commands.append(["sudo", "-iu", username, "sh", "-lc", 'printf "%s" "${HERMES_HOME-}"'])

for cmd in probe_commands:
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
timeout=10,
)
except (OSError, subprocess.TimeoutExpired):
continue

if result.returncode != 0:
continue

value = (result.stdout or "").strip()
if value:
return value

return None


def _hermes_home_for_target_user(target_home_dir: str, current_hermes: str | Path | None = None) -> str:
"""Remap the current HERMES_HOME to the equivalent under a target user's home.

When installing a system service via sudo, get_hermes_home() resolves to
Expand All @@ -627,7 +660,7 @@ def _hermes_home_for_target_user(target_home_dir: str) -> str:
/root/.hermes/profiles/coder β†’ /home/alice/.hermes/profiles/coder
/opt/custom-hermes β†’ /opt/custom-hermes (kept as-is)
"""
current_hermes = get_hermes_home().resolve()
current_hermes = Path(current_hermes).expanduser().resolve() if current_hermes is not None else get_hermes_home().resolve()
current_default = (Path.home() / ".hermes").resolve()
target_default = Path(target_home_dir) / ".hermes"

Expand All @@ -644,6 +677,19 @@ def _hermes_home_for_target_user(target_home_dir: str) -> str:
return str(current_hermes)


def _resolve_system_service_hermes_home(username: str, target_home_dir: str) -> str:
"""Resolve HERMES_HOME for Linux system service units."""
explicit_current = (os.getenv("HERMES_HOME") or "").strip()
if explicit_current:
return _hermes_home_for_target_user(target_home_dir, current_hermes=explicit_current)

probed = _probe_target_user_hermes_home(username)
if probed:
return probed

return _hermes_home_for_target_user(target_home_dir)


def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str:
python_path = get_python_path()
working_dir = str(PROJECT_ROOT)
Expand All @@ -663,7 +709,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)

if system:
username, group_name, home_dir = _system_service_identity(run_as_user)
hermes_home = _hermes_home_for_target_user(home_dir)
hermes_home = _resolve_system_service_hermes_home(username, home_dir)
profile_arg = _profile_arg(hermes_home)
path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries))
path_entries.extend(common_bin_paths)
Expand Down
52 changes: 52 additions & 0 deletions tests/hermes_cli/test_gateway_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,38 @@ def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(se
assert "TimeoutStopSec=60" in unit
assert "WantedBy=multi-user.target" in unit

def test_system_unit_uses_probed_target_user_custom_hermes_home(self, monkeypatch):
monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root")))
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setattr(
gateway_cli, "_system_service_identity",
lambda run_as_user=None: ("alice", "alice", "/home/alice"),
)
monkeypatch.setattr(gateway_cli, "_probe_target_user_hermes_home", lambda username: "/opt/hermes-shared")
monkeypatch.setattr(gateway_cli, "_build_user_local_paths", lambda home, existing: [])

unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice")

assert 'HERMES_HOME=/opt/hermes-shared' in unit

def test_system_unit_uses_probed_target_user_profile_hermes_home(self, monkeypatch):
monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root")))
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setattr(
gateway_cli, "_system_service_identity",
lambda run_as_user=None: ("alice", "alice", "/home/alice"),
)
monkeypatch.setattr(
gateway_cli,
"_probe_target_user_hermes_home",
lambda username: "/home/alice/.hermes/profiles/coder",
)
monkeypatch.setattr(gateway_cli, "_build_user_local_paths", lambda home, existing: [])

unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice")

assert 'HERMES_HOME=/home/alice/.hermes/profiles/coder' in unit


class TestGatewayStopCleanup:
def test_stop_only_kills_current_profile_by_default(self, tmp_path, monkeypatch):
Expand Down Expand Up @@ -530,6 +562,26 @@ def test_noop_when_same_user(self, monkeypatch):
assert result == "/home/alice/.hermes"


class TestResolveSystemServiceHermesHome:
def test_prefers_explicit_current_process_hermes_home(self, monkeypatch):
monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root")))
monkeypatch.setenv("HERMES_HOME", "/root/.hermes/profiles/coder")
monkeypatch.setattr(gateway_cli, "_probe_target_user_hermes_home", lambda username: (_ for _ in ()).throw(AssertionError("probe should not run")))

result = gateway_cli._resolve_system_service_hermes_home("alice", "/home/alice")

assert result == "/home/alice/.hermes/profiles/coder"

def test_falls_back_to_default_when_probe_returns_empty(self, monkeypatch):
monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root")))
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setattr(gateway_cli, "_probe_target_user_hermes_home", lambda username: None)

result = gateway_cli._resolve_system_service_hermes_home("alice", "/home/alice")

assert result == "/home/alice/.hermes"


class TestGeneratedUnitUsesDetectedVenv:
def test_systemd_unit_uses_dot_venv_when_detected(self, tmp_path, monkeypatch):
dot_venv = tmp_path / ".venv"
Expand Down