diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 548f7b45270c..d7d7f4b7d51e 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -613,14 +613,28 @@ def _remap_path_for_user(path: str, target_home_dir: str) -> str: /root/.hermes/hermes-agent -> /home/alice/.hermes/hermes-agent /opt/hermes -> /opt/hermes (kept as-is) + + The lexical (unresolved) form of *path* is preferred so that symlinks + pointing outside of $HOME — notably the interpreter inside a uv-managed + venv, whose ``venv/bin/python`` is a symlink into uv's shared Python + store — are preserved verbatim. Resolving those would emit the bare + interpreter in ExecStart and bypass the venv's site-packages at runtime. """ - current_home = Path.home().resolve() - resolved = Path(path).resolve() + source = Path(path) + current_home = Path.home() + for home_candidate in (current_home, current_home.resolve()): + try: + relative = source.relative_to(home_candidate) + return str(Path(target_home_dir) / relative) + except ValueError: + continue + # Fallback: compare resolved forms so a symlinked $HOME + # (e.g. /home/alice -> /mnt/users/alice) still remaps cleanly. try: - relative = resolved.relative_to(current_home) + relative = source.resolve().relative_to(current_home.resolve()) return str(Path(target_home_dir) / relative) except ValueError: - return str(resolved) + return path def _hermes_home_for_target_user(target_home_dir: str) -> str: diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index b32c7fe78732..71b975dce912 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -843,6 +843,30 @@ def test_noop_when_same_user(self, monkeypatch, tmp_path): result = gateway_cli._remap_path_for_user(original, str(tmp_path / "alice")) assert result == original + def test_preserves_symlink_pointing_outside_home(self, monkeypatch, tmp_path): + """Regression: uv-managed venv has venv/bin/python as a symlink to an + interpreter outside $HOME. _remap_path_for_user must preserve the + lexical path so ExecStart points at the venv interpreter — resolving + the symlink would emit the bare python and bypass site-packages. + """ + root_home = tmp_path / "root" + root_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: root_home) + + # Simulate a uv-style venv: venv/bin/python -> external interpreter + venv_bin = root_home / "src" / "hermes-agent" / "venv" / "bin" + venv_bin.mkdir(parents=True) + external_python = tmp_path / "uv-store" / "python3.11" + external_python.parent.mkdir(parents=True) + external_python.write_text("") + venv_python = venv_bin / "python" + venv_python.symlink_to(external_python) + + result = gateway_cli._remap_path_for_user(str(venv_python), "/home/alice") + + assert result == "/home/alice/src/hermes-agent/venv/bin/python" + assert "uv-store" not in result + class TestSystemUnitPathRemapping: """System units must remap ALL paths from the caller's home to the target user."""