Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 77 additions & 5 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,32 @@ def check_info(text: str):
print(f" {color('→', Colors.CYAN)} {text}")


def _current_username() -> str:
"""Return the current username for diagnostics."""
try:
import pwd

return pwd.getpwuid(os.getuid()).pw_name
except Exception:
return os.environ.get("USER") or os.environ.get("LOGNAME") or str(os.getuid())


def _macos_console_username() -> str | None:
"""Return the logged-in macOS console user when one exists."""
if sys.platform != "darwin":
return None

try:
import pwd

console_uid = os.stat("/dev/console").st_uid
if console_uid <= 0:
return None
return pwd.getpwuid(console_uid).pw_name
except Exception:
return None


def _check_gateway_service_linger(issues: list[str]) -> None:
"""Warn when a systemd user gateway service will stop after logout."""
try:
Expand Down Expand Up @@ -162,6 +188,52 @@ def _check_gateway_service_linger(issues: list[str]) -> None:
check_warn("Could not verify systemd linger", f"({linger_detail})")


def _check_gateway_service_launchd_session(issues: list[str]) -> None:
"""Warn when a macOS LaunchAgent is installed under a non-console user."""
if _is_termux():
return

try:
from hermes_cli.gateway import (
get_launchd_plist_path,
is_macos,
)
except Exception as e:
check_warn("Gateway service launchd session", f"(could not import gateway helpers: {e})")
return

if not is_macos():
return

plist_path = get_launchd_plist_path()
if not plist_path.exists():
return

current_user = _current_username()
console_user = _macos_console_username()
if console_user == current_user:
return

print()
print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD))
if console_user:
check_warn(
"LaunchAgent user is not the logged-in macOS user",
f"({current_user} is running Hermes; console user is {console_user})",
)
else:
check_warn(
"No logged-in macOS console user detected",
"(launchd user agents start inside a desktop login session)",
)
check_info("Use a system LaunchDaemon for headless/background deployments")
check_info("Or keep 'hermes gateway run' inside tmux/screen")
issues.append(
"macOS launchd user agents require the logged-in desktop account; "
"use a LaunchDaemon or tmux for headless deployments"
)


def run_doctor(args):
"""Run diagnostic checks."""
should_fix = getattr(args, 'fix', False)
Expand Down Expand Up @@ -613,6 +685,7 @@ def run_doctor(args):
pass

_check_gateway_service_linger(issues)
_check_gateway_service_launchd_session(issues)

# =========================================================================
# Check: Command installation (hermes bin symlink)
Expand Down Expand Up @@ -715,7 +788,9 @@ def run_doctor(args):

# Docker (optional)
terminal_env = os.getenv("TERMINAL_ENV", "local")
if terminal_env == "docker":
if _is_termux():
check_info("Docker backend is not available inside Termux (expected on Android)")
elif terminal_env == "docker":
if shutil.which("docker"):
# Check if docker daemon is running
try:
Expand All @@ -734,10 +809,7 @@ def run_doctor(args):
if shutil.which("docker"):
check_ok("docker", "(optional)")
else:
if _is_termux():
check_info("Docker backend is not available inside Termux (expected on Android)")
else:
check_warn("docker not found", "(optional)")
check_warn("docker not found", "(optional)")

# SSH (if using ssh backend)
if terminal_env == "ssh":
Expand Down
56 changes: 49 additions & 7 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import subprocess
import sys
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path

PROJECT_ROOT = Path(__file__).parent.parent.resolve()
Expand Down Expand Up @@ -1652,8 +1653,32 @@ def get_launchd_label() -> str:
return f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway"


