diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index b19ceaac9afc..bb830bf755e6 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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 @@ -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" @@ -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) @@ -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) diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index aa21793ae464..e31f67283e49 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -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): @@ -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"