diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index b12d3b97c..30d4f633d 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -40,7 +40,9 @@ logger = logging.getLogger(__name__) MAX_MESSAGE_LENGTH = 4000 # Store directory for E2EE keys and sync state. -_STORE_DIR = Path.home() / ".hermes" / "matrix" / "store" +# Uses get_hermes_home() so each profile gets its own Matrix store. +from hermes_constants import get_hermes_home as _get_hermes_home +_STORE_DIR = _get_hermes_home() / "matrix" / "store" # Grace period: ignore messages older than this many seconds before startup. _STARTUP_GRACE_SECONDS = 5 diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 83753096f..926ac81d6 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -345,7 +345,8 @@ class TelegramAdapter(BasePlatformAdapter): def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None: """Save a newly created thread_id back into config.yaml so it persists across restarts.""" try: - config_path = _Path.home() / ".hermes" / "config.yaml" + from hermes_constants import get_hermes_home + config_path = get_hermes_home() / "config.yaml" if not config_path.exists(): logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path) return @@ -1757,7 +1758,8 @@ class TelegramAdapter(BasePlatformAdapter): recognized without a gateway restart. """ try: - config_path = _Path.home() / ".hermes" / "config.yaml" + from hermes_constants import get_hermes_home + config_path = get_hermes_home() / "config.yaml" if not config_path.exists(): return diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index b8a1faa0c..a94daf764 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -125,20 +125,43 @@ _SERVICE_BASE = "hermes-gateway" SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration" +def _profile_suffix() -> str: + """Derive a service-name suffix from the current HERMES_HOME. + + Returns ``""`` for the default ``~/.hermes``, the profile name for + ``~/.hermes/profiles/``, or a short hash for any other custom + HERMES_HOME path. + """ + import hashlib + import re + from pathlib import Path as _Path + home = get_hermes_home().resolve() + default = (_Path.home() / ".hermes").resolve() + if home == default: + return "" + # Detect ~/.hermes/profiles/ pattern → use the profile name + profiles_root = (default / "profiles").resolve() + try: + rel = home.relative_to(profiles_root) + parts = rel.parts + if len(parts) == 1 and re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", parts[0]): + return parts[0] + except ValueError: + pass + # Fallback: short hash for arbitrary HERMES_HOME paths + return hashlib.sha256(str(home).encode()).hexdigest()[:8] + + def get_service_name() -> str: """Derive a systemd service name scoped to this HERMES_HOME. Default ``~/.hermes`` returns ``hermes-gateway`` (backward compatible). - Any other HERMES_HOME appends a short hash so multiple installations - can each have their own systemd service without conflicting. + Profile ``~/.hermes/profiles/coder`` returns ``hermes-gateway-coder``. + Any other HERMES_HOME appends a short hash for uniqueness. """ - import hashlib - from pathlib import Path as _Path # local import to avoid monkeypatch interference - home = get_hermes_home().resolve() - default = (_Path.home() / ".hermes").resolve() - if home == default: + suffix = _profile_suffix() + if not suffix: return _SERVICE_BASE - suffix = hashlib.sha256(str(home).encode()).hexdigest()[:8] return f"{_SERVICE_BASE}-{suffix}" @@ -369,7 +392,14 @@ def print_systemd_linger_guidance() -> None: print(" sudo loginctl enable-linger $USER") def get_launchd_plist_path() -> Path: - return Path.home() / "Library" / "LaunchAgents" / "ai.hermes.gateway.plist" + """Return the launchd plist path, scoped per profile. + + Default ``~/.hermes`` → ``ai.hermes.gateway.plist`` (backward compatible). + Profile ``~/.hermes/profiles/coder`` → ``ai.hermes.gateway-coder.plist``. + """ + suffix = _profile_suffix() + name = f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway" + return Path.home() / "Library" / "LaunchAgents" / f"{name}.plist" def _detect_venv_dir() -> Path | None: """Detect the active virtualenv directory. @@ -769,18 +799,26 @@ def systemd_status(deep: bool = False, system: bool = False): # Launchd (macOS) # ============================================================================= +def get_launchd_label() -> str: + """Return the launchd service label, scoped per profile.""" + suffix = _profile_suffix() + return f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway" + + def generate_launchd_plist() -> str: python_path = get_python_path() working_dir = str(PROJECT_ROOT) + hermes_home = str(get_hermes_home().resolve()) log_dir = get_hermes_home() / "logs" log_dir.mkdir(parents=True, exist_ok=True) + label = get_launchd_label() return f""" Label - ai.hermes.gateway + {label} ProgramArguments @@ -795,6 +833,12 @@ def generate_launchd_plist() -> str: WorkingDirectory {working_dir} + EnvironmentVariables + + HERMES_HOME + {hermes_home} + + RunAtLoad @@ -882,18 +926,20 @@ def launchd_uninstall(): def launchd_start(): refresh_launchd_plist_if_needed() plist_path = get_launchd_plist_path() + label = get_launchd_label() try: - subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True) + subprocess.run(["launchctl", "start", label], check=True) except subprocess.CalledProcessError as e: if e.returncode != 3 or not plist_path.exists(): raise print("↻ launchd job was unloaded; reloading service definition") subprocess.run(["launchctl", "load", str(plist_path)], check=True) - subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True) + subprocess.run(["launchctl", "start", label], check=True) print("✓ Service started") def launchd_stop(): - subprocess.run(["launchctl", "stop", "ai.hermes.gateway"], check=True) + label = get_launchd_label() + subprocess.run(["launchctl", "stop", label], check=True) print("✓ Service stopped") def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0): @@ -948,8 +994,9 @@ def launchd_restart(): def launchd_status(deep: bool = False): plist_path = get_launchd_plist_path() + label = get_launchd_label() result = subprocess.run( - ["launchctl", "list", "ai.hermes.gateway"], + ["launchctl", "list", label], capture_output=True, text=True ) @@ -1454,7 +1501,7 @@ def _is_service_running() -> bool: return False elif is_macos() and get_launchd_plist_path().exists(): result = subprocess.run( - ["launchctl", "list", "ai.hermes.gateway"], + ["launchctl", "list", get_launchd_label()], capture_output=True, text=True ) return result.returncode == 0 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 375e28333..e70cd2520 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2968,10 +2968,11 @@ def cmd_update(args): # Check for macOS launchd service if is_macos(): try: + from hermes_cli.gateway import get_launchd_label plist_path = get_launchd_plist_path() if plist_path.exists(): check = subprocess.run( - ["launchctl", "list", "ai.hermes.gateway"], + ["launchctl", "list", get_launchd_label()], capture_output=True, text=True, timeout=5, ) has_launchd_service = check.returncode == 0 @@ -3027,12 +3028,13 @@ def cmd_update(args): # after a manual SIGTERM, which would race with the # PID file cleanup. print("→ Restarting gateway service...") + _launchd_label = get_launchd_label() stop = subprocess.run( - ["launchctl", "stop", "ai.hermes.gateway"], + ["launchctl", "stop", _launchd_label], capture_output=True, text=True, timeout=10, ) start = subprocess.run( - ["launchctl", "start", "ai.hermes.gateway"], + ["launchctl", "start", _launchd_label], capture_output=True, text=True, timeout=10, ) if start.returncode == 0: diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 01f46b766..c622fca7b 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -292,8 +292,9 @@ def show_status(args): print(" Manager: systemd (user)") elif sys.platform == 'darwin': + from hermes_cli.gateway import get_launchd_label result = subprocess.run( - ["launchctl", "list", "ai.hermes.gateway"], + ["launchctl", "list", get_launchd_label()], capture_output=True, text=True ) diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index 168f1e817..e71d3f82c 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -10,6 +10,7 @@ Covers: """ import asyncio +import os import sys from pathlib import Path from types import SimpleNamespace @@ -227,7 +228,8 @@ def test_persist_dm_topic_thread_id_writes_config(tmp_path): adapter = _make_adapter() - with patch.object(Path, "home", return_value=tmp_path): + with patch.object(Path, "home", return_value=tmp_path), \ + patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}): adapter._persist_dm_topic_thread_id(111, "General", 999) with open(config_file) as f: @@ -366,7 +368,8 @@ def test_get_dm_topic_info_hot_reloads_from_config(tmp_path): with open(config_file, "w") as f: yaml.dump(config_data, f) - with patch.object(Path, "home", return_value=tmp_path): + with patch.object(Path, "home", return_value=tmp_path), \ + patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}): result = adapter._get_dm_topic_info("111", "555") assert result is not None diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 12bae0f31..87daa845b 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -153,12 +153,13 @@ class TestLaunchdServiceRecovery: def test_launchd_start_reloads_unloaded_job_and_retries(self, tmp_path, monkeypatch): plist_path = tmp_path / "ai.hermes.gateway.plist" plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8") + label = gateway_cli.get_launchd_label() calls = [] def fake_run(cmd, check=False, **kwargs): calls.append(cmd) - if cmd == ["launchctl", "start", "ai.hermes.gateway"] and calls.count(cmd) == 1: + if cmd == ["launchctl", "start", label] and calls.count(cmd) == 1: raise gateway_cli.subprocess.CalledProcessError(3, cmd, stderr="Could not find service") return SimpleNamespace(returncode=0, stdout="", stderr="") @@ -168,9 +169,9 @@ class TestLaunchdServiceRecovery: gateway_cli.launchd_start() assert calls == [ - ["launchctl", "start", "ai.hermes.gateway"], + ["launchctl", "start", label], ["launchctl", "load", str(plist_path)], - ["launchctl", "start", "ai.hermes.gateway"], + ["launchctl", "start", label], ] def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys): diff --git a/tools/file_tools.py b/tools/file_tools.py index 519178c00..7387c4dcb 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -171,8 +171,9 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = # Security: block direct reads of internal Hermes cache/index files # to prevent prompt injection via catalog or hub metadata files. import pathlib as _pathlib + from hermes_constants import get_hermes_home as _get_hh _resolved = _pathlib.Path(path).expanduser().resolve() - _hermes_home = _pathlib.Path("~/.hermes").expanduser().resolve() + _hermes_home = _get_hh().resolve() _blocked_dirs = [ _hermes_home / "skills" / ".hub" / "index-cache", _hermes_home / "skills" / ".hub",