@lru_cache(maxsize=1)
def _launchd_managername() -> str | None:
"""Return the current launchd session type when available."""
try:
result = subprocess.run(
["launchctl", "managername"],
capture_output=True,
text=True,
timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None

if result.returncode != 0:
return None

name = result.stdout.strip()
return name or None


def _launchd_domain() -> str:
return f"gui/{os.getuid()}"
uid = os.getuid()
manager = (_launchd_managername() or "").strip().lower()
if manager == "background":
return f"user/{uid}"
return f"gui/{uid}"


def generate_launchd_plist() -> str:
Expand Down Expand Up @@ -1816,26 +1841,43 @@ def launchd_uninstall():
def launchd_start():
plist_path = get_launchd_plist_path()
label = get_launchd_label()
domain = _launchd_domain()

# Self-heal if the plist is missing entirely (e.g., manual cleanup, failed upgrade)
if not plist_path.exists():
print("↻ launchd plist missing; regenerating service definition")
plist_path.parent.mkdir(parents=True, exist_ok=True)
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
subprocess.run(["launchctl", "bootstrap", domain, str(plist_path)], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", f"{domain}/{label}"], check=True, timeout=30)
print("✓ Service started")
return

refresh_launchd_plist_if_needed()
loaded = False
try:
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
result = subprocess.run(
["launchctl", "list", label],
capture_output=True,
text=True,
timeout=10,
)
loaded = result.returncode == 0
except subprocess.TimeoutExpired:
loaded = False

if not loaded:
print("↻ launchd job was unloaded; reloading service definition")
subprocess.run(["launchctl", "bootstrap", domain, str(plist_path)], check=True, timeout=30)

try:
subprocess.run(["launchctl", "kickstart", f"{domain}/{label}"], check=True, timeout=30)
except subprocess.CalledProcessError as e:
if e.returncode not in (3, 113):
if e.returncode not in (3, 113, 125):
raise
print("↻ launchd job was unloaded; reloading service definition")
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
subprocess.run(["launchctl", "bootstrap", domain, str(plist_path)], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", f"{domain}/{label}"], check=True, timeout=30)
print("✓ Service started")

def launchd_stop():
Expand Down
40 changes: 40 additions & 0 deletions tests/hermes_cli/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,46 @@ def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypat
assert issues == []


def test_check_gateway_service_launchd_session_warns_for_non_console_user(monkeypatch, tmp_path, capsys):
plist_path = tmp_path / "ai.hermes.gateway.plist"
plist_path.write_text("<plist/>\n")

monkeypatch.setattr(gateway_cli, "is_macos", lambda: True)
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
monkeypatch.setattr(doctor, "_current_username", lambda: "oc_runtime")
monkeypatch.setattr(doctor, "_macos_console_username", lambda: "svc_oc")

issues = []
doctor._check_gateway_service_launchd_session(issues)

out = capsys.readouterr().out
assert "Gateway Service" in out
assert "LaunchAgent user is not the logged-in macOS user" in out
assert "oc_runtime" in out
assert "svc_oc" in out
assert "LaunchDaemon" in out
assert issues == [
"macOS launchd user agents require the logged-in desktop account; use a LaunchDaemon or tmux for headless deployments"
]


def test_check_gateway_service_launchd_session_skips_when_console_user_matches(monkeypatch, tmp_path, capsys):
plist_path = tmp_path / "ai.hermes.gateway.plist"
plist_path.write_text("<plist/>\n")

monkeypatch.setattr(gateway_cli, "is_macos", lambda: True)
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
monkeypatch.setattr(doctor, "_current_username", lambda: "oc_runtime")
monkeypatch.setattr(doctor, "_macos_console_username", lambda: "oc_runtime")

issues = []
doctor._check_gateway_service_launchd_session(issues)

out = capsys.readouterr().out
assert out == ""
assert issues == []


# ── Memory provider section (doctor should only check the *active* provider) ──


Expand Down
51 changes: 51 additions & 0 deletions tests/hermes_cli/test_gateway_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
)


def _pin_launchd_manager(monkeypatch, name="Aqua"):
monkeypatch.setattr(gateway_cli, "_launchd_managername", lambda: name)


class TestSystemdServiceRefresh:
def test_systemd_install_repairs_outdated_unit_without_force(self, tmp_path, monkeypatch):
unit_path = tmp_path / "hermes-gateway.service"
Expand Down Expand Up @@ -195,6 +199,7 @@ def test_launchd_install_repairs_outdated_plist_without_force(self, tmp_path, mo
plist_path = tmp_path / "ai.hermes.gateway.plist"
plist_path.write_text("<plist>old content</plist>", encoding="utf-8")

_pin_launchd_manager(monkeypatch)
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)

calls = []
Expand All @@ -220,6 +225,7 @@ def test_launchd_start_reloads_unloaded_job_and_retries(self, tmp_path, monkeypa
plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8")
label = gateway_cli.get_launchd_label()

_pin_launchd_manager(monkeypatch)
calls = []
domain = gateway_cli._launchd_domain()
target = f"{domain}/{label}"
Expand All @@ -236,6 +242,7 @@ def fake_run(cmd, check=False, **kwargs):
gateway_cli.launchd_start()

assert calls == [
["launchctl", "list", label],
["launchctl", "kickstart", target],
["launchctl", "bootstrap", domain, str(plist_path)],
["launchctl", "kickstart", target],
Expand All @@ -247,6 +254,7 @@ def test_launchd_start_reloads_on_kickstart_exit_code_113(self, tmp_path, monkey
plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8")
label = gateway_cli.get_launchd_label()

_pin_launchd_manager(monkeypatch)
calls = []
domain = gateway_cli._launchd_domain()
target = f"{domain}/{label}"
Expand All @@ -263,12 +271,41 @@ def fake_run(cmd, check=False, **kwargs):
gateway_cli.launchd_start()

assert calls == [
["launchctl", "list", label],
["launchctl", "kickstart", target],
["launchctl", "bootstrap", domain, str(plist_path)],
["launchctl", "kickstart", target],
]

def test_launchd_start_bootstraps_before_kickstart_when_label_is_unloaded(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()

_pin_launchd_manager(monkeypatch)
calls = []
domain = gateway_cli._launchd_domain()
target = f"{domain}/{label}"

def fake_run(cmd, check=False, **kwargs):
calls.append(cmd)
if cmd == ["launchctl", "list", label]:
return SimpleNamespace(returncode=1, stdout="", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")

monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)

gateway_cli.launchd_start()

assert calls == [
["launchctl", "list", label],
["launchctl", "bootstrap", domain, str(plist_path)],
["launchctl", "kickstart", target],
]

def test_launchd_restart_drains_running_gateway_before_kickstart(self, monkeypatch):
_pin_launchd_manager(monkeypatch)
calls = []
target = f"{gateway_cli._launchd_domain()}/{gateway_cli.get_launchd_label()}"

Expand Down Expand Up @@ -297,6 +334,7 @@ def fake_run(cmd, check=False, **kwargs):
def test_launchd_restart_self_requests_graceful_restart_without_kickstart(self, monkeypatch, capsys):
calls = []

_pin_launchd_manager(monkeypatch)
monkeypatch.setattr(
"gateway.status.get_running_pid",
lambda: 321,
Expand All @@ -319,6 +357,7 @@ def test_launchd_restart_self_requests_graceful_restart_without_kickstart(self,

def test_launchd_stop_uses_bootout_not_kill(self, monkeypatch):
"""launchd_stop must bootout the service so KeepAlive doesn't respawn it."""
_pin_launchd_manager(monkeypatch)
label = gateway_cli.get_launchd_label()
domain = gateway_cli._launchd_domain()
target = f"{domain}/{label}"
Expand All @@ -338,6 +377,7 @@ def fake_run(cmd, check=False, **kwargs):

def test_launchd_stop_tolerates_already_unloaded(self, monkeypatch, capsys):
"""launchd_stop silently handles exit codes 3/113 (job not loaded)."""
_pin_launchd_manager(monkeypatch)
label = gateway_cli.get_launchd_label()
domain = gateway_cli._launchd_domain()
target = f"{domain}/{label}"
Expand All @@ -358,6 +398,7 @@ def fake_run(cmd, check=False, **kwargs):

def test_launchd_stop_waits_for_process_exit(self, monkeypatch):
"""launchd_stop calls _wait_for_gateway_exit after bootout."""
_pin_launchd_manager(monkeypatch)
wait_called = []

def fake_run(cmd, check=False, **kwargs):
Expand All @@ -374,6 +415,16 @@ def fake_wait(**kwargs):
assert len(wait_called) == 1
assert wait_called[0] == {"timeout": 10.0, "force_after": 5.0}

def test_launchd_domain_uses_user_scope_for_background_sessions(self, monkeypatch):
_pin_launchd_manager(monkeypatch, "Background")

assert gateway_cli._launchd_domain() == f"user/{os.getuid()}"

def test_launchd_domain_uses_gui_scope_for_aqua_sessions(self, monkeypatch):
_pin_launchd_manager(monkeypatch, "Aqua")

assert gateway_cli._launchd_domain() == f"gui/{os.getuid()}"

def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys):
plist_path = tmp_path / "ai.hermes.gateway.plist"
plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
Expand Down
Loading