mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
feat(gateway): WSL-aware gateway with smart systemd detection (#7510)
- Add shared is_wsl() to hermes_constants (like is_termux) - Update supports_systemd_services() to verify systemd is actually running on WSL before returning True - Add WSL-specific guidance in gateway install/start/setup/status for both cases: WSL+systemd and WSL without systemd - Improve help strings: 'run' now says recommended for WSL/Docker, 'start'/'install' now mention systemd/launchd explicitly - Add WSL gateway FAQ section with tmux/nohup/Task Scheduler tips - Update CLI commands docs with WSL tip - Deduplicate _is_wsl() from clipboard.py to shared hermes_constants - Fix clipboard tests to reset hermes_constants cache - 20 new WSL-specific tests covering detection, systemd check, supports_systemd_services integration, and command output Motivated by user feedback: took 1 hour to figure out run vs start on WSL, Telegram bot kept disconnecting due to flaky WSL systemd.
This commit is contained in:
@@ -19,10 +19,9 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from hermes_constants import is_wsl as _is_wsl
|
||||
|
||||
# Cache WSL detection (checked once per process)
|
||||
_wsl_detected: bool | None = None
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def save_clipboard_image(dest: Path) -> bool:
|
||||
@@ -217,19 +216,6 @@ def _windows_save(dest: Path) -> bool:
|
||||
|
||||
# ── Linux ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _is_wsl() -> bool:
|
||||
"""Detect if running inside WSL (1 or 2)."""
|
||||
global _wsl_detected
|
||||
if _wsl_detected is not None:
|
||||
return _wsl_detected
|
||||
try:
|
||||
with open("/proc/version", "r") as f:
|
||||
_wsl_detected = "microsoft" in f.read().lower()
|
||||
except Exception:
|
||||
_wsl_detected = False
|
||||
return _wsl_detected
|
||||
|
||||
|
||||
def _linux_save(dest: Path) -> bool:
|
||||
"""Try clipboard backends in priority order: WSL → Wayland → X11."""
|
||||
if _is_wsl():
|
||||
|
||||
@@ -226,11 +226,33 @@ def is_linux() -> bool:
|
||||
return sys.platform.startswith('linux')
|
||||
|
||||
|
||||
from hermes_constants import is_termux
|
||||
from hermes_constants import is_termux, is_wsl
|
||||
|
||||
|
||||
def _wsl_systemd_operational() -> bool:
|
||||
"""Check if systemd is actually running as PID 1 on WSL.
|
||||
|
||||
WSL2 with ``systemd=true`` in wsl.conf has working systemd.
|
||||
WSL2 without it (or WSL1) does not — systemctl commands fail.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-system-running"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
# "running", "degraded", "starting" all mean systemd is PID 1
|
||||
status = result.stdout.strip().lower()
|
||||
return status in ("running", "degraded", "starting", "initializing")
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def supports_systemd_services() -> bool:
|
||||
return is_linux() and not is_termux()
|
||||
if not is_linux() or is_termux():
|
||||
return False
|
||||
if is_wsl():
|
||||
return _wsl_systemd_operational()
|
||||
return True
|
||||
|
||||
|
||||
def is_macos() -> bool:
|
||||
@@ -2244,7 +2266,8 @@ def gateway_setup():
|
||||
print()
|
||||
if supports_systemd_services() or is_macos():
|
||||
platform_name = "systemd" if supports_systemd_services() else "launchd"
|
||||
if prompt_yes_no(f" Install the gateway as a {platform_name} service? (runs in background, starts on boot)", True):
|
||||
wsl_note = " (note: services may not survive WSL restarts)" if is_wsl() else ""
|
||||
if prompt_yes_no(f" Install the gateway as a {platform_name} service?{wsl_note} (runs in background, starts on boot)", True):
|
||||
try:
|
||||
installed_scope = None
|
||||
did_install = False
|
||||
@@ -2269,16 +2292,21 @@ def gateway_setup():
|
||||
print_info(" You can install later: hermes gateway install")
|
||||
if supports_systemd_services():
|
||||
print_info(" Or as a boot-time service: sudo hermes gateway install --system")
|
||||
print_info(" Or run in foreground: hermes gateway")
|
||||
print_info(" Or run in foreground: hermes gateway run")
|
||||
elif is_wsl():
|
||||
print_info(" WSL detected but systemd is not running.")
|
||||
print_info(" Run in foreground: hermes gateway run")
|
||||
print_info(" For persistence: tmux new -s hermes 'hermes gateway run'")
|
||||
print_info(" To enable systemd: add systemd=true to /etc/wsl.conf, then 'wsl --shutdown'")
|
||||
else:
|
||||
if is_termux():
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
print_info(" Termux does not use systemd/launchd services.")
|
||||
print_info(" Run in foreground: hermes gateway")
|
||||
print_info(f" Or start it manually in the background (best effort): nohup hermes gateway >{_dhh()}/logs/gateway.log 2>&1 &")
|
||||
print_info(" Run in foreground: hermes gateway run")
|
||||
print_info(f" Or start it manually in the background (best effort): nohup hermes gateway run >{_dhh()}/logs/gateway.log 2>&1 &")
|
||||
else:
|
||||
print_info(" Service install not supported on this platform.")
|
||||
print_info(" Run in foreground: hermes gateway")
|
||||
print_info(" Run in foreground: hermes gateway run")
|
||||
else:
|
||||
print()
|
||||
print_info("No platforms configured. Run 'hermes gateway setup' when ready.")
|
||||
@@ -2319,9 +2347,23 @@ def gateway_command(args):
|
||||
print("Run manually: hermes gateway")
|
||||
sys.exit(1)
|
||||
if supports_systemd_services():
|
||||
if is_wsl():
|
||||
print_warning("WSL detected — systemd services may not survive WSL restarts.")
|
||||
print_info(" Consider running in foreground instead: hermes gateway run")
|
||||
print_info(" Or use tmux/screen for persistence: tmux new -s hermes 'hermes gateway run'")
|
||||
print()
|
||||
systemd_install(force=force, system=system, run_as_user=run_as_user)
|
||||
elif is_macos():
|
||||
launchd_install(force)
|
||||
elif is_wsl():
|
||||
print("WSL detected but systemd is not running.")
|
||||
print("Either enable systemd (add systemd=true to /etc/wsl.conf and restart WSL)")
|
||||
print("or run the gateway in foreground mode:")
|
||||
print()
|
||||
print(" hermes gateway run # direct foreground")
|
||||
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)
|
||||
else:
|
||||
print("Service installation not supported on this platform.")
|
||||
print("Run manually: hermes gateway run")
|
||||
@@ -2354,6 +2396,16 @@ def gateway_command(args):
|
||||
systemd_start(system=system)
|
||||
elif is_macos():
|
||||
launchd_start()
|
||||
elif is_wsl():
|
||||
print("WSL detected but systemd is not available.")
|
||||
print("Run the gateway in foreground mode instead:")
|
||||
print()
|
||||
print(" hermes gateway run # direct foreground")
|
||||
print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux")
|
||||
print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background")
|
||||
print()
|
||||
print("To enable systemd: add systemd=true to /etc/wsl.conf and run 'wsl --shutdown' from PowerShell.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Not supported on this platform.")
|
||||
sys.exit(1)
|
||||
@@ -2488,6 +2540,10 @@ def gateway_command(args):
|
||||
if is_termux():
|
||||
print("Termux note:")
|
||||
print(" Android may stop background jobs when Termux is suspended")
|
||||
elif is_wsl():
|
||||
print("WSL note:")
|
||||
print(" The gateway is running in foreground/manual mode (recommended for WSL).")
|
||||
print(" Use tmux or screen for persistence across terminal closes.")
|
||||
else:
|
||||
print("To install as a service:")
|
||||
print(" hermes gateway install")
|
||||
@@ -2502,9 +2558,12 @@ def gateway_command(args):
|
||||
print(f" {line}")
|
||||
print()
|
||||
print("To start:")
|
||||
print(" hermes gateway # Run in foreground")
|
||||
print(" hermes gateway run # Run in foreground")
|
||||
if is_termux():
|
||||
print(" nohup hermes gateway > ~/.hermes/logs/gateway.log 2>&1 & # Best-effort background start")
|
||||
print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # Best-effort background start")
|
||||
elif is_wsl():
|
||||
print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux")
|
||||
print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background")
|
||||
else:
|
||||
print(" hermes gateway install # Install as user service")
|
||||
print(" sudo hermes gateway install --system # Install as boot-time system service")
|
||||
|
||||
@@ -4447,7 +4447,7 @@ For more help on a command:
|
||||
gateway_subparsers = gateway_parser.add_subparsers(dest="gateway_command")
|
||||
|
||||
# gateway run (default)
|
||||
gateway_run = gateway_subparsers.add_parser("run", help="Run gateway in foreground")
|
||||
gateway_run = gateway_subparsers.add_parser("run", help="Run gateway in foreground (recommended for WSL, Docker, Termux)")
|
||||
gateway_run.add_argument("-v", "--verbose", action="count", default=0,
|
||||
help="Increase stderr log verbosity (-v=INFO, -vv=DEBUG)")
|
||||
gateway_run.add_argument("-q", "--quiet", action="store_true",
|
||||
@@ -4456,7 +4456,7 @@ For more help on a command:
|
||||
help="Replace any existing gateway instance (useful for systemd)")
|
||||
|
||||
# gateway start
|
||||
gateway_start = gateway_subparsers.add_parser("start", help="Start gateway service")
|
||||
gateway_start = gateway_subparsers.add_parser("start", help="Start the installed systemd/launchd background service")
|
||||
gateway_start.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service")
|
||||
|
||||
# gateway stop
|
||||
@@ -4474,7 +4474,7 @@ For more help on a command:
|
||||
gateway_status.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service")
|
||||
|
||||
# gateway install
|
||||
gateway_install = gateway_subparsers.add_parser("install", help="Install gateway as service")
|
||||
gateway_install = gateway_subparsers.add_parser("install", help="Install gateway as a systemd/launchd background service")
|
||||
gateway_install.add_argument("--force", action="store_true", help="Force reinstall")
|
||||
gateway_install.add_argument("--system", action="store_true", help="Install as a Linux system-level service (starts at boot)")
|
||||
gateway_install.add_argument("--run-as-user", dest="run_as_user", help="User account the Linux system service should run as")
|
||||
|
||||
@@ -168,6 +168,27 @@ def is_termux() -> bool:
|
||||
return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
|
||||
|
||||
|
||||
_wsl_detected: bool | None = None
|
||||
|
||||
|
||||
def is_wsl() -> bool:
|
||||
"""Return True when running inside WSL (Windows Subsystem for Linux).
|
||||
|
||||
Checks ``/proc/version`` for the ``microsoft`` marker that both WSL1
|
||||
and WSL2 inject. Result is cached for the process lifetime.
|
||||
Import-safe — no heavy deps.
|
||||
"""
|
||||
global _wsl_detected
|
||||
if _wsl_detected is not None:
|
||||
return _wsl_detected
|
||||
try:
|
||||
with open("/proc/version", "r") as f:
|
||||
_wsl_detected = "microsoft" in f.read().lower()
|
||||
except Exception:
|
||||
_wsl_detected = False
|
||||
return _wsl_detected
|
||||
|
||||
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
|
||||
|
||||
|
||||
279
tests/hermes_cli/test_gateway_wsl.py
Normal file
279
tests/hermes_cli/test_gateway_wsl.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""Tests for WSL detection and WSL-aware gateway behavior."""
|
||||
|
||||
import io
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch, MagicMock, mock_open
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.gateway as gateway
|
||||
import hermes_constants
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# is_wsl() in hermes_constants
|
||||
# =============================================================================
|
||||
|
||||
class TestIsWsl:
|
||||
"""Test the shared is_wsl() utility."""
|
||||
|
||||
def setup_method(self):
|
||||
# Reset cached value between tests
|
||||
hermes_constants._wsl_detected = None
|
||||
|
||||
def test_detects_wsl2(self):
|
||||
fake_content = (
|
||||
"Linux version 5.15.146.1-microsoft-standard-WSL2 "
|
||||
"(gcc (GCC) 11.2.0) #1 SMP Thu Jan 11 04:09:03 UTC 2024\n"
|
||||
)
|
||||
with patch("builtins.open", mock_open(read_data=fake_content)):
|
||||
assert hermes_constants.is_wsl() is True
|
||||
|
||||
def test_detects_wsl1(self):
|
||||
fake_content = (
|
||||
"Linux version 4.4.0-19041-Microsoft "
|
||||
"(Microsoft@Microsoft.com) (gcc version 5.4.0) #1\n"
|
||||
)
|
||||
with patch("builtins.open", mock_open(read_data=fake_content)):
|
||||
assert hermes_constants.is_wsl() is True
|
||||
|
||||
def test_native_linux(self):
|
||||
fake_content = (
|
||||
"Linux version 6.5.0-44-generic (buildd@lcy02-amd64-015) "
|
||||
"(x86_64-linux-gnu-gcc-12 (Ubuntu 12.3.0-1ubuntu1~22.04) 12.3.0) #44\n"
|
||||
)
|
||||
with patch("builtins.open", mock_open(read_data=fake_content)):
|
||||
assert hermes_constants.is_wsl() is False
|
||||
|
||||
def test_no_proc_version(self):
|
||||
with patch("builtins.open", side_effect=FileNotFoundError):
|
||||
assert hermes_constants.is_wsl() is False
|
||||
|
||||
def test_result_is_cached(self):
|
||||
"""After first detection, subsequent calls return the cached value."""
|
||||
hermes_constants._wsl_detected = True
|
||||
# Even with open raising, cached value is returned
|
||||
with patch("builtins.open", side_effect=FileNotFoundError):
|
||||
assert hermes_constants.is_wsl() is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _wsl_systemd_operational() in gateway
|
||||
# =============================================================================
|
||||
|
||||
class TestWslSystemdOperational:
|
||||
"""Test the WSL systemd check."""
|
||||
|
||||
def test_running(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess, "run",
|
||||
lambda *a, **kw: SimpleNamespace(
|
||||
returncode=0, stdout="running\n", stderr=""
|
||||
),
|
||||
)
|
||||
assert gateway._wsl_systemd_operational() is True
|
||||
|
||||
def test_degraded(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess, "run",
|
||||
lambda *a, **kw: SimpleNamespace(
|
||||
returncode=1, stdout="degraded\n", stderr=""
|
||||
),
|
||||
)
|
||||
assert gateway._wsl_systemd_operational() is True
|
||||
|
||||
def test_starting(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess, "run",
|
||||
lambda *a, **kw: SimpleNamespace(
|
||||
returncode=1, stdout="starting\n", stderr=""
|
||||
),
|
||||
)
|
||||
assert gateway._wsl_systemd_operational() is True
|
||||
|
||||
def test_offline_no_systemd(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess, "run",
|
||||
lambda *a, **kw: SimpleNamespace(
|
||||
returncode=1, stdout="offline\n", stderr=""
|
||||
),
|
||||
)
|
||||
assert gateway._wsl_systemd_operational() is False
|
||||
|
||||
def test_systemctl_not_found(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess, "run",
|
||||
MagicMock(side_effect=FileNotFoundError),
|
||||
)
|
||||
assert gateway._wsl_systemd_operational() is False
|
||||
|
||||
def test_timeout(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess, "run",
|
||||
MagicMock(side_effect=subprocess.TimeoutExpired("systemctl", 5)),
|
||||
)
|
||||
assert gateway._wsl_systemd_operational() is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# supports_systemd_services() WSL integration
|
||||
# =============================================================================
|
||||
|
||||
class TestSupportsSystemdServicesWSL:
|
||||
"""Test that supports_systemd_services() handles WSL correctly."""
|
||||
|
||||
def test_wsl_with_systemd(self, monkeypatch):
|
||||
"""WSL + working systemd → True."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "_wsl_systemd_operational", lambda: True)
|
||||
assert gateway.supports_systemd_services() is True
|
||||
|
||||
def test_wsl_without_systemd(self, monkeypatch):
|
||||
"""WSL + no systemd → False."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "_wsl_systemd_operational", lambda: False)
|
||||
assert gateway.supports_systemd_services() is False
|
||||
|
||||
def test_native_linux(self, monkeypatch):
|
||||
"""Native Linux (not WSL) → True without checking systemd."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
|
||||
assert gateway.supports_systemd_services() is True
|
||||
|
||||
def test_termux_still_excluded(self, monkeypatch):
|
||||
"""Termux → False regardless of WSL status."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: True)
|
||||
assert gateway.supports_systemd_services() is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WSL messaging in gateway commands
|
||||
# =============================================================================
|
||||
|
||||
class TestGatewayCommandWSLMessages:
|
||||
"""Test that WSL users see appropriate guidance."""
|
||||
|
||||
def test_install_wsl_no_systemd(self, monkeypatch, capsys):
|
||||
"""hermes gateway install on WSL without systemd shows guidance."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_managed", lambda: False)
|
||||
|
||||
args = SimpleNamespace(
|
||||
gateway_command="install", force=False, system=False,
|
||||
run_as_user=None,
|
||||
)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
gateway.gateway_command(args)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "WSL detected" in out
|
||||
assert "systemd is not running" in out
|
||||
assert "hermes gateway run" in out
|
||||
assert "tmux" in out
|
||||
|
||||
def test_start_wsl_no_systemd(self, monkeypatch, capsys):
|
||||
"""hermes gateway start on WSL without systemd shows guidance."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
|
||||
args = SimpleNamespace(gateway_command="start", system=False)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
gateway.gateway_command(args)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "WSL detected" in out
|
||||
assert "hermes gateway run" in out
|
||||
assert "wsl.conf" in out
|
||||
|
||||
def test_install_wsl_with_systemd_warns(self, monkeypatch, capsys):
|
||||
"""hermes gateway install on WSL with systemd shows warning but proceeds."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_managed", lambda: False)
|
||||
|
||||
# Mock systemd_install to capture call
|
||||
install_called = []
|
||||
monkeypatch.setattr(
|
||||
gateway, "systemd_install",
|
||||
lambda **kwargs: install_called.append(kwargs),
|
||||
)
|
||||
|
||||
args = SimpleNamespace(
|
||||
gateway_command="install", force=False, system=False,
|
||||
run_as_user=None,
|
||||
)
|
||||
gateway.gateway_command(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "WSL detected" in out
|
||||
assert "may not survive WSL restarts" in out
|
||||
assert len(install_called) == 1 # install still proceeded
|
||||
|
||||
def test_status_wsl_running_manual(self, monkeypatch, capsys):
|
||||
"""hermes gateway status on WSL with manual process shows WSL note."""
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "find_gateway_pids", lambda: [12345])
|
||||
monkeypatch.setattr(gateway, "_runtime_health_lines", lambda: [])
|
||||
# Stub out the systemd unit path check
|
||||
monkeypatch.setattr(
|
||||
gateway, "get_systemd_unit_path",
|
||||
lambda system=False: SimpleNamespace(exists=lambda: False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway, "get_launchd_plist_path",
|
||||
lambda: SimpleNamespace(exists=lambda: False),
|
||||
)
|
||||
|
||||
args = SimpleNamespace(gateway_command="status", deep=False, system=False)
|
||||
gateway.gateway_command(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "WSL note" in out
|
||||
assert "tmux or screen" in out
|
||||
|
||||
def test_status_wsl_not_running(self, monkeypatch, capsys):
|
||||
"""hermes gateway status on WSL with no process shows WSL start advice."""
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "find_gateway_pids", lambda: [])
|
||||
monkeypatch.setattr(gateway, "_runtime_health_lines", lambda: [])
|
||||
monkeypatch.setattr(
|
||||
gateway, "get_systemd_unit_path",
|
||||
lambda system=False: SimpleNamespace(exists=lambda: False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway, "get_launchd_plist_path",
|
||||
lambda: SimpleNamespace(exists=lambda: False),
|
||||
)
|
||||
|
||||
args = SimpleNamespace(gateway_command="status", deep=False, system=False)
|
||||
gateway.gateway_command(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "hermes gateway run" in out
|
||||
assert "tmux" in out
|
||||
@@ -205,9 +205,9 @@ class TestMacosOsascript:
|
||||
|
||||
class TestIsWsl:
|
||||
def setup_method(self):
|
||||
# Reset cached value before each test
|
||||
import hermes_cli.clipboard as cb
|
||||
cb._wsl_detected = None
|
||||
# _is_wsl is now hermes_constants.is_wsl — reset its cache
|
||||
import hermes_constants
|
||||
hermes_constants._wsl_detected = None
|
||||
|
||||
def test_wsl2_detected(self):
|
||||
content = "Linux version 5.15.0 (microsoft-standard-WSL2)"
|
||||
@@ -229,6 +229,7 @@ class TestIsWsl:
|
||||
assert _is_wsl() is False
|
||||
|
||||
def test_result_is_cached(self):
|
||||
import hermes_constants
|
||||
content = "Linux version 5.15.0 (microsoft-standard-WSL2)"
|
||||
with patch("builtins.open", mock_open(read_data=content)) as m:
|
||||
assert _is_wsl() is True
|
||||
|
||||
@@ -140,15 +140,19 @@ Subcommands:
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `run` | Run the gateway in the foreground. |
|
||||
| `start` | Start the installed gateway service. |
|
||||
| `stop` | Stop the service. |
|
||||
| `run` | Run the gateway in the foreground. Recommended for WSL, Docker, and Termux. |
|
||||
| `start` | Start the installed systemd/launchd background service. |
|
||||
| `stop` | Stop the service (or foreground process). |
|
||||
| `restart` | Restart the service. |
|
||||
| `status` | Show service status. |
|
||||
| `install` | Install as a user service (`systemd` on Linux, `launchd` on macOS). |
|
||||
| `install` | Install as a systemd (Linux) or launchd (macOS) background service. |
|
||||
| `uninstall` | Remove the installed service. |
|
||||
| `setup` | Interactive messaging-platform setup. |
|
||||
|
||||
:::tip WSL users
|
||||
Use `hermes gateway run` instead of `hermes gateway start` — WSL's systemd support is unreliable. Wrap it in tmux for persistence: `tmux new -s hermes 'hermes gateway run'`. See [WSL FAQ](/docs/reference/faq#wsl-gateway-keeps-disconnecting-or-hermes-gateway-start-fails) for details.
|
||||
:::
|
||||
|
||||
## `hermes setup`
|
||||
|
||||
```bash
|
||||
|
||||
@@ -375,6 +375,42 @@ lsof -i :8080
|
||||
hermes config show
|
||||
```
|
||||
|
||||
#### WSL: Gateway keeps disconnecting or `hermes gateway start` fails
|
||||
|
||||
**Cause:** WSL's systemd support is unreliable. Many WSL2 installations don't have systemd enabled, and even when enabled, services may not survive WSL restarts or Windows idle shutdowns.
|
||||
|
||||
**Solution:** Use foreground mode instead of the systemd service:
|
||||
|
||||
```bash
|
||||
# Option 1: Direct foreground (simplest)
|
||||
hermes gateway run
|
||||
|
||||
# Option 2: Persistent via tmux (survives terminal close)
|
||||
tmux new -s hermes 'hermes gateway run'
|
||||
# Reattach later: tmux attach -t hermes
|
||||
|
||||
# Option 3: Background via nohup
|
||||
nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 &
|
||||
```
|
||||
|
||||
If you want to try systemd anyway, make sure it's enabled:
|
||||
|
||||
1. Open `/etc/wsl.conf` (create it if it doesn't exist)
|
||||
2. Add:
|
||||
```ini
|
||||
[boot]
|
||||
systemd=true
|
||||
```
|
||||
3. From PowerShell: `wsl --shutdown`
|
||||
4. Reopen your WSL terminal
|
||||
5. Verify: `systemctl is-system-running` should say "running" or "degraded"
|
||||
|
||||
:::tip Auto-start on Windows boot
|
||||
For reliable auto-start, use Windows Task Scheduler to launch WSL + the gateway on login:
|
||||
1. Create a task that runs `wsl -d Ubuntu -- bash -lc 'hermes gateway run'`
|
||||
2. Set it to trigger on user logon
|
||||
:::
|
||||
|
||||
#### macOS: Node.js / ffmpeg / other tools not found by gateway
|
||||
|
||||
**Cause:** launchd services inherit a minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) that doesn't include Homebrew, nvm, cargo, or other user-installed tool directories. This commonly breaks the WhatsApp bridge (`node not found`) or voice transcription (`ffmpeg not found`).
|
||||
|
||||
Reference in New Issue
Block a user