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:
Teknium
2026-04-10 21:15:47 -07:00
committed by GitHub
parent 830040f937
commit a8fd7257b1
8 changed files with 421 additions and 35 deletions

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

@@ -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`).