diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index ba2922771a..a88552e2ee 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -463,6 +463,32 @@ 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: + """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 + root's home. This translates it to the target user's equivalent path: + /root/.hermes → /home/alice/.hermes + /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_default = (Path.home() / ".hermes").resolve() + target_default = Path(target_home_dir) / ".hermes" + + # Default ~/.hermes → remap to target user's default + if current_hermes == current_default: + return str(target_default) + + # Profile or subdir of ~/.hermes → preserve the relative structure + try: + relative = current_hermes.relative_to(current_default) + return str(target_default / relative) + except ValueError: + # Completely custom path (not under ~/.hermes) — keep as-is + return str(current_hermes) + + def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str: python_path = get_python_path() working_dir = str(PROJECT_ROOT) @@ -478,12 +504,11 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) if resolved_node_dir not in path_entries: path_entries.append(resolved_node_dir) - hermes_home = str(get_hermes_home().resolve()) - common_bin_paths = ["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"] if system: username, group_name, home_dir = _system_service_identity(run_as_user) + hermes_home = _hermes_home_for_target_user(home_dir) path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries)) path_entries.extend(common_bin_paths) sane_path = ":".join(path_entries) @@ -518,6 +543,7 @@ StandardError=journal WantedBy=multi-user.target """ + hermes_home = str(get_hermes_home().resolve()) path_entries.extend(_build_user_local_paths(Path.home(), path_entries)) path_entries.extend(common_bin_paths) sane_path = ":".join(path_entries) diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 87daa845b9..96215e6ed9 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -339,6 +339,102 @@ class TestDetectVenvDir: assert result is None +class TestSystemUnitHermesHome: + """HERMES_HOME in system units must reference the target user, not root.""" + + def test_system_unit_uses_target_user_home_not_calling_user(self, monkeypatch): + # Simulate sudo: Path.home() returns /root, target user is alice + 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, "_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' in unit + assert '/root/.hermes' not in unit + + def test_system_unit_remaps_profile_to_target_user(self, monkeypatch): + # Simulate sudo with a profile: HERMES_HOME was resolved under root + monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root"))) + monkeypatch.setenv("HERMES_HOME", "/root/.hermes/profiles/coder") + monkeypatch.setattr( + gateway_cli, "_system_service_identity", + lambda run_as_user=None: ("alice", "alice", "/home/alice"), + ) + 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 + assert '/root/' not in unit + + def test_system_unit_preserves_custom_hermes_home(self, monkeypatch): + # Custom HERMES_HOME not under any user's home — keep as-is + monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root"))) + monkeypatch.setenv("HERMES_HOME", "/opt/hermes-shared") + monkeypatch.setattr( + gateway_cli, "_system_service_identity", + lambda run_as_user=None: ("alice", "alice", "/home/alice"), + ) + 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_user_unit_unaffected_by_change(self): + # User-scope units should still use the calling user's HERMES_HOME + unit = gateway_cli.generate_systemd_unit(system=False) + + hermes_home = str(gateway_cli.get_hermes_home().resolve()) + assert f'HERMES_HOME={hermes_home}' in unit + + +class TestHermesHomeForTargetUser: + """Unit tests for _hermes_home_for_target_user().""" + + def test_remaps_default_home(self, monkeypatch): + monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root"))) + monkeypatch.delenv("HERMES_HOME", raising=False) + + result = gateway_cli._hermes_home_for_target_user("/home/alice") + assert result == "/home/alice/.hermes" + + def test_remaps_profile_path(self, monkeypatch): + monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root"))) + monkeypatch.setenv("HERMES_HOME", "/root/.hermes/profiles/coder") + + result = gateway_cli._hermes_home_for_target_user("/home/alice") + assert result == "/home/alice/.hermes/profiles/coder" + + def test_keeps_custom_path(self, monkeypatch): + monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root"))) + monkeypatch.setenv("HERMES_HOME", "/opt/hermes") + + result = gateway_cli._hermes_home_for_target_user("/home/alice") + assert result == "/opt/hermes" + + def test_noop_when_same_user(self, monkeypatch): + monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/home/alice"))) + monkeypatch.delenv("HERMES_HOME", raising=False) + + result = gateway_cli._hermes_home_for_target_user("/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"