fix(gateway): resolve sudo profile system installs

This commit is contained in:
helix4u 2026-06-13 15:38:01 -06:00 committed by Teknium
parent 1f5eef8093
commit d76a58bd15
6 changed files with 116 additions and 6 deletions

View file

@ -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 <name>`` only when HERMES_HOME is a named profile.
For ``~/.hermes/profiles/<name>``, returns ``"--profile <name>"``.
@ -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.

View file

@ -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 <name>` 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:

View file

@ -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:

View file

@ -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"

View file

@ -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"

View file

@ -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")