Fix dashboard gateway profile scoping
This commit is contained in:
parent
f80381c456
commit
674e8b098a
@ -555,6 +555,41 @@ def read_runtime_status() -> Optional[dict[str, Any]]:
|
|||||||
return _read_json_file(_get_runtime_status_path())
|
return _read_json_file(_get_runtime_status_path())
|
||||||
|
|
||||||
|
|
||||||
|
def get_runtime_status_running_pid(
|
||||||
|
runtime: Optional[dict[str, Any]] = None,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Return a live gateway PID from the runtime status record, if valid.
|
||||||
|
|
||||||
|
``get_running_pid()`` is the primary liveness source because it verifies the
|
||||||
|
runtime lock and PID file. Launch-service managers can still leave us with
|
||||||
|
a live process and a fresh ``gateway_state.json`` but no ``gateway.pid``; use
|
||||||
|
this as a conservative fallback by checking both the persisted state and the
|
||||||
|
OS process identity.
|
||||||
|
"""
|
||||||
|
payload = runtime if runtime is not None else read_runtime_status()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
if payload.get("gateway_state") in {None, "stopped", "startup_failed"}:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pid = _pid_from_record(payload)
|
||||||
|
if pid is None or not _pid_exists(pid):
|
||||||
|
return None
|
||||||
|
|
||||||
|
recorded_start = payload.get("start_time")
|
||||||
|
current_start = _get_process_start_time(pid)
|
||||||
|
if (
|
||||||
|
recorded_start is not None
|
||||||
|
and current_start is not None
|
||||||
|
and current_start != recorded_start
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if _looks_like_gateway_process(pid) or _record_looks_like_gateway(payload):
|
||||||
|
return pid
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def remove_pid_file() -> None:
|
def remove_pid_file() -> None:
|
||||||
"""Remove the gateway PID file, but only if it belongs to this process.
|
"""Remove the gateway PID file, but only if it belongs to this process.
|
||||||
|
|
||||||
|
|||||||
@ -62,7 +62,11 @@ from hermes_cli.config import (
|
|||||||
recommended_update_command_for_method,
|
recommended_update_command_for_method,
|
||||||
redact_key,
|
redact_key,
|
||||||
)
|
)
|
||||||
from gateway.status import get_running_pid, read_runtime_status
|
from gateway.status import (
|
||||||
|
get_running_pid,
|
||||||
|
get_runtime_status_running_pid,
|
||||||
|
read_runtime_status,
|
||||||
|
)
|
||||||
from utils import env_var_enabled
|
from utils import env_var_enabled
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -678,6 +682,7 @@ class TelegramOnboardingStart(BaseModel):
|
|||||||
|
|
||||||
class TelegramOnboardingApply(BaseModel):
|
class TelegramOnboardingApply(BaseModel):
|
||||||
allowed_user_ids: List[str]
|
allowed_user_ids: List[str]
|
||||||
|
profile: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class AudioTranscriptionRequest(BaseModel):
|
class AudioTranscriptionRequest(BaseModel):
|
||||||
@ -1609,145 +1614,161 @@ async def fs_default_cwd():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
async def get_status():
|
async def get_status(profile: Optional[str] = None):
|
||||||
current_ver, latest_ver = check_config_version()
|
status_scope = None
|
||||||
|
requested_profile = (profile or "").strip()
|
||||||
|
# Plain /api/status stays the machine-level public liveness probe. The
|
||||||
|
# dashboard adds ?profile= when its management switcher targets another
|
||||||
|
# profile, so its gateway badge reflects the selected profile.
|
||||||
|
if requested_profile and requested_profile.lower() != "current":
|
||||||
|
status_scope = _profile_scope(requested_profile)
|
||||||
|
status_scope.__enter__()
|
||||||
|
|
||||||
# --- Gateway liveness detection ---
|
|
||||||
# Try local PID check first (same-host). If that fails and a remote
|
|
||||||
# GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the
|
|
||||||
# dashboard works when the gateway runs in a separate container.
|
|
||||||
gateway_pid = get_running_pid()
|
|
||||||
gateway_running = gateway_pid is not None
|
|
||||||
remote_health_body: dict | None = None
|
|
||||||
|
|
||||||
if not gateway_running and _GATEWAY_HEALTH_URL:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
alive, remote_health_body = await loop.run_in_executor(
|
|
||||||
None, _probe_gateway_health
|
|
||||||
)
|
|
||||||
if alive:
|
|
||||||
gateway_running = True
|
|
||||||
# PID from the remote container (display only — not locally valid)
|
|
||||||
if remote_health_body:
|
|
||||||
gateway_pid = remote_health_body.get("pid")
|
|
||||||
|
|
||||||
gateway_state = None
|
|
||||||
gateway_platforms: dict = {}
|
|
||||||
gateway_exit_reason = None
|
|
||||||
gateway_updated_at = None
|
|
||||||
configured_gateway_platforms: set[str] | None = None
|
|
||||||
try:
|
try:
|
||||||
from gateway.config import load_gateway_config
|
current_ver, latest_ver = check_config_version()
|
||||||
|
# --- Gateway liveness detection ---
|
||||||
|
# Try local PID check first (same-host). If that fails and a remote
|
||||||
|
# GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the
|
||||||
|
# dashboard works when the gateway runs in a separate container.
|
||||||
|
gateway_pid = get_running_pid()
|
||||||
|
gateway_running = gateway_pid is not None
|
||||||
|
remote_health_body: dict | None = None
|
||||||
|
|
||||||
gateway_config = load_gateway_config()
|
if not gateway_running and _GATEWAY_HEALTH_URL:
|
||||||
configured_gateway_platforms = {
|
loop = asyncio.get_running_loop()
|
||||||
platform.value for platform in gateway_config.get_connected_platforms()
|
alive, remote_health_body = await loop.run_in_executor(
|
||||||
}
|
None, _probe_gateway_health
|
||||||
except Exception:
|
|
||||||
configured_gateway_platforms = None
|
|
||||||
|
|
||||||
# Prefer the detailed health endpoint response (has full state) when the
|
|
||||||
# local runtime status file is absent or stale (cross-container).
|
|
||||||
runtime = read_runtime_status()
|
|
||||||
if runtime is None and remote_health_body and remote_health_body.get("gateway_state"):
|
|
||||||
runtime = remote_health_body
|
|
||||||
|
|
||||||
if runtime:
|
|
||||||
gateway_state = runtime.get("gateway_state")
|
|
||||||
gateway_platforms = runtime.get("platforms") or {}
|
|
||||||
if configured_gateway_platforms is not None:
|
|
||||||
gateway_platforms = {
|
|
||||||
key: value
|
|
||||||
for key, value in gateway_platforms.items()
|
|
||||||
if key in configured_gateway_platforms
|
|
||||||
}
|
|
||||||
gateway_exit_reason = runtime.get("exit_reason")
|
|
||||||
gateway_updated_at = runtime.get("updated_at")
|
|
||||||
if not gateway_running:
|
|
||||||
gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped"
|
|
||||||
gateway_platforms = {}
|
|
||||||
elif gateway_running and remote_health_body is not None:
|
|
||||||
# The health probe confirmed the gateway is alive, but the local
|
|
||||||
# runtime status file may be stale (cross-container). Override
|
|
||||||
# stopped/None state so the dashboard shows the correct badge.
|
|
||||||
if gateway_state in {None, "stopped"}:
|
|
||||||
gateway_state = "running"
|
|
||||||
|
|
||||||
# If there was no runtime info at all but the health probe confirmed alive,
|
|
||||||
# ensure we still report the gateway as running (no shared volume scenario).
|
|
||||||
if gateway_running and gateway_state is None and remote_health_body is not None:
|
|
||||||
gateway_state = "running"
|
|
||||||
|
|
||||||
active_sessions = 0
|
|
||||||
try:
|
|
||||||
from hermes_state import SessionDB
|
|
||||||
db = SessionDB()
|
|
||||||
try:
|
|
||||||
sessions = db.list_sessions_rich(limit=50)
|
|
||||||
now = time.time()
|
|
||||||
active_sessions = sum(
|
|
||||||
1 for s in sessions
|
|
||||||
if s.get("ended_at") is None
|
|
||||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
|
||||||
)
|
)
|
||||||
finally:
|
if alive:
|
||||||
db.close()
|
gateway_running = True
|
||||||
except Exception:
|
# PID from the remote container (display only — not locally valid)
|
||||||
pass
|
if remote_health_body:
|
||||||
|
gateway_pid = remote_health_body.get("pid")
|
||||||
|
|
||||||
# Dashboard auth gate (Phase 7): surface whether the gate is engaged
|
gateway_state = None
|
||||||
# and which providers are registered so ``hermes status`` and the
|
gateway_platforms: dict = {}
|
||||||
# SPA's StatusPage can show "OAuth gate ON via Nous Research" or
|
gateway_exit_reason = None
|
||||||
# "loopback only — no auth gate" with no extra round trips.
|
gateway_updated_at = None
|
||||||
auth_required = bool(getattr(app.state, "auth_required", False))
|
configured_gateway_platforms: set[str] | None = None
|
||||||
auth_providers: list[str] = []
|
try:
|
||||||
try:
|
from gateway.config import load_gateway_config
|
||||||
from hermes_cli.dashboard_auth import list_providers as _list_providers
|
|
||||||
auth_providers = [p.name for p in _list_providers()]
|
|
||||||
except Exception:
|
|
||||||
# Module not importable yet (early startup) — leave as [].
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Always-public liveness + auth-gate shape. Safe for external uptime
|
gateway_config = load_gateway_config()
|
||||||
# probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login
|
configured_gateway_platforms = {
|
||||||
# bootstrap, and anyone who can curl the host — i.e. exactly the audience
|
platform.value for platform in gateway_config.get_connected_platforms()
|
||||||
# ``PUBLIC_API_PATHS`` documents this endpoint as serving.
|
}
|
||||||
status = {
|
except Exception:
|
||||||
"version": __version__,
|
configured_gateway_platforms = None
|
||||||
"release_date": __release_date__,
|
|
||||||
"config_version": current_ver,
|
|
||||||
"latest_config_version": latest_ver,
|
|
||||||
"can_update_hermes": not _dashboard_local_update_managed_externally(),
|
|
||||||
"gateway_running": gateway_running,
|
|
||||||
"gateway_state": gateway_state,
|
|
||||||
"gateway_platforms": gateway_platforms,
|
|
||||||
"gateway_exit_reason": gateway_exit_reason,
|
|
||||||
"gateway_updated_at": gateway_updated_at,
|
|
||||||
"active_sessions": active_sessions,
|
|
||||||
"auth_required": auth_required,
|
|
||||||
"auth_providers": auth_providers,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Absolute host paths, the gateway PID, and the internal gateway health
|
# Prefer the detailed health endpoint response (has full state) when the
|
||||||
# URL are deployment recon a liveness probe never needs. ``/api/status``
|
# local runtime status file is absent or stale (cross-container).
|
||||||
# is in ``PUBLIC_API_PATHS`` so it bypasses dashboard auth; on a
|
runtime = read_runtime_status()
|
||||||
# network-exposed (gated) bind that means *any* unauthenticated caller
|
if runtime is None and remote_health_body and remote_health_body.get("gateway_state"):
|
||||||
# reaches it, and leaking host metadata there contradicts the allowlist's
|
runtime = remote_health_body
|
||||||
# own contract ("version, gateway state, active session count, and the
|
runtime_pid = get_runtime_status_running_pid(runtime)
|
||||||
# dashboard auth-gate shape. No bodies, no session content, no secrets").
|
if not gateway_running and runtime_pid is not None:
|
||||||
# Surface this detail only on a loopback / ``--insecure`` bind, where the
|
gateway_running = True
|
||||||
# dashboard is local-only and the caller is already inside the trust
|
gateway_pid = runtime_pid
|
||||||
# envelope — the same loopback/gated split ``should_require_auth`` draws.
|
|
||||||
if not auth_required:
|
|
||||||
status.update({
|
|
||||||
"hermes_home": str(get_hermes_home()),
|
|
||||||
"config_path": str(get_config_path()),
|
|
||||||
"env_path": str(get_env_path()),
|
|
||||||
"gateway_pid": gateway_pid,
|
|
||||||
"gateway_health_url": _GATEWAY_HEALTH_URL,
|
|
||||||
})
|
|
||||||
|
|
||||||
return status
|
if runtime:
|
||||||
|
gateway_state = runtime.get("gateway_state")
|
||||||
|
gateway_platforms = runtime.get("platforms") or {}
|
||||||
|
if configured_gateway_platforms is not None:
|
||||||
|
gateway_platforms = {
|
||||||
|
key: value
|
||||||
|
for key, value in gateway_platforms.items()
|
||||||
|
if key in configured_gateway_platforms
|
||||||
|
}
|
||||||
|
gateway_exit_reason = runtime.get("exit_reason")
|
||||||
|
gateway_updated_at = runtime.get("updated_at")
|
||||||
|
if not gateway_running:
|
||||||
|
gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped"
|
||||||
|
gateway_platforms = {}
|
||||||
|
elif gateway_running and remote_health_body is not None:
|
||||||
|
# The health probe confirmed the gateway is alive, but the local
|
||||||
|
# runtime status file may be stale (cross-container). Override
|
||||||
|
# stopped/None state so the dashboard shows the correct badge.
|
||||||
|
if gateway_state in {None, "stopped"}:
|
||||||
|
gateway_state = "running"
|
||||||
|
|
||||||
|
# If there was no runtime info at all but the health probe confirmed alive,
|
||||||
|
# ensure we still report the gateway as running (no shared volume scenario).
|
||||||
|
if gateway_running and gateway_state is None and remote_health_body is not None:
|
||||||
|
gateway_state = "running"
|
||||||
|
|
||||||
|
active_sessions = 0
|
||||||
|
try:
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB()
|
||||||
|
try:
|
||||||
|
sessions = db.list_sessions_rich(limit=50)
|
||||||
|
now = time.time()
|
||||||
|
active_sessions = sum(
|
||||||
|
1 for s in sessions
|
||||||
|
if s.get("ended_at") is None
|
||||||
|
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Dashboard auth gate (Phase 7): surface whether the gate is engaged
|
||||||
|
# and which providers are registered so ``hermes status`` and the
|
||||||
|
# SPA's StatusPage can show "OAuth gate ON via Nous Research" or
|
||||||
|
# "loopback only — no auth gate" with no extra round trips.
|
||||||
|
auth_required = bool(getattr(app.state, "auth_required", False))
|
||||||
|
auth_providers: list[str] = []
|
||||||
|
try:
|
||||||
|
from hermes_cli.dashboard_auth import list_providers as _list_providers
|
||||||
|
auth_providers = [p.name for p in _list_providers()]
|
||||||
|
except Exception:
|
||||||
|
# Module not importable yet (early startup) — leave as [].
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Always-public liveness + auth-gate shape. Safe for external uptime
|
||||||
|
# probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login
|
||||||
|
# bootstrap, and anyone who can curl the host — i.e. exactly the audience
|
||||||
|
# ``PUBLIC_API_PATHS`` documents this endpoint as serving.
|
||||||
|
status = {
|
||||||
|
"version": __version__,
|
||||||
|
"release_date": __release_date__,
|
||||||
|
"config_version": current_ver,
|
||||||
|
"latest_config_version": latest_ver,
|
||||||
|
"can_update_hermes": not _dashboard_local_update_managed_externally(),
|
||||||
|
"gateway_running": gateway_running,
|
||||||
|
"gateway_state": gateway_state,
|
||||||
|
"gateway_platforms": gateway_platforms,
|
||||||
|
"gateway_exit_reason": gateway_exit_reason,
|
||||||
|
"gateway_updated_at": gateway_updated_at,
|
||||||
|
"active_sessions": active_sessions,
|
||||||
|
"auth_required": auth_required,
|
||||||
|
"auth_providers": auth_providers,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Absolute host paths, the gateway PID, and the internal gateway health
|
||||||
|
# URL are deployment recon a liveness probe never needs. ``/api/status``
|
||||||
|
# is in ``PUBLIC_API_PATHS`` so it bypasses dashboard auth; on a
|
||||||
|
# network-exposed (gated) bind that means *any* unauthenticated caller
|
||||||
|
# reaches it, and leaking host metadata there contradicts the allowlist's
|
||||||
|
# own contract ("version, gateway state, active session count, and the
|
||||||
|
# dashboard auth-gate shape. No bodies, no session content, no secrets").
|
||||||
|
# Surface this detail only on a loopback / ``--insecure`` bind, where the
|
||||||
|
# dashboard is local-only and the caller is already inside the trust
|
||||||
|
# envelope — the same loopback/gated split ``should_require_auth`` draws.
|
||||||
|
if not auth_required:
|
||||||
|
status.update({
|
||||||
|
"hermes_home": str(get_hermes_home()),
|
||||||
|
"config_path": str(get_config_path()),
|
||||||
|
"env_path": str(get_env_path()),
|
||||||
|
"gateway_pid": gateway_pid,
|
||||||
|
"gateway_health_url": _GATEWAY_HEALTH_URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
return status
|
||||||
|
finally:
|
||||||
|
if status_scope is not None:
|
||||||
|
status_scope.__exit__(*sys.exc_info())
|
||||||
|
|
||||||
|
|
||||||
_WINDOWS_11_MIN_BUILD = 22000
|
_WINDOWS_11_MIN_BUILD = 22000
|
||||||
@ -2095,6 +2116,7 @@ _ACTION_LOG_FILES: Dict[str, str] = {
|
|||||||
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
|
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
|
||||||
# report liveness and exit code without shelling out to ``ps``.
|
# report liveness and exit code without shelling out to ``ps``.
|
||||||
_ACTION_PROCS: Dict[str, subprocess.Popen] = {}
|
_ACTION_PROCS: Dict[str, subprocess.Popen] = {}
|
||||||
|
_ACTION_COMMANDS: Dict[str, Tuple[str, ...]] = {}
|
||||||
|
|
||||||
# ``name`` → completed synthetic action result for actions the server handled
|
# ``name`` → completed synthetic action result for actions the server handled
|
||||||
# without spawning a subprocess (for example, unsupported Docker updates).
|
# without spawning a subprocess (for example, unsupported Docker updates).
|
||||||
@ -2114,6 +2136,7 @@ def _record_completed_action(name: str, message: str, exit_code: int = 1) -> Non
|
|||||||
if not message.endswith("\n"):
|
if not message.endswith("\n"):
|
||||||
log_file.write(b"\n")
|
log_file.write(b"\n")
|
||||||
_ACTION_PROCS.pop(name, None)
|
_ACTION_PROCS.pop(name, None)
|
||||||
|
_ACTION_COMMANDS.pop(name, None)
|
||||||
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None}
|
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None}
|
||||||
|
|
||||||
|
|
||||||
@ -2154,6 +2177,7 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
|
|||||||
# fd per spawned action.
|
# fd per spawned action.
|
||||||
log_file.close()
|
log_file.close()
|
||||||
_ACTION_RESULTS.pop(name, None)
|
_ACTION_RESULTS.pop(name, None)
|
||||||
|
_ACTION_COMMANDS[name] = tuple(subcommand)
|
||||||
_ACTION_PROCS[name] = proc
|
_ACTION_PROCS[name] = proc
|
||||||
return proc
|
return proc
|
||||||
|
|
||||||
@ -2172,7 +2196,15 @@ def _tail_lines(path: Path, n: int) -> List[str]:
|
|||||||
return lines[-n:] if n > 0 else lines
|
return lines[-n:] if n > 0 else lines
|
||||||
|
|
||||||
|
|
||||||
def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
|
def _gateway_subcommand(profile: Optional[str], verb: str) -> List[str]:
|
||||||
|
return _profile_cli_args(profile) + ["gateway", verb]
|
||||||
|
|
||||||
|
|
||||||
|
def _gateway_display_command(profile: Optional[str], verb: str) -> str:
|
||||||
|
return " ".join(["hermes", *_gateway_subcommand(profile, verb)])
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]:
|
||||||
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
|
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
|
||||||
|
|
||||||
Multiple dashboard paths can request a restart in quick succession
|
Multiple dashboard paths can request a restart in quick succession
|
||||||
@ -2183,16 +2215,20 @@ def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
|
|||||||
|
|
||||||
Returns ``(proc, reused)``.
|
Returns ``(proc, reused)``.
|
||||||
"""
|
"""
|
||||||
|
subcommand = _gateway_subcommand(profile, "restart")
|
||||||
existing = _ACTION_PROCS.get("gateway-restart")
|
existing = _ACTION_PROCS.get("gateway-restart")
|
||||||
if existing is not None and existing.poll() is None:
|
if existing is not None and existing.poll() is None:
|
||||||
return existing, True
|
existing_command = _ACTION_COMMANDS.get("gateway-restart")
|
||||||
return _spawn_hermes_action(["gateway", "restart"], "gateway-restart"), False
|
if existing_command is None or existing_command == tuple(subcommand):
|
||||||
|
return existing, True
|
||||||
|
raise RuntimeError("gateway restart already in progress for another profile")
|
||||||
|
return _spawn_hermes_action(subcommand, "gateway-restart"), False
|
||||||
|
|
||||||
|
|
||||||
def _restart_gateway_after_webhook_enable() -> dict[str, Any]:
|
def _restart_gateway_after_webhook_enable(profile: Optional[str] = None) -> dict[str, Any]:
|
||||||
"""Best-effort gateway restart after enabling the webhook platform."""
|
"""Best-effort gateway restart after enabling the webhook platform."""
|
||||||
try:
|
try:
|
||||||
proc, reused = _spawn_gateway_restart()
|
proc, reused = _spawn_gateway_restart(profile)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.exception("Failed to auto-restart gateway after enabling webhooks")
|
_log.exception("Failed to auto-restart gateway after enabling webhooks")
|
||||||
return {
|
return {
|
||||||
@ -2212,10 +2248,12 @@ def _restart_gateway_after_webhook_enable() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/gateway/restart")
|
@app.post("/api/gateway/restart")
|
||||||
async def restart_gateway():
|
async def restart_gateway(profile: Optional[str] = None):
|
||||||
"""Kick off a ``hermes gateway restart`` in the background."""
|
"""Kick off a ``hermes gateway restart`` in the background."""
|
||||||
try:
|
try:
|
||||||
proc, _reused = _spawn_gateway_restart()
|
proc, _reused = _spawn_gateway_restart(profile)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.exception("Failed to spawn gateway restart")
|
_log.exception("Failed to spawn gateway restart")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}")
|
raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}")
|
||||||
@ -2635,6 +2673,7 @@ async def get_action_status(name: str, lines: int = 200):
|
|||||||
pass
|
pass
|
||||||
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": pid}
|
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": pid}
|
||||||
_ACTION_PROCS.pop(name, None)
|
_ACTION_PROCS.pop(name, None)
|
||||||
|
_ACTION_COMMANDS.pop(name, None)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"name": name,
|
"name": name,
|
||||||
@ -4346,13 +4385,16 @@ def _messaging_platform_payload(
|
|||||||
scoped: bool = False,
|
scoped: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
platform_id = entry["id"]
|
platform_id = entry["id"]
|
||||||
gateway_running = get_running_pid() is not None
|
|
||||||
runtime_platforms = runtime.get("platforms") if runtime else {}
|
runtime_platforms = runtime.get("platforms") if runtime else {}
|
||||||
runtime_platform = (
|
runtime_platform = (
|
||||||
runtime_platforms.get(platform_id, {})
|
runtime_platforms.get(platform_id, {})
|
||||||
if isinstance(runtime_platforms, dict)
|
if isinstance(runtime_platforms, dict)
|
||||||
else {}
|
else {}
|
||||||
)
|
)
|
||||||
|
gateway_running = (
|
||||||
|
get_running_pid() is not None
|
||||||
|
or get_runtime_status_running_pid(runtime) is not None
|
||||||
|
)
|
||||||
env_vars = []
|
env_vars = []
|
||||||
|
|
||||||
for key in entry["env_vars"]:
|
for key in entry["env_vars"]:
|
||||||
@ -4415,15 +4457,37 @@ def _messaging_platform_payload(
|
|||||||
state = (
|
state = (
|
||||||
runtime_platform.get("state") if isinstance(runtime_platform, dict) else None
|
runtime_platform.get("state") if isinstance(runtime_platform, dict) else None
|
||||||
)
|
)
|
||||||
|
runtime_gateway_state = runtime.get("gateway_state") if isinstance(runtime, dict) else None
|
||||||
|
runtime_gateway_error = runtime.get("exit_reason") if isinstance(runtime, dict) else None
|
||||||
if not enabled:
|
if not enabled:
|
||||||
state = "disabled"
|
state = "disabled"
|
||||||
elif not configured:
|
elif not configured:
|
||||||
state = "not_configured"
|
state = "not_configured"
|
||||||
elif gateway_running and not state:
|
elif gateway_running and not state:
|
||||||
state = "pending_restart"
|
state = "pending_restart"
|
||||||
|
elif (
|
||||||
|
not gateway_running
|
||||||
|
and not state
|
||||||
|
and runtime_gateway_state == "startup_failed"
|
||||||
|
):
|
||||||
|
state = "startup_failed"
|
||||||
elif not gateway_running and not state:
|
elif not gateway_running and not state:
|
||||||
state = "gateway_stopped"
|
state = "gateway_stopped"
|
||||||
|
|
||||||
|
error_code = (
|
||||||
|
runtime_platform.get("error_code")
|
||||||
|
if isinstance(runtime_platform, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
error_message = (
|
||||||
|
runtime_platform.get("error_message")
|
||||||
|
if isinstance(runtime_platform, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if state == "startup_failed":
|
||||||
|
error_code = error_code or "startup_failed"
|
||||||
|
error_message = error_message or runtime_gateway_error
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": platform_id,
|
"id": platform_id,
|
||||||
"name": entry["name"],
|
"name": entry["name"],
|
||||||
@ -4433,16 +4497,8 @@ def _messaging_platform_payload(
|
|||||||
"configured": configured,
|
"configured": configured,
|
||||||
"gateway_running": gateway_running,
|
"gateway_running": gateway_running,
|
||||||
"state": state,
|
"state": state,
|
||||||
"error_code": (
|
"error_code": error_code,
|
||||||
runtime_platform.get("error_code")
|
"error_message": error_message,
|
||||||
if isinstance(runtime_platform, dict)
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"error_message": (
|
|
||||||
runtime_platform.get("error_message")
|
|
||||||
if isinstance(runtime_platform, dict)
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"updated_at": (
|
"updated_at": (
|
||||||
runtime_platform.get("updated_at")
|
runtime_platform.get("updated_at")
|
||||||
if isinstance(runtime_platform, dict)
|
if isinstance(runtime_platform, dict)
|
||||||
@ -4732,7 +4788,7 @@ async def get_telegram_onboarding_status(pairing_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
|
def _restart_gateway_after_telegram_onboarding(profile: Optional[str] = None) -> dict[str, Any]:
|
||||||
"""Best-effort gateway restart after saving Telegram QR onboarding.
|
"""Best-effort gateway restart after saving Telegram QR onboarding.
|
||||||
|
|
||||||
The QR flow naturally pulls users into Telegram on another device. If the
|
The QR flow naturally pulls users into Telegram on another device. If the
|
||||||
@ -4741,7 +4797,7 @@ def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
|
|||||||
restart failures so the UI can fall back to the existing manual banner.
|
restart failures so the UI can fall back to the existing manual banner.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
proc, reused = _spawn_gateway_restart()
|
proc, reused = _spawn_gateway_restart(profile)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.exception("Failed to auto-restart gateway after Telegram onboarding")
|
_log.exception("Failed to auto-restart gateway after Telegram onboarding")
|
||||||
return {
|
return {
|
||||||
@ -4762,7 +4818,7 @@ def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
|
|||||||
|
|
||||||
@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
|
@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
|
||||||
async def apply_telegram_onboarding(
|
async def apply_telegram_onboarding(
|
||||||
pairing_id: str, body: TelegramOnboardingApply
|
pairing_id: str, body: TelegramOnboardingApply, profile: Optional[str] = None
|
||||||
):
|
):
|
||||||
allowed_user_ids = []
|
allowed_user_ids = []
|
||||||
seen = set()
|
seen = set()
|
||||||
@ -4798,10 +4854,14 @@ async def apply_telegram_onboarding(
|
|||||||
detail="Telegram setup is not ready yet.",
|
detail="Telegram setup is not ready yet.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
effective_profile = body.profile or profile
|
||||||
try:
|
try:
|
||||||
save_env_value("TELEGRAM_BOT_TOKEN", bot_token)
|
with _profile_scope(effective_profile):
|
||||||
save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids))
|
save_env_value("TELEGRAM_BOT_TOKEN", bot_token)
|
||||||
_write_platform_enabled("telegram", True)
|
save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids))
|
||||||
|
_write_platform_enabled("telegram", True)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@ -4814,7 +4874,7 @@ async def apply_telegram_onboarding(
|
|||||||
with _telegram_onboarding_lock:
|
with _telegram_onboarding_lock:
|
||||||
_telegram_onboarding_pairings.pop(pairing_id, None)
|
_telegram_onboarding_pairings.pop(pairing_id, None)
|
||||||
|
|
||||||
restart_result = _restart_gateway_after_telegram_onboarding()
|
restart_result = _restart_gateway_after_telegram_onboarding(effective_profile)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@ -4842,6 +4902,8 @@ async def get_messaging_platforms(profile: Optional[str] = None):
|
|||||||
env_on_disk = load_env()
|
env_on_disk = load_env()
|
||||||
runtime = read_runtime_status()
|
runtime = read_runtime_status()
|
||||||
return {
|
return {
|
||||||
|
"env_path": str(get_env_path()),
|
||||||
|
"gateway_start_command": _gateway_display_command(profile, "start"),
|
||||||
"platforms": [
|
"platforms": [
|
||||||
_messaging_platform_payload(
|
_messaging_platform_payload(
|
||||||
entry, env_on_disk, runtime, scoped=scoped_dir is not None
|
entry, env_on_disk, runtime, scoped=scoped_dir is not None
|
||||||
@ -7802,9 +7864,11 @@ async def set_webhook_enabled(name: str, body: WebhookEnabledToggle):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/gateway/start")
|
@app.post("/api/gateway/start")
|
||||||
async def start_gateway():
|
async def start_gateway(profile: Optional[str] = None):
|
||||||
try:
|
try:
|
||||||
proc = _spawn_hermes_action(["gateway", "start"], "gateway-start")
|
proc = _spawn_hermes_action(_gateway_subcommand(profile, "start"), "gateway-start")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.exception("Failed to spawn gateway start")
|
_log.exception("Failed to spawn gateway start")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to start gateway: {exc}")
|
raise HTTPException(status_code=500, detail=f"Failed to start gateway: {exc}")
|
||||||
@ -7812,9 +7876,11 @@ async def start_gateway():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/gateway/stop")
|
@app.post("/api/gateway/stop")
|
||||||
async def stop_gateway():
|
async def stop_gateway(profile: Optional[str] = None):
|
||||||
try:
|
try:
|
||||||
proc = _spawn_hermes_action(["gateway", "stop"], "gateway-stop")
|
proc = _spawn_hermes_action(_gateway_subcommand(profile, "stop"), "gateway-stop")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.exception("Failed to spawn gateway stop")
|
_log.exception("Failed to spawn gateway stop")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to stop gateway: {exc}")
|
raise HTTPException(status_code=500, detail=f"Failed to stop gateway: {exc}")
|
||||||
@ -8346,7 +8412,7 @@ def _profile_cli_args(profile: Optional[str]) -> List[str]:
|
|||||||
profile (no args, legacy behavior).
|
profile (no args, legacy behavior).
|
||||||
"""
|
"""
|
||||||
requested = (profile or "").strip()
|
requested = (profile or "").strip()
|
||||||
if not requested or requested.lower() == "current":
|
if not requested or requested.lower() in {"current", "default"}:
|
||||||
return []
|
return []
|
||||||
from hermes_cli import profiles as profiles_mod
|
from hermes_cli import profiles as profiles_mod
|
||||||
_resolve_profile_dir(requested)
|
_resolve_profile_dir(requested)
|
||||||
|
|||||||
@ -20,3 +20,34 @@ def test_runtime_health_lines_include_fatal_platform_and_startup_reason(monkeypa
|
|||||||
|
|
||||||
assert "⚠ telegram: another poller is active" in lines
|
assert "⚠ telegram: another poller is active" in lines
|
||||||
assert "⚠ Last startup issue: telegram conflict" in lines
|
assert "⚠ Last startup issue: telegram conflict" in lines
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_status_running_pid_validates_live_gateway_record(monkeypatch):
|
||||||
|
from gateway import status as status_mod
|
||||||
|
|
||||||
|
runtime = {
|
||||||
|
"pid": 12345,
|
||||||
|
"kind": "hermes-gateway",
|
||||||
|
"argv": ["/opt/hermes/hermes_cli/main.py", "gateway", "run", "--replace"],
|
||||||
|
"start_time": None,
|
||||||
|
"gateway_state": "running",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(status_mod, "_pid_exists", lambda pid: pid == 12345)
|
||||||
|
monkeypatch.setattr(status_mod, "_get_process_start_time", lambda pid: None)
|
||||||
|
monkeypatch.setattr(status_mod, "_looks_like_gateway_process", lambda pid: False)
|
||||||
|
|
||||||
|
assert status_mod.get_runtime_status_running_pid(runtime) == 12345
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_status_running_pid_rejects_stopped_record(monkeypatch):
|
||||||
|
from gateway import status as status_mod
|
||||||
|
|
||||||
|
runtime = {
|
||||||
|
"pid": 12345,
|
||||||
|
"kind": "hermes-gateway",
|
||||||
|
"argv": ["/opt/hermes/hermes_cli/main.py", "gateway", "run", "--replace"],
|
||||||
|
"gateway_state": "stopped",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(status_mod, "_pid_exists", lambda pid: True)
|
||||||
|
|
||||||
|
assert status_mod.get_runtime_status_running_pid(runtime) is None
|
||||||
|
|||||||
@ -91,6 +91,47 @@ class TestProfileScopedMessagingReads:
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_scoped_read_returns_profile_path_command_and_startup_failure(
|
||||||
|
self, client, isolated_profiles, monkeypatch
|
||||||
|
):
|
||||||
|
import hermes_cli.web_server as web_server
|
||||||
|
|
||||||
|
worker_home = isolated_profiles["worker_alpha"]
|
||||||
|
(worker_home / ".env").write_text(
|
||||||
|
"TELEGRAM_BOT_TOKEN=worker-token\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(worker_home / "config.yaml").write_text(
|
||||||
|
yaml.safe_dump({"platforms": {"telegram": {"enabled": True}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(web_server, "get_running_pid", lambda: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
web_server,
|
||||||
|
"read_runtime_status",
|
||||||
|
lambda: {
|
||||||
|
"gateway_state": "startup_failed",
|
||||||
|
"exit_reason": "all configured messaging platforms failed to connect",
|
||||||
|
"platforms": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
"/api/messaging/platforms", params={"profile": "worker_alpha"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.json()
|
||||||
|
assert payload["env_path"] == str(worker_home / ".env")
|
||||||
|
assert payload["gateway_start_command"] == (
|
||||||
|
"hermes -p worker_alpha gateway start"
|
||||||
|
)
|
||||||
|
telegram = _telegram(payload)
|
||||||
|
assert telegram["state"] == "startup_failed"
|
||||||
|
assert telegram["error_code"] == "startup_failed"
|
||||||
|
assert telegram["error_message"] == (
|
||||||
|
"all configured messaging platforms failed to connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestProfileScopedMessagingWrites:
|
class TestProfileScopedMessagingWrites:
|
||||||
def test_scoped_write_lands_in_target_profile_env(
|
def test_scoped_write_lands_in_target_profile_env(
|
||||||
|
|||||||
@ -353,6 +353,167 @@ class TestProfileScopedPostSetup:
|
|||||||
assert calls == [["tools", "post-setup", "agent_browser"]]
|
assert calls == [["tools", "post-setup", "agent_browser"]]
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileScopedGateway:
|
||||||
|
def test_lifecycle_spawns_with_profile_flag(
|
||||||
|
self, client, isolated_profiles, monkeypatch
|
||||||
|
):
|
||||||
|
import hermes_cli.web_server as web_server
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class _FakeProc:
|
||||||
|
pid = 888
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
web_server,
|
||||||
|
"_spawn_hermes_action",
|
||||||
|
lambda subcommand, name: calls.append((list(subcommand), name)) or _FakeProc(),
|
||||||
|
)
|
||||||
|
web_server._ACTION_PROCS.pop("gateway-restart", None)
|
||||||
|
web_server._ACTION_COMMANDS.pop("gateway-restart", None)
|
||||||
|
|
||||||
|
for verb in ("start", "stop", "restart"):
|
||||||
|
resp = client.post(f"/api/gateway/{verb}", params={"profile": "worker_beta"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
assert calls == [
|
||||||
|
(["-p", "worker_beta", "gateway", "start"], "gateway-start"),
|
||||||
|
(["-p", "worker_beta", "gateway", "stop"], "gateway-stop"),
|
||||||
|
(["-p", "worker_beta", "gateway", "restart"], "gateway-restart"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_status_reads_requested_profile_home(
|
||||||
|
self, client, isolated_profiles, monkeypatch
|
||||||
|
):
|
||||||
|
import hermes_cli.web_server as web_server
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
|
seen_homes = []
|
||||||
|
|
||||||
|
def fake_get_running_pid():
|
||||||
|
seen_homes.append(str(get_hermes_home()))
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
|
||||||
|
monkeypatch.setattr(web_server, "get_running_pid", fake_get_running_pid)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
web_server,
|
||||||
|
"read_runtime_status",
|
||||||
|
lambda: {"gateway_state": "startup_failed", "platforms": {}},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
|
||||||
|
|
||||||
|
resp = client.get("/api/status", params={"profile": "worker_beta"})
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert seen_homes[0] == str(isolated_profiles["worker_beta"])
|
||||||
|
assert resp.json()["hermes_home"] == str(isolated_profiles["worker_beta"])
|
||||||
|
|
||||||
|
def test_status_uses_runtime_pid_when_profile_pid_file_is_missing(
|
||||||
|
self, client, isolated_profiles, monkeypatch
|
||||||
|
):
|
||||||
|
import hermes_cli.web_server as web_server
|
||||||
|
|
||||||
|
worker_home = isolated_profiles["worker_beta"]
|
||||||
|
(worker_home / ".env").write_text(
|
||||||
|
"TELEGRAM_BOT_TOKEN=worker-token\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(worker_home / "config.yaml").write_text(
|
||||||
|
yaml.safe_dump({"platforms": {"telegram": {"enabled": True}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
runtime = {
|
||||||
|
"pid": 4242,
|
||||||
|
"gateway_state": "running",
|
||||||
|
"platforms": {"telegram": {"state": "connected"}},
|
||||||
|
"exit_reason": None,
|
||||||
|
"updated_at": "2026-06-17T00:00:00+00:00",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
|
||||||
|
monkeypatch.setattr(web_server, "get_running_pid", lambda: None)
|
||||||
|
monkeypatch.setattr(web_server, "read_runtime_status", lambda: runtime)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
web_server, "get_runtime_status_running_pid", lambda payload: 4242
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
|
||||||
|
from gateway.config import Platform
|
||||||
|
|
||||||
|
class _FakeGatewayConfig:
|
||||||
|
def get_connected_platforms(self):
|
||||||
|
return [Platform.TELEGRAM]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gateway.config.load_gateway_config", lambda: _FakeGatewayConfig()
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/status", params={"profile": "worker_beta"})
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["gateway_running"] is True
|
||||||
|
assert data["gateway_pid"] == 4242
|
||||||
|
assert data["gateway_state"] == "running"
|
||||||
|
assert data["gateway_platforms"] == {"telegram": {"state": "connected"}}
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileScopedTelegramOnboarding:
|
||||||
|
def test_apply_writes_target_profile_and_restarts_target(
|
||||||
|
self, client, isolated_profiles, monkeypatch
|
||||||
|
):
|
||||||
|
import time
|
||||||
|
import hermes_cli.web_server as web_server
|
||||||
|
|
||||||
|
with web_server._telegram_onboarding_lock:
|
||||||
|
web_server._telegram_onboarding_pairings.clear()
|
||||||
|
web_server._telegram_onboarding_pairings["pair-worker"] = (
|
||||||
|
web_server._TelegramOnboardingPairing(
|
||||||
|
poll_token="poll-secret",
|
||||||
|
expires_at="2027-05-18T00:00:00.000Z",
|
||||||
|
expires_at_ts=time.time() + 600,
|
||||||
|
bot_token="123456:SECRET",
|
||||||
|
bot_username="worker_bot",
|
||||||
|
owner_user_id="123456789",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class _FakeProc:
|
||||||
|
pid = 889
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
web_server,
|
||||||
|
"_spawn_hermes_action",
|
||||||
|
lambda subcommand, name: calls.append((list(subcommand), name)) or _FakeProc(),
|
||||||
|
)
|
||||||
|
web_server._ACTION_PROCS.pop("gateway-restart", None)
|
||||||
|
web_server._ACTION_COMMANDS.pop("gateway-restart", None)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/messaging/telegram/onboarding/pair-worker/apply",
|
||||||
|
params={"profile": "worker_beta"},
|
||||||
|
json={"allowed_user_ids": ["123456789"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["restart_started"] is True
|
||||||
|
assert calls == [
|
||||||
|
(["-p", "worker_beta", "gateway", "restart"], "gateway-restart")
|
||||||
|
]
|
||||||
|
|
||||||
|
worker_env = (isolated_profiles["worker_beta"] / ".env").read_text()
|
||||||
|
assert "TELEGRAM_BOT_TOKEN=123456:SECRET" in worker_env
|
||||||
|
assert "TELEGRAM_ALLOWED_USERS=123456789" in worker_env
|
||||||
|
default_env_path = isolated_profiles["default"] / ".env"
|
||||||
|
if default_env_path.exists():
|
||||||
|
assert "TELEGRAM_BOT_TOKEN" not in default_env_path.read_text()
|
||||||
|
|
||||||
|
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||||
|
default_cfg = _cfg(isolated_profiles["default"])
|
||||||
|
assert worker_cfg["platforms"]["telegram"]["enabled"] is True
|
||||||
|
assert default_cfg.get("platforms", {}).get("telegram", {}).get("enabled") is not True
|
||||||
|
|
||||||
|
|
||||||
class TestProfileScopedChatPty:
|
class TestProfileScopedChatPty:
|
||||||
def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch):
|
def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch):
|
||||||
import hermes_cli.web_server as web_server
|
import hermes_cli.web_server as web_server
|
||||||
|
|||||||
@ -60,10 +60,11 @@ export function getManagementProfile(): string {
|
|||||||
|
|
||||||
// Endpoint families that honor ?profile= on the backend (web_server.py
|
// Endpoint families that honor ?profile= on the backend (web_server.py
|
||||||
// _profile_scope or explicit per-profile DB opens). Anything else — ops,
|
// _profile_scope or explicit per-profile DB opens). Anything else — ops,
|
||||||
// pairing, telegram onboarding, cron (which has its own per-job profile
|
// pairing, cron (which has its own per-job profile params), profiles
|
||||||
// params), profiles themselves — is machine-global or self-scoped and must
|
// themselves — is machine-global or self-scoped and must NOT be rewritten.
|
||||||
// NOT be rewritten.
|
|
||||||
const PROFILE_SCOPED_PREFIXES = [
|
const PROFILE_SCOPED_PREFIXES = [
|
||||||
|
"/api/status",
|
||||||
|
"/api/gateway",
|
||||||
"/api/analytics",
|
"/api/analytics",
|
||||||
"/api/skills",
|
"/api/skills",
|
||||||
"/api/tools/toolsets",
|
"/api/tools/toolsets",
|
||||||
@ -71,6 +72,7 @@ const PROFILE_SCOPED_PREFIXES = [
|
|||||||
"/api/env",
|
"/api/env",
|
||||||
"/api/mcp",
|
"/api/mcp",
|
||||||
"/api/messaging/platforms",
|
"/api/messaging/platforms",
|
||||||
|
"/api/messaging/telegram/onboarding",
|
||||||
"/api/model/info",
|
"/api/model/info",
|
||||||
"/api/model/set",
|
"/api/model/set",
|
||||||
"/api/model/auxiliary",
|
"/api/model/auxiliary",
|
||||||
@ -771,7 +773,7 @@ export const api = {
|
|||||||
|
|
||||||
// Messaging platforms (gateway channels)
|
// Messaging platforms (gateway channels)
|
||||||
getMessagingPlatforms: () =>
|
getMessagingPlatforms: () =>
|
||||||
fetchJSON<{ platforms: MessagingPlatform[] }>("/api/messaging/platforms"),
|
fetchJSON<MessagingPlatformsResponse>("/api/messaging/platforms"),
|
||||||
updateMessagingPlatform: (id: string, body: MessagingPlatformUpdate) =>
|
updateMessagingPlatform: (id: string, body: MessagingPlatformUpdate) =>
|
||||||
fetchJSON<{ ok: boolean; platform: string }>(
|
fetchJSON<{ ok: boolean; platform: string }>(
|
||||||
`/api/messaging/platforms/${encodeURIComponent(id)}`,
|
`/api/messaging/platforms/${encodeURIComponent(id)}`,
|
||||||
@ -801,7 +803,7 @@ export const api = {
|
|||||||
),
|
),
|
||||||
applyTelegramOnboarding: (
|
applyTelegramOnboarding: (
|
||||||
pairingId: string,
|
pairingId: string,
|
||||||
body: { allowed_user_ids: string[] },
|
body: { allowed_user_ids: string[]; profile?: string },
|
||||||
) =>
|
) =>
|
||||||
fetchJSON<TelegramOnboardingApplyResponse>(
|
fetchJSON<TelegramOnboardingApplyResponse>(
|
||||||
`/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}/apply`,
|
`/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}/apply`,
|
||||||
@ -1339,7 +1341,7 @@ export interface MessagingPlatform {
|
|||||||
gateway_running: boolean;
|
gateway_running: boolean;
|
||||||
/**
|
/**
|
||||||
* "connected" | "disabled" | "not_configured" | "pending_restart" |
|
* "connected" | "disabled" | "not_configured" | "pending_restart" |
|
||||||
* "gateway_stopped" | "disconnected" | "fatal" | string
|
* "gateway_stopped" | "startup_failed" | "disconnected" | "fatal" | string
|
||||||
*/
|
*/
|
||||||
state: string;
|
state: string;
|
||||||
error_code: string | null;
|
error_code: string | null;
|
||||||
@ -1349,6 +1351,12 @@ export interface MessagingPlatform {
|
|||||||
env_vars: MessagingPlatformEnvVar[];
|
env_vars: MessagingPlatformEnvVar[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessagingPlatformsResponse {
|
||||||
|
env_path: string;
|
||||||
|
gateway_start_command: string;
|
||||||
|
platforms: MessagingPlatform[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessagingPlatformUpdate {
|
export interface MessagingPlatformUpdate {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
|
|||||||
@ -43,6 +43,7 @@ const STATE_BADGE: Record<
|
|||||||
connected: { tone: "success", label: "Connected" },
|
connected: { tone: "success", label: "Connected" },
|
||||||
pending_restart: { tone: "warning", label: "Restart to apply" },
|
pending_restart: { tone: "warning", label: "Restart to apply" },
|
||||||
gateway_stopped: { tone: "warning", label: "Gateway stopped" },
|
gateway_stopped: { tone: "warning", label: "Gateway stopped" },
|
||||||
|
startup_failed: { tone: "destructive", label: "Start failed" },
|
||||||
disconnected: { tone: "warning", label: "Disconnected" },
|
disconnected: { tone: "warning", label: "Disconnected" },
|
||||||
not_configured: { tone: "outline", label: "Not configured" },
|
not_configured: { tone: "outline", label: "Not configured" },
|
||||||
disabled: { tone: "secondary", label: "Disabled" },
|
disabled: { tone: "secondary", label: "Disabled" },
|
||||||
@ -71,6 +72,10 @@ function isTerminalTelegramOnboardingError(error: unknown): boolean {
|
|||||||
|
|
||||||
export default function ChannelsPage() {
|
export default function ChannelsPage() {
|
||||||
const [platforms, setPlatforms] = useState<MessagingPlatform[]>([]);
|
const [platforms, setPlatforms] = useState<MessagingPlatform[]>([]);
|
||||||
|
const [envPath, setEnvPath] = useState("~/.hermes/.env");
|
||||||
|
const [gatewayStartCommand, setGatewayStartCommand] = useState(
|
||||||
|
"hermes gateway start",
|
||||||
|
);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { toast, showToast } = useToast();
|
const { toast, showToast } = useToast();
|
||||||
const { setEnd } = usePageHeader();
|
const { setEnd } = usePageHeader();
|
||||||
@ -93,7 +98,11 @@ export default function ChannelsPage() {
|
|||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
return api
|
return api
|
||||||
.getMessagingPlatforms()
|
.getMessagingPlatforms()
|
||||||
.then((res) => setPlatforms(res.platforms))
|
.then((res) => {
|
||||||
|
setPlatforms(res.platforms);
|
||||||
|
setEnvPath(res.env_path || "~/.hermes/.env");
|
||||||
|
setGatewayStartCommand(res.gateway_start_command || "hermes gateway start");
|
||||||
|
})
|
||||||
.catch((e) => showToast(`Error: ${e}`, "error"));
|
.catch((e) => showToast(`Error: ${e}`, "error"));
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
|
|
||||||
@ -253,7 +262,7 @@ export default function ChannelsPage() {
|
|||||||
<WifiOff className="h-4 w-4 shrink-0" />
|
<WifiOff className="h-4 w-4 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
The gateway is not running. Configure channels here, then start the
|
The gateway is not running. Configure channels here, then start the
|
||||||
gateway with <code className="font-courier">hermes gateway start</code>{" "}
|
gateway with <code className="font-courier">{gatewayStartCommand}</code>{" "}
|
||||||
(or the Restart button above).
|
(or the Restart button above).
|
||||||
</span>
|
</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -262,7 +271,7 @@ export default function ChannelsPage() {
|
|||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{configured} of {platforms.length} channels configured. Credentials are
|
{configured} of {platforms.length} channels configured. Credentials are
|
||||||
written to <code className="font-courier">~/.hermes/.env</code>; the
|
written to <code className="font-courier">{envPath}</code>; the
|
||||||
gateway connects each enabled channel on its next restart.
|
gateway connects each enabled channel on its next restart.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -369,7 +378,7 @@ export default function ChannelsPage() {
|
|||||||
const StateIcon =
|
const StateIcon =
|
||||||
platform.state === "connected"
|
platform.state === "connected"
|
||||||
? CheckCircle2
|
? CheckCircle2
|
||||||
: platform.state === "fatal"
|
: platform.state === "fatal" || platform.state === "startup_failed"
|
||||||
? AlertTriangle
|
? AlertTriangle
|
||||||
: Radio;
|
: Radio;
|
||||||
return (
|
return (
|
||||||
@ -382,7 +391,8 @@ export default function ChannelsPage() {
|
|||||||
"h-5 w-5 shrink-0 mt-0.5",
|
"h-5 w-5 shrink-0 mt-0.5",
|
||||||
platform.state === "connected"
|
platform.state === "connected"
|
||||||
? "text-success"
|
? "text-success"
|
||||||
: platform.state === "fatal"
|
: platform.state === "fatal" ||
|
||||||
|
platform.state === "startup_failed"
|
||||||
? "text-destructive"
|
? "text-destructive"
|
||||||
: "text-muted-foreground",
|
: "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user