Skip to content
Merged
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
22 changes: 2 additions & 20 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,25 +148,6 @@ def managed_error(action: str = "modify configuration"):
# Container-aware CLI (NixOS container mode)
# =============================================================================

def _is_inside_container() -> bool:
"""Detect if we're already running inside a Docker/Podman container."""
# Standard Docker/Podman indicators
if os.path.exists("/.dockerenv"):
return True
# Podman uses /run/.containerenv
if os.path.exists("/run/.containerenv"):
return True
# Check cgroup for container runtime evidence (works for both Docker & Podman)
try:
with open("/proc/1/cgroup", "r") as f:
cgroup = f.read()
if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup:
return True
except OSError:
pass
return False


def get_container_exec_info() -> Optional[dict]:
"""Read container mode metadata from HERMES_HOME/.container-mode.

Expand All @@ -181,7 +162,8 @@ def get_container_exec_info() -> Optional[dict]:
if os.environ.get("HERMES_DEV") == "1":
return None

if _is_inside_container():
from hermes_constants import is_container
if is_container():
return None

container_mode_file = get_hermes_home() / ".container-mode"
Expand Down
10 changes: 10 additions & 0 deletions hermes_cli/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ def _redact(value: str) -> str:
def _gateway_status() -> str:
"""Return a short gateway status string."""
if sys.platform.startswith("linux"):
from hermes_constants import is_container
if is_container():
try:
from hermes_cli.gateway import find_gateway_pids
pids = find_gateway_pids()
if pids:
return f"running (docker, pid {pids[0]})"
return "stopped (docker)"
except Exception:
return "stopped (docker)"
try:
from hermes_cli.gateway import get_service_name
svc = get_service_name()
Expand Down
96 changes: 70 additions & 26 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ def is_linux() -> bool:
return sys.platform.startswith('linux')


from hermes_constants import is_termux, is_wsl
from hermes_constants import is_container, is_termux, is_wsl


def _wsl_systemd_operational() -> bool:
Expand All @@ -353,7 +353,9 @@ def _wsl_systemd_operational() -> bool:


def supports_systemd_services() -> bool:
if not is_linux() or is_termux():
if not is_linux() or is_termux() or is_container():
return False
if shutil.which("systemctl") is None:
return False
if is_wsl():
return _wsl_systemd_operational()
Expand Down Expand Up @@ -483,6 +485,21 @@ def _journalctl_cmd(system: bool = False) -> list[str]:
return ["journalctl"] if system else ["journalctl", "--user"]


def _run_systemctl(args: list[str], *, system: bool = False, **kwargs) -> subprocess.CompletedProcess:
"""Run a systemctl command, raising RuntimeError if systemctl is missing.

