diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index c1f7c04d7f2..69bf4ca6b25 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1430,7 +1430,7 @@ def _profile_suffix() -> str: return hashlib.sha256(str(home).encode()).hexdigest()[:8] -def _profile_arg(hermes_home: str | None = None) -> str: +def _profile_arg(hermes_home: str | None = None, default_root: str | Path | None = None) -> str: """Return ``--profile `` only when HERMES_HOME is a named profile. For ``~/.hermes/profiles/``, returns ``"--profile "``. @@ -1440,12 +1440,16 @@ def _profile_arg(hermes_home: str | None = None) -> str: hermes_home: Optional explicit HERMES_HOME path. Defaults to the current ``get_hermes_home()`` value. Should be passed when generating a service definition for a different user (e.g. system service). + default_root: Optional Hermes root to compare against. Used when + generating a system service for another user from a sudo/root + process, where ``Path.home()`` and ``get_default_hermes_root()`` + refer to root but the target profile lives under the service user. """ import re from hermes_constants import get_default_hermes_root home = Path(hermes_home or str(get_hermes_home())).resolve() - default = get_default_hermes_root().resolve() + default = Path(default_root).resolve() if default_root else get_default_hermes_root().resolve() if home == default: return "" profiles_root = (default / "profiles").resolve() @@ -1459,6 +1463,16 @@ def _profile_arg(hermes_home: str | None = None) -> str: return "" +def _profile_arg_for_target_user(hermes_home: str, target_home_dir: str) -> str: + """Return the profile arg for a system service running as another user.""" + target_root = Path(target_home_dir) / ".hermes" + try: + Path(hermes_home).resolve().relative_to(target_root.resolve()) + return _profile_arg(hermes_home, default_root=target_root) + except ValueError: + return _profile_arg(hermes_home) + + def get_service_name() -> str: """Derive a systemd service name scoped to this HERMES_HOME. @@ -2384,7 +2398,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) - profile_arg = _profile_arg(hermes_home) + profile_arg = _profile_arg_for_target_user(hermes_home, home_dir) # Remap all paths that may resolve under the calling user's home # (e.g. /root/) to the target user's home so the service can # actually access them. diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 81d6951e71e..714a61cae62 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -354,6 +354,37 @@ def _apply_profile_override() -> None: return False return True + def _resolve_sudo_user_profile_env(name: str) -> str | None: + """Resolve `sudo hermes -p ` against the invoking user's home. + + `_apply_profile_override()` runs before argparse, so `--run-as-user` + is not available yet. For sudo invocations, the best available signal + is SUDO_USER: root is only doing the privileged install/start action, + while the profile store normally belongs to the user who invoked sudo. + """ + if name == "default": + return None + if not hasattr(os, "geteuid") or os.geteuid() != 0: + return None + sudo_user = os.environ.get("SUDO_USER", "").strip() + if not sudo_user or sudo_user == "root": + return None + + try: + import pwd + + home = Path(pwd.getpwnam(sudo_user).pw_dir) + except Exception: + return None + + candidate = home / ".hermes" / "profiles" / name + try: + if candidate.is_dir(): + return str(candidate) + except OSError: + return None + return None + # 1. Check for explicit -p / --profile flag. Historically this worked even # after the subcommand (`hermes chat -p coder`), so keep scanning broadly. # The exception is command-argv passthrough regions such as `mcp add --args`. @@ -441,7 +472,12 @@ def _apply_profile_override() -> None: from hermes_cli.profiles import resolve_profile_env hermes_home = resolve_profile_env(profile_name) - except (ValueError, FileNotFoundError) as exc: + except FileNotFoundError as exc: + hermes_home = _resolve_sudo_user_profile_env(profile_name) + if not hermes_home: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + except ValueError as exc: print(f"Error: {exc}", file=sys.stderr) sys.exit(1) except Exception as exc: diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 7617089c055..ee810a1dd34 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -22,6 +22,7 @@ Usage:: import json import os import re +import shlex import shutil import stat import subprocess @@ -421,7 +422,8 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P else: wrapper_path = wrapper_dir / canon try: - wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {profile} "$@"\n') + hermes_exe = shutil.which("hermes") or "hermes" + wrapper_path.write_text(f'#!/bin/sh\nexec {shlex.quote(hermes_exe)} -p {profile} "$@"\n') wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) return wrapper_path except OSError as e: diff --git a/tests/hermes_cli/test_apply_profile_override.py b/tests/hermes_cli/test_apply_profile_override.py index 9159969d3b5..0d3c2956a40 100644 --- a/tests/hermes_cli/test_apply_profile_override.py +++ b/tests/hermes_cli/test_apply_profile_override.py @@ -14,6 +14,7 @@ from __future__ import annotations import os import sys from pathlib import Path +from types import SimpleNamespace @@ -124,6 +125,30 @@ class TestApplyProfileOverrideHermesHomeGuard: assert result is not None assert "coder" in result + def test_sudo_explicit_profile_resolves_invoking_users_profile(self, tmp_path, monkeypatch): + """sudo elias ... should resolve `-p elias` under SUDO_USER, not root.""" + root_home = tmp_path / "root" + user_home = tmp_path / "home" / "hermes" + profile_dir = user_home / ".hermes" / "profiles" / "elias" + profile_dir.mkdir(parents=True, exist_ok=True) + (root_home / ".hermes").mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(Path, "home", lambda: root_home) + monkeypatch.setenv("SUDO_USER", "hermes") + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.setattr(os, "geteuid", lambda: 0, raising=False) + monkeypatch.setattr(sys, "argv", ["hermes", "-p", "elias", "gateway", "install", "--system"]) + + import pwd + + monkeypatch.setattr(pwd, "getpwnam", lambda name: SimpleNamespace(pw_dir=str(user_home))) + + from hermes_cli.main import _apply_profile_override + _apply_profile_override() + + assert os.environ.get("HERMES_HOME") == str(profile_dir) + assert sys.argv == ["hermes", "gateway", "install", "--system"] + def test_hermes_home_unset_default_profile_no_redirect(self, tmp_path, monkeypatch): """active_profile=default must not redirect HERMES_HOME.""" hermes_root = tmp_path / ".hermes" diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 0c6d7ca836d..f9cdcc1f313 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -1980,6 +1980,16 @@ class TestProfileArg: result = gateway_cli._profile_arg(str(profile_dir)) assert result == "--profile mybot" + def test_named_profile_under_target_user_root_returns_flag(self, tmp_path): + """System installs generated under sudo must compare against target user's root.""" + target_root = tmp_path / "home" / "alice" / ".hermes" + profile_dir = target_root / "profiles" / "mybot" + profile_dir.mkdir(parents=True) + + result = gateway_cli._profile_arg(str(profile_dir), default_root=target_root) + + assert result == "--profile mybot" + def test_hash_path_returns_empty(self, tmp_path, monkeypatch): """Arbitrary non-profile HERMES_HOME should return empty string.""" custom_home = tmp_path / "custom" / "hermes" @@ -2023,6 +2033,28 @@ class TestProfileArg: # on the manual launchd fallback path — see test_launchd_plist_includes_profile.) assert "--replace" not in unit + def test_systemd_unit_for_target_user_includes_named_profile(self, tmp_path, monkeypatch): + """sudo system install must keep the target user's named profile in ExecStart.""" + root_home = tmp_path / "root" + target_home = tmp_path / "home" / "alice" + root_profile = root_home / ".hermes" / "profiles" / "mybot" + root_profile.mkdir(parents=True) + + monkeypatch.setattr(Path, "home", lambda: root_home) + monkeypatch.setenv("HERMES_HOME", str(root_profile)) + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: root_profile) + monkeypatch.setattr( + gateway_cli, + "_system_service_identity", + lambda run_as_user=None: ("alice", "alice", str(target_home)), + ) + + unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice") + + assert "ExecStart=" in unit + assert "--profile mybot gateway run" in unit + assert f'HERMES_HOME={target_home / ".hermes" / "profiles" / "mybot"}' in unit + def test_launchd_plist_includes_profile(self, tmp_path, monkeypatch): """generate_launchd_plist should include --profile in ProgramArguments for named profiles.""" profile_dir = tmp_path / ".hermes" / "profiles" / "mybot" diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 2a23b648baa..f700e42b1b4 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -760,13 +760,14 @@ class TestWrapperScript: def test_creates_sh_on_posix(self, profile_env, monkeypatch): monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr("hermes_cli.profiles.shutil.which", lambda name: "/opt/hermes/bin/hermes") from hermes_cli.profiles import create_wrapper_script wrapper = create_wrapper_script("mybot") assert wrapper is not None assert wrapper.name == "mybot" content = wrapper.read_text() assert content.startswith("#!/bin/sh") - assert "hermes -p mybot" in content + assert "exec /opt/hermes/bin/hermes -p mybot" in content def test_creates_bat_on_windows(self, profile_env, monkeypatch): monkeypatch.setattr("sys.platform", "win32")