From 223881e492dcbdeb560476cd52cb40afe6dfa686 Mon Sep 17 00:00:00 2001 From: robbyczgw-cla Date: Wed, 15 Jul 2026 08:33:24 +0000 Subject: [PATCH] fix(dashboard): reject invalid MoA controls --- hermes_cli/web_server.py | 36 +++++++++++++++++++++-------- tests/hermes_cli/test_web_server.py | 31 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ade1a8a0eade..b02edc272777 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -27,6 +27,7 @@ import inspect import importlib.util import json import logging +import math import mimetypes import os import queue @@ -47,7 +48,7 @@ import zipfile from hermes_cli._subprocess_compat import windows_detach_flags, windows_hide_flags import urllib.request from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Literal, Optional, Tuple import yaml @@ -105,7 +106,7 @@ try: from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles - from pydantic import BaseModel, SecretStr + from pydantic import BaseModel, SecretStr, field_validator from starlette.concurrency import run_in_threadpool except ImportError: # First try lazy-installing the dashboard extras. Only the user actually @@ -121,7 +122,7 @@ except ImportError: from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles - from pydantic import BaseModel, SecretStr + from pydantic import BaseModel, SecretStr, field_validator from starlette.concurrency import run_in_threadpool except Exception: raise SystemExit( @@ -1363,15 +1364,34 @@ class MoaModelSlot(BaseModel): enabled: bool = True -class MoaPresetPayload(BaseModel): +class _MoaReferenceControls(BaseModel): + reference_timeout: float = 30.0 + degraded_reference_policy: Literal["loud", "silent"] = "loud" + + @field_validator("reference_timeout", mode="before") + @classmethod + def _validate_reference_timeout(cls, value: Any) -> float: + """Reject JSON booleans/non-finite values before float coercion.""" + if isinstance(value, bool): + raise ValueError("reference_timeout must be a finite positive number") + try: + timeout = float(value) + except (TypeError, ValueError) as exc: + raise ValueError( + "reference_timeout must be a finite positive number" + ) from exc + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError("reference_timeout must be a finite positive number") + return timeout + + +class MoaPresetPayload(_MoaReferenceControls): reference_models: list[MoaModelSlot] = [] aggregator: MoaModelSlot = MoaModelSlot() # None = temperature omitted from API calls (provider default), matching # single-model agent behavior. reference_temperature: Optional[float] = None aggregator_temperature: Optional[float] = None - reference_timeout: float = 30.0 - degraded_reference_policy: str = "loud" max_tokens: int = 4096 # Newer per-preset knobs (see moa_config._normalize_preset). Optional so # older clients that never send them keep working; declared so clients @@ -1381,7 +1401,7 @@ class MoaPresetPayload(BaseModel): enabled: bool = True -class MoaConfigPayload(BaseModel): +class MoaConfigPayload(_MoaReferenceControls): default_preset: str = "default" active_preset: str = "" presets: dict[str, MoaPresetPayload] = {} @@ -1391,8 +1411,6 @@ class MoaConfigPayload(BaseModel): aggregator: MoaModelSlot = MoaModelSlot() reference_temperature: Optional[float] = None aggregator_temperature: Optional[float] = None - reference_timeout: float = 30.0 - degraded_reference_policy: str = "loud" max_tokens: int = 4096 reference_max_tokens: Optional[int] = None fanout: Optional[str] = None diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index cd37e5d5449a..314040e088da 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -901,6 +901,9 @@ class TestWebServerEndpoints: assert {"provider": agg["provider"], "model": agg["model"]} == payload["aggregator"] assert cfg["moa"]["reference_timeout"] == 44.5 assert cfg["moa"]["degraded_reference_policy"] == "silent" + returned = self.client.get("/api/model/moa").json() + assert returned["reference_timeout"] == 44.5 + assert returned["degraded_reference_policy"] == "silent" def test_put_moa_models_persists_reference_failure_controls_per_preset(self): from hermes_cli.config import load_config @@ -1325,6 +1328,34 @@ class TestWebServerEndpoints: json={"values": {"userPeerAliases": "{not json"}}, ).status_code == 400 + @pytest.mark.parametrize( + "payload", + [ + {"reference_timeout": True}, + {"reference_timeout": False}, + {"reference_timeout": "nan"}, + {"reference_timeout": "inf"}, + {"presets": {"review": {"reference_timeout": True}}}, + {"presets": {"review": {"reference_timeout": "-inf"}}}, + ], + ) + def test_put_moa_models_rejects_invalid_reference_timeout(self, payload): + resp = self.client.put("/api/model/moa", json=payload) + + assert resp.status_code == 422 + + @pytest.mark.parametrize( + "payload", + [ + {"degraded_reference_policy": "verbose"}, + {"presets": {"review": {"degraded_reference_policy": "verbose"}}}, + ], + ) + def test_put_moa_models_rejects_invalid_degraded_reference_policy(self, payload): + resp = self.client.put("/api/model/moa", json=payload) + + assert resp.status_code == 422 + # ── GET /api/media (remote image display) ─────────────────────────── def test_get_media_serves_image_in_root(self):