feat(#3628): gateway start/stop/restart controls in Settings → System

Adds WebUI controls to manage the messaging gateway lifecycle without a
terminal. _handle_gateway_lifecycle routes start/stop/restart to the
existing platform-aware `hermes gateway {action}` CLI via a list-argv
subprocess (no shell), scoped to the active profile, with bounded
timeout/error handling and a status-card refresh. Auth + CSRF gated;
profile name charset-validated; stdout/stderr never leaked to the client.

Maintainer hardening (warm-up SHOULD-FIX): added _GATEWAY_ACTION_LOCK, a
server-side single-flight guard. The client disables its button in-flight,
but a scripted authed client could otherwise fire overlapping
start/stop/restart calls and spawn concurrent gateway subprocesses. A
non-blocking acquire now returns 409 on contention (mirrors the self-update
_apply_lock pattern), released in finally. Added a regression test
(toothless-checked: without the lock the 2nd action spawns a subprocess +
returns 200).

Cross-platform: delegates to the mature hermes_cli gateway subcommand
(systemctl/launchctl), pathlib paths, PYTHONUTF8=1 + BROWSER=echo in env.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
nesquena-hermes 2026-06-17 21:57:35 +00:00
parent 41d51ecbcf
commit a41a1bf393
5 changed files with 709 additions and 119 deletions

View file

@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.481] — 2026-06-17 — Release QP (gateway start/stop/restart controls in Settings)
### Added
- **Settings → System now has start / stop / restart controls for the messaging gateway (#3628).** Manage the Hermes gateway (Telegram, Discord, Slack, etc.) from the WebUI without dropping to a terminal. The controls call the existing platform-aware `hermes gateway {start,stop,restart}` CLI (systemd on Linux, launchd on macOS), refresh the gateway status card, and surface bounded errors. The endpoint is auth + CSRF gated, validates the active profile name, runs a list-argv subprocess (no shell), and is serialized server-side so overlapping actions return 409 rather than spawning concurrent gateway processes. Thanks @rodboev.
## [v0.51.480] — 2026-06-17 — Release QO (inline PDF preview renders all pages)
### Fixed

View file

@ -949,6 +949,206 @@ def _load_gateway_session_identity_map() -> dict[str, dict]:
return mapping.copy()
def _gateway_status_payload() -> dict:
import datetime
identity_map = _load_gateway_session_identity_map()
sessions_path = _gateway_session_metadata_path()
# Detect whether the gateway process is alive, independent of connected
# messaging platforms. An empty identity_map means zero connected
# platforms, not necessarily a stopped gateway.
health = build_agent_health_payload()
alive = health.get("alive")
details = health.get("details") if isinstance(health.get("details"), dict) else {}
health_reason = details.get("reason")
health_state = details.get("state")
health_gateway_state = details.get("gateway_state")
if alive is True:
running = True
configured = True
elif alive is False:
running = False
configured = True
else:
gateway_running_metadata = (
health_reason == "gateway_stale_running_state"
or health_gateway_state == "running"
)
configured = True if gateway_running_metadata else bool(identity_map)
running = bool(identity_map)
platforms_set: set[str] = set()
for meta in identity_map.values():
raw = meta.get("raw_source") or meta.get("platform") or ""
norm = _normalize_messaging_source(raw)
if norm:
platforms_set.add(norm)
platform_labels = {
"telegram": "Telegram",
"discord": "Discord",
"slack": "Slack",
"email": "Email",
"web": "Web",
"api": "API",
}
platforms = sorted(
[{"name": p, "label": platform_labels.get(p, p.title())} for p in platforms_set],
key=lambda x: x["label"],
)
last_active = ""
if running and sessions_path.exists():
try:
mtime = sessions_path.stat().st_mtime
last_active = datetime.datetime.fromtimestamp(mtime).isoformat()
except Exception:
pass
return {
"running": running,
"configured": configured,
"platforms": platforms,
"last_active": last_active,
"session_count": len(identity_map),
"health": {
"state": health_state,
"reason": health_reason,
"gateway_state": health_gateway_state,
},
}
_GATEWAY_LIFECYCLE_TIMEOUT_SECONDS = 60
# Server-side single-flight guard for gateway lifecycle actions. The client
# disables its button while a request is in flight, but a scripted authed
# client could still fire overlapping start/stop/restart calls, spawning
# concurrent `hermes gateway` subprocesses. Serialize them here (mirrors the
# self-update _apply_lock pattern): a non-blocking acquire returns 409 on
# contention rather than launching a second overlapping subprocess.
_GATEWAY_ACTION_LOCK = threading.Lock()
def _run_gateway_lifecycle_command(action: str) -> subprocess.CompletedProcess:
if action not in {"start", "stop", "restart"}:
raise ValueError("unsupported gateway action")
from api import config as api_config
from api.profiles import get_active_profile_name
agent_dir = getattr(api_config, "_AGENT_DIR", None)
if not agent_dir:
raise FileNotFoundError("Hermes agent checkout not found")
agent_dir = Path(agent_dir).expanduser().resolve()
main_py = agent_dir / "hermes_cli" / "main.py"
if not main_py.exists():
raise FileNotFoundError("Hermes agent CLI entrypoint not found")
cmd = [str(getattr(api_config, "PYTHON_EXE", sys.executable)), str(main_py)]
profile_name = ""
try:
profile_name = str(get_active_profile_name() or "").strip()
except Exception as exc:
logger.debug("Could not resolve active profile for gateway lifecycle: %s", exc)
if profile_name and profile_name != "default":
cmd.extend(["--profile", profile_name])
cmd.extend(["gateway", action])
env = os.environ.copy()
env.setdefault("PYTHONUTF8", "1")
env.setdefault("BROWSER", "echo")
return subprocess.run(
cmd,
cwd=str(agent_dir),
env=env,
capture_output=True,
text=True,
timeout=_GATEWAY_LIFECYCLE_TIMEOUT_SECONDS,
)
def _handle_gateway_lifecycle(handler, action: str, body: dict):
del body # Reserved for future per-gateway naming without changing the route contract.
# Reject overlapping lifecycle actions instead of spawning concurrent
# `hermes gateway` subprocesses (a non-blocking acquire — the action holds
# the lock for at most _GATEWAY_LIFECYCLE_TIMEOUT_SECONDS).
if action not in {"start", "stop", "restart"}:
return bad(handler, "unsupported gateway action", 400)
if not _GATEWAY_ACTION_LOCK.acquire(blocking=False):
return j(
handler,
{
"ok": False,
"error": "Another gateway action is already in progress; try again shortly.",
"action": action,
},
status=409,
)
try:
result = _run_gateway_lifecycle_command(action)
except ValueError as exc:
return bad(handler, str(exc), 400)
except FileNotFoundError as exc:
return j(handler, {"ok": False, "error": _sanitize_error(exc), "action": action}, status=500)
except subprocess.TimeoutExpired as exc:
logger.warning(
"Gateway %s command timed out after %ss; stdout=%r stderr=%r",
action,
_GATEWAY_LIFECYCLE_TIMEOUT_SECONDS,
exc.stdout,
exc.stderr,
)
return j(
handler,
{
"ok": False,
"error": f"Gateway {action} timed out after {_GATEWAY_LIFECYCLE_TIMEOUT_SECONDS} seconds",
"action": action,
},
status=504,
)
except Exception as exc:
logger.exception("Gateway %s command failed before completion", action)
return j(handler, {"ok": False, "error": _sanitize_error(exc), "action": action}, status=500)
finally:
_GATEWAY_ACTION_LOCK.release()
stdout = (result.stdout or "").strip()
stderr = (result.stderr or "").strip()
if result.returncode != 0:
logger.warning(
"Gateway %s command failed with exit code %s; stdout=%r stderr=%r",
action,
result.returncode,
stdout,
stderr,
)
return j(
handler,
{
"ok": False,
"error": f"Gateway {action} failed with exit code {result.returncode}",
"action": action,
"returncode": result.returncode,
},
status=500,
)
return j(
handler,
{
"ok": True,
"action": action,
# Do NOT return captured stdout/stderr — the `hermes gateway` CLI
# prints service/PID/status details the browser shouldn't receive
# (mirrors the failure path, which already suppresses them). The
# frontend localizes its own success copy; the refreshed status
# payload carries the user-facing state.
"message": f"Gateway {action} completed.",
"status": _gateway_status_payload(),
},
)
def _mark_cron_running(job_id: str):
with _RUNNING_CRON_LOCK:
_RUNNING_CRON_JOBS[job_id] = time.time()
@ -7977,97 +8177,7 @@ def handle_get(handler, parsed) -> bool:
# ── Gateway Status (GET) ──
if parsed.path == "/api/gateway/status":
import datetime
identity_map = _load_gateway_session_identity_map()
sessions_path = _gateway_session_metadata_path()
# Detect whether the gateway process is alive, independent of
# connected messaging platforms. An empty identity_map just
# means zero platforms connected, not that the gateway is down.
#
# agent_health.build_agent_health_payload() is the authoritative
# signal: it reads gateway.status runtime metadata and returns a
# tri-state `alive` field (True/False/None). This avoids the
# false-negative where the gateway is running but has zero active
# messaging sessions (empty identity_map).
#
# `alive` tri-state semantics:
# True → gateway process is alive
# False → gateway metadata exists but process is down
# None → no gateway metadata/status available; this WebUI
# setup is probably not configured with a gateway
health = build_agent_health_payload()
alive = health.get("alive")
details = health.get("details") if isinstance(health.get("details"), dict) else {}
health_reason = details.get("reason")
health_state = details.get("state")
health_gateway_state = details.get("gateway_state")
if alive is True:
running = True
configured = True
elif alive is False:
running = False
configured = True
else: # alive is None
# `alive is None` conflates two very different states:
# (a) no gateway metadata at all → genuinely not configured
# (b) gateway metadata EXISTS but is stale/inconclusive.
# A stale-but-RUNNING gateway (freshly started, hasn't ticked
# `updated_at` yet, or cross-container) still proves the gateway is
# *configured* — the banner must not report "Gateway not configured"
# just because no conversation has happened yet and identity_map is
# empty (#3194).
#
# A stale-STOPPED gateway is deliberately NOT treated as configured:
# agent_health emits `gateway_stale_stopped_state` precisely so a
# stopped service the user isn't running reads like "no root gateway
# configured" rather than nagging (#1944). So stale-stopped falls
# through to the identity_map signal like the genuinely-unconfigured
# case.
gateway_running_metadata = (
health_reason == "gateway_stale_running_state"
or health_gateway_state == "running"
)
configured = True if gateway_running_metadata else bool(identity_map)
running = bool(identity_map)
platforms_set: set[str] = set()
for meta in identity_map.values():
raw = meta.get("raw_source") or meta.get("platform") or ""
norm = _normalize_messaging_source(raw)
if norm:
platforms_set.add(norm)
_PLATFORM_LABELS = {
"telegram": "Telegram",
"discord": "Discord",
"slack": "Slack",
"email": "Email",
"web": "Web",
"api": "API",
}
platforms = sorted(
[{"name": p, "label": _PLATFORM_LABELS.get(p, p.title())} for p in platforms_set],
key=lambda x: x["label"],
)
last_active = ""
if running and sessions_path.exists():
try:
mtime = sessions_path.stat().st_mtime
last_active = datetime.datetime.fromtimestamp(mtime).isoformat()
except Exception:
pass
return j(handler, {
"running": running,
"configured": configured,
"platforms": platforms,
"last_active": last_active,
"session_count": len(identity_map),
"health": {
"state": health_state,
"reason": health_reason,
"gateway_state": health_gateway_state,
},
})
return j(handler, _gateway_status_payload())
# ── MCP Servers (GET) ──
if parsed.path == "/api/mcp/servers":
@ -9477,6 +9587,9 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/memory/write":
return _handle_memory_write(handler, body)
if parsed.path in {"/api/gateway/start", "/api/gateway/stop", "/api/gateway/restart"}:
return _handle_gateway_lifecycle(handler, parsed.path.rsplit("/", 1)[-1], body)
# ── Profile API (POST) ──
if parsed.path == "/api/profile/switch":
name = body.get("name", "").strip()

View file

@ -186,6 +186,19 @@ const LOCALES = {
provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`,
provider_mismatch_label: 'Provider mismatch',
gateway_auth_label: 'Gateway authentication failed',
gateway_start: 'Start',
gateway_stop: 'Stop',
gateway_restart: 'Restart',
gateway_start_success: 'Gateway start completed.',
gateway_stop_success: 'Gateway stop completed.',
gateway_restart_success: 'Gateway restart completed.',
gateway_start_failed: 'Gateway start failed',
gateway_stop_failed: 'Gateway stop failed',
gateway_restart_failed: 'Gateway restart failed',
gateway_running: 'Running',
gateway_session: 'session',
gateway_sessions: 'sessions',
gateway_status_load_failed: 'Failed to load gateway status',
model_not_found_label: 'Model not found',
model_custom_label: 'Custom model ID',
model_custom_placeholder: 'e.g. openai/gpt-5.4',
@ -1684,6 +1697,19 @@ const LOCALES = {
provider_mismatch_warning: (m,p)=>`"${m}" potrebbe non funzionare con il provider configurato (${p}). Invia comunque, o esegui \`hermes model\` nel terminale per cambiare.`,
provider_mismatch_label: 'Provider non corrispondente',
gateway_auth_label: 'Autenticazione Gateway non riuscita',
gateway_start: 'Start',
gateway_stop: 'Stop',
gateway_restart: 'Restart',
gateway_start_success: 'Gateway start completed.',
gateway_stop_success: 'Gateway stop completed.',
gateway_restart_success: 'Gateway restart completed.',
gateway_start_failed: 'Gateway start failed',
gateway_stop_failed: 'Gateway stop failed',
gateway_restart_failed: 'Gateway restart failed',
gateway_running: 'Running',
gateway_session: 'session',
gateway_sessions: 'sessions',
gateway_status_load_failed: 'Failed to load gateway status',
model_not_found_label: 'Modello non trovato',
model_custom_label: 'ID modello personalizzato',
model_custom_placeholder: 'es. openai/gpt-5.4',
@ -3173,6 +3199,19 @@ const LOCALES = {
provider_mismatch_warning: (m,p)=>`"${m}" は設定されたプロバイダ (${p}) で動作しない可能性があります。このまま送信するか、ターミナルで \`hermes model\` を実行して切り替えてください。`,
provider_mismatch_label: 'プロバイダ不一致',
gateway_auth_label: 'ゲートウェイ認証に失敗しました',
gateway_start: 'Start',
gateway_stop: 'Stop',
gateway_restart: 'Restart',
gateway_start_success: 'Gateway start completed.',
gateway_stop_success: 'Gateway stop completed.',
gateway_restart_success: 'Gateway restart completed.',
gateway_start_failed: 'Gateway start failed',
gateway_stop_failed: 'Gateway stop failed',
gateway_restart_failed: 'Gateway restart failed',
gateway_running: 'Running',
gateway_session: 'session',
gateway_sessions: 'sessions',
gateway_status_load_failed: 'Failed to load gateway status',
model_not_found_label: 'モデルが見つかりません',
model_custom_label: 'カスタムモデルID',
model_custom_placeholder: '例: openai/gpt-5.4',
@ -4639,6 +4678,19 @@ const LOCALES = {
`"${m}" может не работать с вашим настроенным провайдером (${p}). Всё равно отправить или запустите \`hermes model\` в терминале, чтобы переключиться.`,
provider_mismatch_label: 'Несовпадение провайдера',
gateway_auth_label: 'Сбой аутентификации Gateway',
gateway_start: 'Start',
gateway_stop: 'Stop',
gateway_restart: 'Restart',
gateway_start_success: 'Gateway start completed.',
gateway_stop_success: 'Gateway stop completed.',
gateway_restart_success: 'Gateway restart completed.',
gateway_start_failed: 'Gateway start failed',
gateway_stop_failed: 'Gateway stop failed',
gateway_restart_failed: 'Gateway restart failed',
gateway_running: 'Running',
gateway_session: 'session',
gateway_sessions: 'sessions',
gateway_status_load_failed: 'Failed to load gateway status',
model_not_found_label: 'Модель не найдена',
model_custom_label: 'Пользовательский ID модели',
model_custom_placeholder: 'например, openai/gpt-5.4',
@ -6072,6 +6124,19 @@ const LOCALES = {
provider_mismatch_warning: (m,p)=>`"${m}" puede no funcionar con tu proveedor configurado (${p}). Envía de todas formas, o ejecuta \`hermes model\` en la terminal para cambiar.`,
provider_mismatch_label: 'Proveedor incompatible',
gateway_auth_label: 'Error de autenticación de Gateway',
gateway_start: 'Start',
gateway_stop: 'Stop',
gateway_restart: 'Restart',
gateway_start_success: 'Gateway start completed.',
gateway_stop_success: 'Gateway stop completed.',
gateway_restart_success: 'Gateway restart completed.',
gateway_start_failed: 'Gateway start failed',
gateway_stop_failed: 'Gateway stop failed',
gateway_restart_failed: 'Gateway restart failed',
gateway_running: 'Running',
gateway_session: 'session',
gateway_sessions: 'sessions',
gateway_status_load_failed: 'Failed to load gateway status',
model_not_found_label: 'Modelo no encontrado',
model_custom_label: 'ID de modelo personalizado',
model_custom_placeholder: 'p. ej. openai/gpt-5.4',
@ -7497,6 +7562,19 @@ const LOCALES = {
provider_mismatch_warning: (m,p)=>`"${m}" funktioniert möglicherweise nicht mit Ihrem konfigurierten Provider (${p}). Trotzdem senden, oder \`hermes model\` im Terminal ausführen.`,
provider_mismatch_label: 'Provider-Konflikt',
gateway_auth_label: 'Gateway-Authentifizierung fehlgeschlagen',
gateway_start: 'Start',
gateway_stop: 'Stop',
gateway_restart: 'Restart',
gateway_start_success: 'Gateway start completed.',
gateway_stop_success: 'Gateway stop completed.',
gateway_restart_success: 'Gateway restart completed.',
gateway_start_failed: 'Gateway start failed',
gateway_stop_failed: 'Gateway stop failed',
gateway_restart_failed: 'Gateway restart failed',
gateway_running: 'Running',
gateway_session: 'session',
gateway_sessions: 'sessions',
gateway_status_load_failed: 'Failed to load gateway status',
model_not_found_label: 'Modell nicht gefunden',
// commands.js
cmd_help: 'Verfügbare Befehle auflisten',
@ -8926,6 +9004,19 @@ const LOCALES = {
provider_mismatch_warning: (m,p)=>`\"${m}\" 可能无法在当前配置的提供商 (${p}) 下工作。直接发送,或在终端运行 \`hermes model\` 切换。`,
provider_mismatch_label: '提供商不匹配',
gateway_auth_label: 'Gateway 身份验证失败',
gateway_start: 'Start',
gateway_stop: 'Stop',
gateway_restart: 'Restart',
gateway_start_success: 'Gateway start completed.',
gateway_stop_success: 'Gateway stop completed.',
gateway_restart_success: 'Gateway restart completed.',
gateway_start_failed: 'Gateway start failed',
gateway_stop_failed: 'Gateway stop failed',
gateway_restart_failed: 'Gateway restart failed',
gateway_running: 'Running',
gateway_session: 'session',
gateway_sessions: 'sessions',
gateway_status_load_failed: 'Failed to load gateway status',
model_not_found_label: '未找到模型',
model_custom_label: '自定义模型 ID',
model_custom_placeholder: '例如 openai/gpt-5.4',
@ -10375,6 +10466,19 @@ const LOCALES = {
provider_mismatch_warning: (m,p)=>`「${m}」可能無法搭配已設定的供應商(${p})使用。仍要送出,或在終端機執行 \`hermes model\` 來切換。`,
provider_mismatch_label: '供應商不相符',
gateway_auth_label: 'Gateway 驗證失敗',
gateway_start: 'Start',
gateway_stop: 'Stop',
gateway_restart: 'Restart',
gateway_start_success: 'Gateway start completed.',
gateway_stop_success: 'Gateway stop completed.',
gateway_restart_success: 'Gateway restart completed.',
gateway_start_failed: 'Gateway start failed',
gateway_stop_failed: 'Gateway stop failed',
gateway_restart_failed: 'Gateway restart failed',
gateway_running: 'Running',
gateway_session: 'session',
gateway_sessions: 'sessions',
gateway_status_load_failed: 'Failed to load gateway status',
model_not_found_label: '未找到模型',
model_custom_label: '自訂模型 ID',
model_custom_placeholder: '例如 openai/gpt-5.4',
@ -11775,6 +11879,24 @@ const LOCALES = {
provider_mismatch_warning: (m,p)=>`"${m}" pode não funcionar com seu provedor configurado (${p}). Enviar assim mesmo, ou execute \`hermes model\` no terminal para trocar.`,
provider_mismatch_label: 'Provedor incompatível',
gateway_auth_label: 'Falha na autenticação do Gateway',
gateway_start: 'Start',
gateway_stop: 'Stop',
gateway_restart: 'Restart',
gateway_start_success: 'Gateway start completed.',
gateway_stop_success: 'Gateway stop completed.',
gateway_restart_success: 'Gateway restart completed.',
gateway_start_failed: 'Gateway start failed',
gateway_stop_failed: 'Gateway stop failed',
gateway_restart_failed: 'Gateway restart failed',
gateway_not_configured: 'Gateway not configured',
gateway_not_running: 'Gateway not running',
gateway_running: 'Running',
gateway_metadata_stale: 'Gateway metadata stale',
gateway_endpoint_unreachable: 'Gateway endpoint not reachable',
gateway_last_active: 'Last active',
gateway_session: 'session',
gateway_sessions: 'sessions',
gateway_status_load_failed: 'Failed to load gateway status',
model_not_found_label: 'Modelo não encontrado',
composer_mobile_workspace: 'Workspace',
composer_mobile_model: 'Modelo',
@ -13145,6 +13267,19 @@ const LOCALES = {
provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`,
provider_mismatch_label: 'Provider mismatch',
gateway_auth_label: 'Gateway 인증 실패',
gateway_start: 'Start',
gateway_stop: 'Stop',
gateway_restart: 'Restart',
gateway_start_success: 'Gateway start completed.',
gateway_stop_success: 'Gateway stop completed.',
gateway_restart_success: 'Gateway restart completed.',
gateway_start_failed: 'Gateway start failed',
gateway_stop_failed: 'Gateway stop failed',
gateway_restart_failed: 'Gateway restart failed',
gateway_running: 'Running',
gateway_session: 'session',
gateway_sessions: 'sessions',
gateway_status_load_failed: 'Failed to load gateway status',
model_not_found_label: 'Model not found',
model_custom_label: 'Custom model ID',
model_custom_placeholder: 'e.g. openai/gpt-5.4',
@ -14639,6 +14774,24 @@ const LOCALES = {
model_unavailable_title: 'Ce modèle ne figure plus dans votre liste de fournisseurs actuelle',
provider_mismatch_label: 'Inadéquation des fournisseurs',
gateway_auth_label: 'Échec de l’authentification Gateway',
gateway_start: 'Start',
gateway_stop: 'Stop',
gateway_restart: 'Restart',
gateway_start_success: 'Gateway start completed.',
gateway_stop_success: 'Gateway stop completed.',
gateway_restart_success: 'Gateway restart completed.',
gateway_start_failed: 'Gateway start failed',
gateway_stop_failed: 'Gateway stop failed',
gateway_restart_failed: 'Gateway restart failed',
gateway_not_configured: 'Gateway not configured',
gateway_not_running: 'Gateway not running',
gateway_running: 'Running',
gateway_metadata_stale: 'Gateway metadata stale',
gateway_endpoint_unreachable: 'Gateway endpoint not reachable',
gateway_last_active: 'Last active',
gateway_session: 'session',
gateway_sessions: 'sessions',
gateway_status_load_failed: 'Failed to load gateway status',
model_not_found_label: 'Modèle introuvable',
model_custom_label: 'ID de modèle personnalisé',
model_custom_placeholder: 'par ex. openai/gpt-5.4',
@ -16062,6 +16215,19 @@ const LOCALES = {
provider_mismatch_warning: (m,p) => `"${m}" yapılandırılmış sağlayıcınızla (${p}) çalışmayabilir. Yine de gönderin veya geçiş yapmak için terminalinizde \`hermes model\` komutunu çalıştırın.`,
provider_mismatch_label: 'Sağlayıcı uyumsuzluğu',
gateway_auth_label: 'Gateway kimlik doğrulaması başarısız',
gateway_start: 'Start',
gateway_stop: 'Stop',
gateway_restart: 'Restart',
gateway_start_success: 'Gateway start completed.',
gateway_stop_success: 'Gateway stop completed.',
gateway_restart_success: 'Gateway restart completed.',
gateway_start_failed: 'Gateway start failed',
gateway_stop_failed: 'Gateway stop failed',
gateway_restart_failed: 'Gateway restart failed',
gateway_running: 'Running',
gateway_session: 'session',
gateway_sessions: 'sessions',
gateway_status_load_failed: 'Failed to load gateway status',
model_not_found_label: 'Model bulunamadı',
model_custom_label: 'Özel model kimliği',
model_custom_placeholder: 'örneğin openai/gpt-5.4',
@ -17553,6 +17719,19 @@ const LOCALES = {
provider_mismatch_warning: (m,p)=>`"${m}" może nie działać z Twoim skonfigurowanym dostawcą (${p}). Wyślij mimo to lub uruchom \`hermes model\` w terminalu, aby przełączyć.`,
provider_mismatch_label: 'Niezgodność dostawcy',
gateway_auth_label: 'Uwierzytelnianie bramy nie powiodło się',
gateway_start: 'Uruchom',
gateway_stop: 'Zatrzymaj',
gateway_restart: 'Uruchom ponownie',
gateway_start_success: 'Uruchamianie bramy zakończone.',
gateway_stop_success: 'Zatrzymywanie bramy zakończone.',
gateway_restart_success: 'Ponowne uruchamianie bramy zakończone.',
gateway_start_failed: 'Uruchamianie bramy nie powiodło się',
gateway_stop_failed: 'Zatrzymywanie bramy nie powiodło się',
gateway_restart_failed: 'Ponowne uruchamianie bramy nie powiodło się',
gateway_running: 'Działa',
gateway_session: 'sesja',
gateway_sessions: 'sesje',
gateway_status_load_failed: 'Nie udało się załadować statusu bramy',
model_not_found_label: 'Model nie został znaleziony',
model_custom_label: 'Niestandardowy ID modelu',
model_custom_placeholder: 'np. openai/gpt-5.4',

View file

@ -9175,37 +9175,64 @@ function loadMcpTools(){
filterMcpTools();
}).catch(()=>{list.innerHTML=`<div class="mcp-tool-error-state" style="color:#ef4444;font-size:12px;padding:6px 0">${esc(t('mcp_tools_load_failed'))}</div>`});
}
let _gatewayActionInFlight=false;
function _gatewayActionButton(action){
const labels={start:t('gateway_start'),stop:t('gateway_stop'),restart:t('gateway_restart')};
return `<button class="sm-btn gateway-action-btn" data-gateway-action="${esc(action)}" onclick="_gatewayAction('${esc(action)}')" ${_gatewayActionInFlight?'disabled':''} style="padding:5px 10px;font-size:12px">${esc(labels[action]||action)}</button>`;
}
function _gatewayActionControls(r){
const actions=(r&&r.running)?['stop','restart']:['start'];
return `<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px">${actions.map(_gatewayActionButton).join('')}</div>`;
}
function _renderGatewayStatus(r){
const card=$('gatewayStatusCard');
if(!card||!r) return;
if(!r.configured){
card.innerHTML=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#f59e0b;display:inline-block"></span>${esc(t('gateway_not_configured'))}</div>${_gatewayActionControls(r)}`;
return;
}
if(!r.running){
const reason = _gatewayStatusReason(r);
const statusLabel = reason === 'gateway_stale_running_state'
? t('gateway_metadata_stale')
: reason === 'remote_gateway_unreachable'
? t('gateway_endpoint_unreachable')
: t('gateway_not_running');
card.innerHTML=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#ef4444;display:inline-block"></span>${esc(statusLabel)}</div>${_gatewayActionControls(r)}`;
return;
}
const platformIcons={telegram:'💬',discord:'🎮',slack:'📝',web:'🌐',api:'🔌'};
let badges='';
if(r.platforms&&r.platforms.length){
badges=r.platforms.map(p=>{
const icon=platformIcons[p.name]||'📡';
return `<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;background:var(--code-bg);border:1px solid var(--border2);border-radius:12px;font-size:12px;font-weight:500">${icon} ${esc(p.label)}</span>`;
}).join(' ');
}
const lastActive=r.last_active?`<span style="font-size:11px;color:var(--muted)">${esc(t('gateway_last_active'))}: ${esc(new Date(r.last_active).toLocaleString())}</span>`:'';
const sessionInfo=r.session_count?`<span style="font-size:11px;color:var(--muted)">${r.session_count} ${esc(r.session_count!==1?t('gateway_sessions'):t('gateway_session'))}</span>`:'';
card.innerHTML=`<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block"></span><span style="font-size:13px;font-weight:500;color:#22c55e">${esc(t('gateway_running'))}</span></div>${badges?`<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">${badges}</div>`:''}<div style="display:flex;gap:12px">${sessionInfo}${lastActive}</div>${_gatewayActionControls(r)}`;
}
function loadGatewayStatus(){
const card=$('gatewayStatusCard');
if(!card) return;
api('/api/gateway/status').then(r=>{
if(!r) return;
if(!r.configured){
card.innerHTML=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#f59e0b;display:inline-block"></span>${esc(t('gateway_not_configured'))}</div>`;
return;
}
if(!r.running){
const reason = _gatewayStatusReason(r);
const statusLabel = reason === 'gateway_stale_running_state'
? t('gateway_metadata_stale')
: reason === 'remote_gateway_unreachable'
? t('gateway_endpoint_unreachable')
: t('gateway_not_running');
card.innerHTML=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#ef4444;display:inline-block"></span>${esc(statusLabel)}</div>`;
return;
}
const platformIcons={telegram:'💬',discord:'🎮',slack:'📝',web:'🌐',api:'🔌'};
let badges='';
if(r.platforms&&r.platforms.length){
badges=r.platforms.map(p=>{
const icon=platformIcons[p.name]||'📡';
return `<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;background:var(--code-bg);border:1px solid var(--border2);border-radius:12px;font-size:12px;font-weight:500">${icon} ${esc(p.label)}</span>`;
}).join(' ');
}
const lastActive=r.last_active?`<span style="font-size:11px;color:var(--muted)">${esc(t('gateway_last_active'))}${esc(new Date(r.last_active).toLocaleString())}</span>`:'';
const sessionInfo=r.session_count?`<span style="font-size:11px;color:var(--muted)">${esc(t('gateway_session_count',r.session_count))}</span>`:'';
card.innerHTML=`<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block"></span><span style="font-size:13px;font-weight:500;color:#22c55e">${esc(t('gateway_running_label'))}</span></div>${badges?`<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">${badges}</div>`:''}<div style="display:flex;gap:12px">${sessionInfo}${lastActive}</div>`;
}).catch(()=>{card.innerHTML=`<div style="color:#ef4444;font-size:12px">${esc(t('gateway_load_failed'))}</div>`});
return api('/api/gateway/status').then(r=>_renderGatewayStatus(r)).catch(()=>{card.innerHTML=`<div style="color:#ef4444;font-size:12px">${esc(t('gateway_status_load_failed'))}</div>`});
}
async function _gatewayAction(action){
if(_gatewayActionInFlight) return;
_gatewayActionInFlight=true;
const buttons=[...document.querySelectorAll('.gateway-action-btn')];
buttons.forEach(btn=>{btn.disabled=true;});
try{
const result=await api(`/api/gateway/${encodeURIComponent(action)}`,{method:'POST',body:JSON.stringify({}),timeoutMs:70000,timeoutToast:false});
if(typeof showToast==='function') showToast(result&&result.message?result.message:t(`gateway_${action}_success`),3000,'success');
}catch(e){
const msg=e&&e.message?e.message:String(e||'');
if(typeof showToast==='function') showToast(`${t(`gateway_${action}_failed`)}${msg?': '+msg:''}`,5000,'error');
}finally{
_gatewayActionInFlight=false;
await loadGatewayStatus();
}
}
// Load MCP servers when system settings tab opens
const _origSwitchSettings=switchSettingsSection;

View file

@ -0,0 +1,265 @@
from __future__ import annotations
import json
import subprocess
import sys
from urllib.parse import urlparse
class _FakeHandler:
def __init__(self):
self.status = None
self.sent_headers: list[tuple[str, str]] = []
self.body = bytearray()
self.wfile = self
self.headers = {}
def send_response(self, code):
self.status = code
def send_header(self, key, value):
self.sent_headers.append((key, value))
def end_headers(self):
pass
def write(self, data):
self.body.extend(data if isinstance(data, (bytes, bytearray)) else data.encode("utf-8"))
def get_json(self):
return json.loads(self.body.decode("utf-8"))
def _fake_agent(tmp_path):
agent_dir = tmp_path / "hermes-agent"
cli_dir = agent_dir / "hermes_cli"
cli_dir.mkdir(parents=True)
(cli_dir / "main.py").write_text("print('fake hermes cli')\n", encoding="utf-8")
return agent_dir
def _call_post(monkeypatch, path: str, body: dict | None = None):
from api import routes
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
monkeypatch.setattr(routes, "read_body", lambda handler: body or {})
handler = _FakeHandler()
routes.handle_post(handler, urlparse(path))
return handler, handler.get_json()
def test_gateway_start_runs_profile_scoped_agent_cli_and_returns_status(monkeypatch, tmp_path):
from api import config, profiles, routes
agent_dir = _fake_agent(tmp_path)
monkeypatch.setattr(config, "_AGENT_DIR", agent_dir)
monkeypatch.setattr(config, "PYTHON_EXE", sys.executable)
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "work")
monkeypatch.setattr(
routes,
"_gateway_status_payload",
lambda: {
"running": True,
"configured": True,
"platforms": [],
"last_active": "",
"session_count": 0,
"health": {},
},
)
calls = []
def fake_run(cmd, **kwargs):
calls.append((cmd, kwargs))
return subprocess.CompletedProcess(cmd, 0, stdout="gateway started\n", stderr="")
monkeypatch.setattr(routes.subprocess, "run", fake_run)
handler, data = _call_post(monkeypatch, "/api/gateway/start", {"name": "telegram"})
assert handler.status == 200
assert data["ok"] is True
assert data["action"] == "start"
assert "stdout" not in data
assert "stderr" not in data
assert data["status"]["running"] is True
cmd, kwargs = calls[0]
assert cmd[:2] == [sys.executable, str(agent_dir / "hermes_cli" / "main.py")]
assert cmd[2:] == ["--profile", "work", "gateway", "start"]
assert kwargs["cwd"] == str(agent_dir)
assert kwargs["env"]["PYTHONUTF8"] == "1"
assert kwargs["env"]["BROWSER"] == "echo"
assert kwargs["capture_output"] is True
assert kwargs["text"] is True
def test_gateway_action_contention_returns_409_without_spawning(monkeypatch, tmp_path):
"""A second lifecycle action while one holds the lock returns 409 and does
NOT spawn an overlapping `hermes gateway` subprocess (server-side
single-flight guard)."""
from api import config, routes
agent_dir = _fake_agent(tmp_path)
monkeypatch.setattr(config, "_AGENT_DIR", agent_dir)
monkeypatch.setattr(config, "PYTHON_EXE", sys.executable)
spawned = []
monkeypatch.setattr(
routes.subprocess,
"run",
lambda cmd, **kw: spawned.append(cmd) or subprocess.CompletedProcess(cmd, 0, stdout="", stderr=""),
)
# Simulate an in-flight action by holding the lock, then fire a second one.
acquired = routes._GATEWAY_ACTION_LOCK.acquire(blocking=False)
assert acquired, "lock should be free at test start"
try:
handler, data = _call_post(monkeypatch, "/api/gateway/restart", {})
finally:
routes._GATEWAY_ACTION_LOCK.release()
assert handler.status == 409, f"expected 409 on contention, got {handler.status}"
assert data["ok"] is False
assert spawned == [], "no subprocess should be spawned while another action holds the lock"
# After the lock is released, a normal action proceeds (lock is reusable).
handler2, data2 = _call_post(monkeypatch, "/api/gateway/restart", {})
assert handler2.status == 200
assert len(spawned) == 1
def test_gateway_stop_failure_returns_bounded_error(monkeypatch, tmp_path):
from api import config, profiles, routes
agent_dir = _fake_agent(tmp_path)
monkeypatch.setattr(config, "_AGENT_DIR", agent_dir)
monkeypatch.setattr(config, "PYTHON_EXE", sys.executable)
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 7, stdout="partial output\n", stderr="stop failed\n")
monkeypatch.setattr(routes.subprocess, "run", fake_run)
handler, data = _call_post(monkeypatch, "/api/gateway/stop")
assert handler.status == 500
assert data["ok"] is False
assert data["action"] == "stop"
assert data["returncode"] == 7
assert "stdout" not in data
assert "stderr" not in data
assert data["error"] == "Gateway stop failed with exit code 7"
def test_gateway_restart_uses_restart_subcommand(monkeypatch, tmp_path):
from api import config, profiles, routes
agent_dir = _fake_agent(tmp_path)
monkeypatch.setattr(config, "_AGENT_DIR", agent_dir)
monkeypatch.setattr(config, "PYTHON_EXE", sys.executable)
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
monkeypatch.setattr(
routes,
"_gateway_status_payload",
lambda: {
"running": True,
"configured": True,
"platforms": [],
"last_active": "",
"session_count": 0,
"health": {},
},
)
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout="gateway restarted\n", stderr="")
monkeypatch.setattr(routes.subprocess, "run", fake_run)
handler, data = _call_post(monkeypatch, "/api/gateway/restart")
assert handler.status == 200
assert data["ok"] is True
assert data["action"] == "restart"
assert calls[0][-2:] == ["gateway", "restart"]
def test_gateway_lifecycle_timeout_returns_gateway_timeout(monkeypatch, tmp_path):
from api import config, profiles, routes
agent_dir = _fake_agent(tmp_path)
monkeypatch.setattr(config, "_AGENT_DIR", agent_dir)
monkeypatch.setattr(config, "PYTHON_EXE", sys.executable)
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
def fake_run(cmd, **kwargs):
raise subprocess.TimeoutExpired(cmd=cmd, timeout=60, output="partial", stderr="slow")
monkeypatch.setattr(routes.subprocess, "run", fake_run)
handler, data = _call_post(monkeypatch, "/api/gateway/restart")
assert handler.status == 504
assert data["ok"] is False
assert data["action"] == "restart"
assert data["error"] == "Gateway restart timed out after 60 seconds"
assert "stdout" not in data
assert "stderr" not in data
def test_gateway_lifecycle_invalid_action_returns_bad_request(monkeypatch):
from api import routes
monkeypatch.setattr(routes, "_run_gateway_lifecycle_command", lambda action: (_ for _ in ()).throw(ValueError("unsupported gateway action")))
handler = _FakeHandler()
routes._handle_gateway_lifecycle(handler, "bogus", {})
data = handler.get_json()
assert handler.status == 400
assert data["error"] == "unsupported gateway action"
def test_gateway_lifecycle_missing_cli_returns_sanitized_error(monkeypatch, tmp_path):
from api import config, profiles
agent_dir = tmp_path / "missing-agent"
agent_dir.mkdir()
monkeypatch.setattr(config, "_AGENT_DIR", agent_dir)
monkeypatch.setattr(config, "PYTHON_EXE", sys.executable)
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
handler, data = _call_post(monkeypatch, "/api/gateway/start")
assert handler.status == 500
assert data["ok"] is False
assert data["action"] == "start"
assert data["error"] == "Hermes agent CLI entrypoint not found"
def test_gateway_lifecycle_frontend_renders_valid_actions():
from pathlib import Path
panels = (Path(__file__).resolve().parents[1] / "static" / "panels.js").read_text(encoding="utf-8")
assert "api(`/api/gateway/${encodeURIComponent(action)}`" in panels
assert "timeoutMs:70000" in panels
assert "timeoutToast:false" in panels
assert "(r&&r.running)?['stop','restart']:['start']" in panels
assert "document.querySelectorAll('.gateway-action-btn')" in panels
assert "await loadGatewayStatus();" in panels
def test_gateway_lifecycle_i18n_keys_exist():
from pathlib import Path
i18n = (Path(__file__).resolve().parents[1] / "static" / "i18n.js").read_text(encoding="utf-8")
for key in (
"gateway_start",
"gateway_stop",
"gateway_restart",
"gateway_start_failed",
"gateway_stop_failed",
"gateway_restart_failed",
):
assert f"{key}:" in i18n