From e905768ffd7fcc6f5e2336167b0e5b876a9df573 Mon Sep 17 00:00:00 2001 From: Dean Kerr Date: Wed, 1 Apr 2026 23:00:51 +1100 Subject: [PATCH] fix(gateway): remap HERMES_HOME to target user in system service unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `sudo hermes gateway install --system --run-as-user ` generates the systemd unit, get_hermes_home() resolves to /root/.hermes because Path.home() returns root's home under sudo. The unit correctly sets HOME= and User= via _system_service_identity(), but HERMES_HOME was computed independently and pointed to root's config directory. Add _hermes_home_for_target_user() which remaps the current HERMES_HOME to the equivalent path under the target user's home. This handles: - Default ~/.hermes → target user's ~/.hermes - Profiles (e.g. ~/.hermes/profiles/coder) → preserves relative structure - Custom paths (e.g. /opt/hermes) → kept as-is Supersedes #3861 which only handled the default case and left profiles broken (also flagged by Copilot review). Co-Authored-By: Claude Opus 4.6 (1M context) --- hermes_cli/gateway.py | 30 +++++++- tests/hermes_cli/test_gateway_service.py | 96 ++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index ba2922771..a88552e2e 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 87daa845b..96215e6ed 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"