Defense-in-depth: callers are gated by ``supports_systemd_services()``,
but this ensures any future caller that bypasses the gate still gets a
clear error instead of a raw ``FileNotFoundError`` traceback.
"""
try:
return subprocess.run(_systemctl_cmd(system) + args, **kwargs)
except FileNotFoundError:
raise RuntimeError(
"systemctl is not available on this system"
) from None


def _service_scope_label(system: bool = False) -> str:
return "system" if system else "user"

Expand Down Expand Up @@ -929,7 +946,7 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool:

expected_user = _read_systemd_user_from_unit(unit_path) if system else None
unit_path.write_text(generate_systemd_unit(system=system, run_as_user=expected_user), encoding="utf-8")
subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True, timeout=30)
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
print(f"↻ Updated gateway {_service_scope_label(system)} service definition to match the current Hermes install")
return True

Expand Down Expand Up @@ -1025,7 +1042,7 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str
if not systemd_unit_is_current(system=system):
print(f"↻ Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}")
refresh_systemd_unit_if_needed(system=system)
subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True, timeout=30)
_run_systemctl(["enable", get_service_name()], system=system, check=True, timeout=30)
print(f"✓ {_service_scope_label(system).capitalize()} service definition updated")
return
print(f"Service already installed at: {unit_path}")
Expand All @@ -1036,8 +1053,8 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str
print(f"Installing {_service_scope_label(system)} systemd service to: {unit_path}")
unit_path.write_text(generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8")

subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True, timeout=30)
subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True, timeout=30)
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
_run_systemctl(["enable", get_service_name()], system=system, check=True, timeout=30)

print()
print(f"✓ {_service_scope_label(system).capitalize()} service installed and enabled!")
Expand All @@ -1063,15 +1080,15 @@ def systemd_uninstall(system: bool = False):
if system:
_require_root_for_system_service("uninstall")

subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=False, timeout=90)
subprocess.run(_systemctl_cmd(system) + ["disable", get_service_name()], check=False, timeout=30)
_run_systemctl(["stop", get_service_name()], system=system, check=False, timeout=90)
_run_systemctl(["disable", get_service_name()], system=system, check=False, timeout=30)

unit_path = get_systemd_unit_path(system=system)
if unit_path.exists():
unit_path.unlink()
print(f"✓ Removed {unit_path}")

subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True, timeout=30)
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
print(f"✓ {_service_scope_label(system).capitalize()} service uninstalled")


Expand All @@ -1080,7 +1097,7 @@ def systemd_start(system: bool = False):
if system:
_require_root_for_system_service("start")
refresh_systemd_unit_if_needed(system=system)
subprocess.run(_systemctl_cmd(system) + ["start", get_service_name()], check=True, timeout=30)
_run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30)
print(f"✓ {_service_scope_label(system).capitalize()} service started")


Expand All @@ -1089,7 +1106,7 @@ def systemd_stop(system: bool = False):
system = _select_systemd_scope(system)
if system:
_require_root_for_system_service("stop")
subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=True, timeout=90)
_run_systemctl(["stop", get_service_name()], system=system, check=True, timeout=90)
print(f"✓ {_service_scope_label(system).capitalize()} service stopped")


Expand All @@ -1105,7 +1122,7 @@ def systemd_restart(system: bool = False):
if pid is not None and _request_gateway_self_restart(pid):
print(f"✓ {_service_scope_label(system).capitalize()} service restart requested")
return
subprocess.run(_systemctl_cmd(system) + ["reload-or-restart", get_service_name()], check=True, timeout=90)
_run_systemctl(["reload-or-restart", get_service_name()], system=system, check=True, timeout=90)
print(f"✓ {_service_scope_label(system).capitalize()} service restarted")


Expand All @@ -1129,14 +1146,16 @@ def systemd_status(deep: bool = False, system: bool = False):
print(f" Run: {'sudo ' if system else ''}hermes gateway restart{scope_flag} # auto-refreshes the unit")
print()

subprocess.run(
_systemctl_cmd(system) + ["status", get_service_name(), "--no-pager"],
_run_systemctl(
["status", get_service_name(), "--no-pager"],
system=system,
capture_output=False,
timeout=10,
)

result = subprocess.run(
_systemctl_cmd(system) + ["is-active", get_service_name()],
result = _run_systemctl(
["is-active", get_service_name()],
system=system,
capture_output=True,
text=True,
timeout=10,
Expand Down Expand Up @@ -2123,24 +2142,24 @@ def _is_service_running() -> bool:

if user_unit_exists:
try:
result = subprocess.run(
_systemctl_cmd(False) + ["is-active", get_service_name()],
capture_output=True, text=True, timeout=10,
result = _run_systemctl(
["is-active", get_service_name()],
system=False, capture_output=True, text=True, timeout=10,
)
if result.stdout.strip() == "active":
return True
except subprocess.TimeoutExpired:
except (RuntimeError, subprocess.TimeoutExpired):
pass

if system_unit_exists:
try:
result = subprocess.run(
_systemctl_cmd(True) + ["is-active", get_service_name()],
capture_output=True, text=True, timeout=10,
result = _run_systemctl(
["is-active", get_service_name()],
system=True, capture_output=True, text=True, timeout=10,
)
if result.stdout.strip() == "active":
return True
except subprocess.TimeoutExpired:
except (RuntimeError, subprocess.TimeoutExpired):
pass

return False
Expand Down Expand Up @@ -2774,6 +2793,15 @@ def gateway_command(args):
print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux")
print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background")
sys.exit(1)
elif is_container():
print("Service installation is not needed inside a Docker container.")
print("The container runtime is your service manager — use Docker restart policies instead:")
print()
print(" docker run --restart unless-stopped ... # auto-restart on crash/reboot")
print(" docker restart <container> # manual restart")
print()
print("To run the gateway: hermes gateway run")
sys.exit(0)
else:
print("Service installation not supported on this platform.")
print("Run manually: hermes gateway run")
Expand All @@ -2792,10 +2820,17 @@ def gateway_command(args):
systemd_uninstall(system=system)
elif is_macos():
launchd_uninstall()
elif is_container():
print("Service uninstall is not applicable inside a Docker container.")
print("To stop the gateway, stop or remove the container:")
print()
print(" docker stop <container>")
print(" docker rm <container>")
sys.exit(0)
else:
print("Not supported on this platform.")
sys.exit(1)

elif subcmd == "start":
system = getattr(args, 'system', False)
if is_termux():
Expand All @@ -2816,10 +2851,19 @@ def gateway_command(args):
print()
print("To enable systemd: add systemd=true to /etc/wsl.conf and run 'wsl --shutdown' from PowerShell.")
sys.exit(1)
elif is_container():
print("Service start is not applicable inside a Docker container.")
print("The gateway runs as the container's main process.")
print()
print(" docker start <container> # start a stopped container")
print(" docker restart <container> # restart a running container")
print()
print("Or run the gateway directly: hermes gateway run")
sys.exit(0)
else:
print("Not supported on this platform.")
sys.exit(1)

elif subcmd == "stop":
stop_all = getattr(args, 'all', False)
system = getattr(args, 'system', False)
Expand Down
32 changes: 22 additions & 10 deletions hermes_cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2232,6 +2232,7 @@ def setup_gateway(config: dict):
from hermes_cli.gateway import (
_is_service_installed,
_is_service_running,
supports_systemd_services,
has_conflicting_systemd_units,
install_linux_gateway_from_setup,
print_systemd_scope_conflict_warning,
Expand All @@ -2244,16 +2245,18 @@ def setup_gateway(config: dict):

service_installed = _is_service_installed()
service_running = _is_service_running()
supports_systemd = supports_systemd_services()
supports_service_manager = supports_systemd or _is_macos

print()
if _is_linux and has_conflicting_systemd_units():
if supports_systemd and has_conflicting_systemd_units():
print_systemd_scope_conflict_warning()
print()

if service_running:
if prompt_yes_no(" Restart the gateway to pick up changes?", True):
try:
if _is_linux:
if supports_systemd:
systemd_restart()
elif _is_macos:
launchd_restart()
Expand All @@ -2262,30 +2265,30 @@ def setup_gateway(config: dict):
elif service_installed:
if prompt_yes_no(" Start the gateway service?", True):
try:
if _is_linux:
if supports_systemd:
systemd_start()
elif _is_macos:
launchd_start()
except Exception as e:
print_error(f" Start failed: {e}")
elif _is_linux or _is_macos:
svc_name = "systemd" if _is_linux else "launchd"
elif supports_service_manager:
svc_name = "systemd" if supports_systemd else "launchd"
if prompt_yes_no(
f" Install the gateway as a {svc_name} service? (runs in background, starts on boot)",
True,
):
try:
installed_scope = None
did_install = False
if _is_linux:
if supports_systemd:
installed_scope, did_install = install_linux_gateway_from_setup(force=False)
else:
launchd_install(force=False)
did_install = True
print()
if did_install and prompt_yes_no(" Start the service now?", True):
try:
if _is_linux:
if supports_systemd:
systemd_start(system=installed_scope == "system")
elif _is_macos:
launchd_start()
Expand All @@ -2296,12 +2299,21 @@ def setup_gateway(config: dict):
print_info(" You can try manually: hermes gateway install")
else:
print_info(" You can install later: hermes gateway install")
if _is_linux:
if supports_systemd:
print_info(" Or as a boot-time service: sudo hermes gateway install --system")
print_info(" Or run in foreground: hermes gateway")
else:
print_info("Start the gateway to bring your bots online:")
print_info(" hermes gateway # Run in foreground")
from hermes_constants import is_container
if is_container():
print_info("Start the gateway to bring your bots online:")
print_info(" hermes gateway run # Run as container main process")
print_info("")
print_info("For automatic restarts, use a Docker restart policy:")
print_info(" docker run --restart unless-stopped ...")
print_info(" docker restart <container> # Manual restart")
else:
print_info("Start the gateway to bring your bots online:")
print_info(" hermes gateway # Run in foreground")

print_info("━" * 50)

Expand Down
Loading
Loading