VYPR
Medium severity5.3NVD Advisory· Published Jun 9, 2026· Updated Jun 9, 2026

CVE-2026-49955

CVE-2026-49955

Description

Hermes WebUI resource exhaustion allows unauthenticated remote attackers to degrade service availability by flooding the passkey options endpoint.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Hermes WebUI resource exhaustion allows unauthenticated remote attackers to degrade service availability by flooding the passkey options endpoint.

Vulnerability

Hermes WebUI before version 0.51.270 contains a resource exhaustion vulnerability. This vulnerability allows unauthenticated remote attackers to degrade service availability by repeatedly calling the passkey options endpoint without completing the assertion process.

Exploitation

An attacker can send unlimited POST requests to the authentication endpoint. This can be done without any authentication or user interaction. The attacker needs network access to the affected endpoint.

Impact

Successful exploitation leads to denial of service by causing unbounded growth of the challenge store file and excessive CPU and disk I/O through repeated JSON file rewrites. This degrades the overall availability of the service.

Mitigation

The vulnerability is fixed in Hermes WebUI version 0.51.270 [2, 4]. This version was released on 2026-06-05 [4]. The fix involves implementing a lock around the challenge store operations and evicting the oldest challenges when the store reaches its limit [2].

AI Insight generated on Jun 9, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

2
58528a4d88b0

Release v0.51.270 — Release IL (stage-u1 — un-hold batch: #3517 #3624 #3613) (#3674)

https://github.com/nesquena/hermes-webuinesquena-hermesJun 5, 2026via nvd-ref
15 files changed · +799 33
  • api/config.py+19 3 modified
    @@ -27,6 +27,10 @@
     
     # ── Basic layout ──────────────────────────────────────────────────────────────
     import api.paths as _paths
    +from api.plugin_providers import (
    +    effective_provider_display_name as _effective_provider_display_name,
    +    is_plugin_model_provider as _is_plugin_model_provider,
    +)
     
     HOME = _paths.HOME
     _hermes_home_has_webui_state = _paths._hermes_home_has_webui_state
    @@ -3198,6 +3202,12 @@ def invalidate_models_cache():
         # Also delete the disk cache so the next cold build starts fresh.
         # Disk delete is outside the lock — file I/O shouldn't block other readers.
         _delete_models_cache_on_disk()
    +    try:
    +        from api.plugin_providers import invalidate_plugin_model_provider_cache
    +
    +        invalidate_plugin_model_provider_cache()
    +    except Exception:
    +        pass
     
     
     def invalidate_credential_pool_cache(provider_id: str):
    @@ -3787,7 +3797,9 @@ def _build_configured_model_badges() -> dict[str, dict[str, str]]:
                     # aliases; ``isinstance(_provider_cfg, dict)`` accepts custom
                     # entries that supply their own models/api_key/base_url. (#2399)
                     _is_known_provider = (
    -                    _canonical in _PROVIDER_MODELS or _canonical in _PROVIDER_DISPLAY
    +                    _canonical in _PROVIDER_MODELS
    +                    or _canonical in _PROVIDER_DISPLAY
    +                    or _is_plugin_model_provider(_canonical)
                     )
                     _is_provider_config = isinstance(_provider_cfg, dict)
                     if not (_is_known_provider or _is_provider_config):
    @@ -4242,7 +4254,7 @@ def _read_custom_endpoint_models(
                                     group["models_endpoint_error"] = _named_custom_errors[pid]
                                 groups.append(group)
                         continue
    -                provider_name = _PROVIDER_DISPLAY.get(pid, pid.title())
    +                provider_name = _effective_provider_display_name(pid, _PROVIDER_DISPLAY)
                     if pid == "openrouter":
                         # OpenRouter has two model surfaces:
                         #   (1) curated tool-supporting catalog via hermes_cli.models.fetch_openrouter_models()
    @@ -4545,7 +4557,11 @@ def _read_custom_endpoint_models(
                                     "models": models,
                                 }
                             )
    -                elif pid in _PROVIDER_MODELS or pid in _canonical_to_raw_provider_key:
    +                elif (
    +                    pid in _PROVIDER_MODELS
    +                    or pid in _canonical_to_raw_provider_key
    +                    or _is_plugin_model_provider(pid)
    +                ):
                         # Look up provider_cfg using the original raw key from
                         # config.yaml so that mixed-case / underscore keys like
                         # ``CLIPpoxy`` or ``snake_case_provider`` still resolve
    
  • api/passkeys.py+47 7 modified
    @@ -12,6 +12,7 @@
     import os
     import secrets
     import tempfile
    +import threading
     import time
     from dataclasses import dataclass
     from pathlib import Path
    @@ -29,14 +30,21 @@
     
     _CREDENTIALS_FILE = STATE_DIR / "passkeys.json"
     _CHALLENGES_FILE = STATE_DIR / ".passkey_challenges.json"
    -_CHALLENGE_TTL = 300
    +_CHALLENGE_TTL = 90
    +_MAX_CHALLENGES = 128
    +_MAX_CHALLENGES_PER_CONTEXT = 8
    +_CHALLENGES_LOCK = threading.Lock()
     _RP_NAME = "Hermes WebUI"
     
     
     class PasskeyError(ValueError):
         """Raised for user-correctable WebAuthn failures."""
     
     
    +class PasskeyRateLimitError(PasskeyError):
    +    """Raised when too many outstanding WebAuthn challenges are pending."""
    +
    +
     def _b64u(data: bytes) -> str:
         return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
     
    @@ -104,6 +112,8 @@ def passkeys_available() -> bool:
     
     
     def _load_challenges() -> dict[str, dict[str, Any]]:
    +    # May prune and rewrite the challenge file; callers that mutate the store
    +    # must hold _CHALLENGES_LOCK across load→mutate→write.
         raw = _json_load(_CHALLENGES_FILE, {})
         if not isinstance(raw, dict):
             return {}
    @@ -117,16 +127,46 @@ def _load_challenges() -> dict[str, dict[str, Any]]:
         return clean
     
     
    +def _oldest_challenge_key(data: dict[str, dict[str, Any]], keys: list[str]) -> str | None:
    +    if not keys:
    +        return None
    +    return min(keys, key=lambda k: float(data.get(k, {}).get("ts", 0)))
    +
    +
    +def _evict_oldest_challenges(data: dict[str, dict[str, Any]], kind: str, rp_id: str, origin: str) -> None:
    +    """Keep the challenge store bounded while admitting the newest challenge."""
    +    while True:
    +        same_context = [
    +            k for k, v in data.items()
    +            if v.get("kind") == kind and v.get("rp_id") == rp_id and v.get("origin") == origin
    +        ]
    +        if len(same_context) < _MAX_CHALLENGES_PER_CONTEXT:
    +            break
    +        oldest = _oldest_challenge_key(data, same_context)
    +        if oldest is None:
    +            break
    +        data.pop(oldest, None)
    +
    +    while len(data) >= _MAX_CHALLENGES:
    +        oldest = _oldest_challenge_key(data, list(data))
    +        if oldest is None:
    +            break
    +        data.pop(oldest, None)
    +
    +
     def _store_challenge(challenge: str, kind: str, rp_id: str, origin: str) -> None:
    -    data = _load_challenges()
    -    data[challenge] = {"kind": kind, "rp_id": rp_id, "origin": origin, "ts": time.time()}
    -    _atomic_write_json(_CHALLENGES_FILE, data)
    +    with _CHALLENGES_LOCK:
    +        data = _load_challenges()
    +        _evict_oldest_challenges(data, kind, rp_id, origin)
    +        data[challenge] = {"kind": kind, "rp_id": rp_id, "origin": origin, "ts": time.time()}
    +        _atomic_write_json(_CHALLENGES_FILE, data)
     
     
     def _consume_challenge(challenge: str, kind: str) -> dict[str, Any]:
    -    data = _load_challenges()
    -    entry = data.pop(challenge, None)
    -    _atomic_write_json(_CHALLENGES_FILE, data)
    +    with _CHALLENGES_LOCK:
    +        data = _load_challenges()
    +        entry = data.pop(challenge, None)
    +        _atomic_write_json(_CHALLENGES_FILE, data)
         if not entry or entry.get("kind") != kind:
             raise PasskeyError("Passkey challenge expired. Try again.")
         return entry
    
  • api/plugin_providers.py+141 0 added
    @@ -0,0 +1,141 @@
    +"""Helpers for model-provider plugins (``plugins/model-providers/<name>/``).
    +
    +The Hermes agent discovers these via ``providers.list_providers()`` and exposes
    +them in the CLI model picker.  WebUI must mirror that registry instead of
    +relying only on the static ``_PROVIDER_DISPLAY`` / ``_PROVIDER_MODELS`` tables.
    +
    +Bundled agent profiles (gemini, nous, custom, …) also live in
    +``list_providers()``.  WebUI already handles those via static tables and
    +dedicated code paths — only *plugin-only* slugs (e.g. user-installed yandex)
    +should take the plugin discovery path.
    +"""
    +
    +from __future__ import annotations
    +
    +import logging
    +import threading
    +from typing import Any
    +
    +logger = logging.getLogger(__name__)
    +
    +_PROFILES_LOCK = threading.Lock()
    +_PROFILES_BY_NAME: dict[str, Any] | None = None
    +_WEBUI_STATIC_PROVIDER_IDS: frozenset[str] | None = None
    +
    +
    +def _webui_static_provider_ids() -> frozenset[str]:
    +    """Provider slugs already owned by WebUI static tables / special cases."""
    +    global _WEBUI_STATIC_PROVIDER_IDS
    +    if _WEBUI_STATIC_PROVIDER_IDS is not None:
    +        return _WEBUI_STATIC_PROVIDER_IDS
    +    try:
    +        from api.config import _PROVIDER_DISPLAY, _PROVIDER_MODELS
    +
    +        static = (
    +            frozenset(_PROVIDER_DISPLAY.keys())
    +            | frozenset(_PROVIDER_MODELS.keys())
    +            | frozenset({"custom"})
    +        )
    +    except Exception:
    +        static = frozenset({"custom"})
    +    _WEBUI_STATIC_PROVIDER_IDS = static
    +    return static
    +
    +
    +def _load_profiles_by_name() -> dict[str, Any]:
    +    try:
    +        from providers import list_providers
    +    except Exception:
    +        logger.debug("providers package unavailable for plugin discovery", exc_info=True)
    +        return {}
    +
    +    result: dict[str, Any] = {}
    +    try:
    +        for profile in list_providers():
    +            name = str(getattr(profile, "name", "") or "").strip().lower()
    +            if name:
    +                result[name] = profile
    +    except Exception:
    +        logger.debug("Failed to enumerate model-provider plugins", exc_info=True)
    +        return {}
    +    return result
    +
    +
    +def plugin_model_provider_profiles() -> dict[str, Any]:
    +    """Return registered model-provider profiles keyed by canonical slug."""
    +    global _PROFILES_BY_NAME
    +    cached = _PROFILES_BY_NAME
    +    if cached is not None:
    +        return cached
    +    with _PROFILES_LOCK:
    +        if _PROFILES_BY_NAME is None:
    +            _PROFILES_BY_NAME = _load_profiles_by_name()
    +        return _PROFILES_BY_NAME
    +
    +
    +def invalidate_plugin_model_provider_cache() -> None:
    +    """Clear cached plugin discovery (e.g. after config reload)."""
    +    global _PROFILES_BY_NAME
    +    with _PROFILES_LOCK:
    +        _PROFILES_BY_NAME = None
    +
    +
    +def plugin_model_provider_ids() -> frozenset[str]:
    +    """Slugs from ``list_providers()`` that are not already WebUI-static."""
    +    static = _webui_static_provider_ids()
    +    return frozenset(
    +        pid for pid in plugin_model_provider_profiles().keys() if pid not in static
    +    )
    +
    +
    +def plugin_model_provider_display_name(provider_id: str) -> str | None:
    +    profile = plugin_model_provider_profiles().get((provider_id or "").strip().lower())
    +    if profile is None:
    +        return None
    +    return str(getattr(profile, "display_name", "") or getattr(profile, "name", "") or "").strip() or None
    +
    +
    +def plugin_model_provider_api_key_env_var(provider_id: str) -> str | None:
    +    """Return the primary API-key env var for a plugin provider, if any."""
    +    profile = plugin_model_provider_profiles().get((provider_id or "").strip().lower())
    +    if profile is None:
    +        return None
    +    env_vars = getattr(profile, "env_vars", ()) or ()
    +    for var in env_vars:
    +        upper = str(var).upper()
    +        if upper.endswith("_BASE_URL") or upper.endswith("_URL"):
    +            continue
    +        if upper.endswith("_FOLDER_ID"):
    +            continue
    +        return str(var)
    +    return None
    +
    +
    +def effective_provider_env_var(provider_id: str, static_map: dict[str, str]) -> str | None:
    +    pid = (provider_id or "").strip().lower()
    +    if not pid:
    +        return None
    +    if pid in static_map:
    +        return static_map[pid]
    +    if not is_plugin_model_provider(pid):
    +        return None
    +    return plugin_model_provider_api_key_env_var(pid)
    +
    +
    +def effective_provider_display_name(provider_id: str, static_map: dict[str, str]) -> str:
    +    pid = (provider_id or "").strip().lower()
    +    if pid in static_map:
    +        return static_map[pid]
    +    if is_plugin_model_provider(pid):
    +        plugin_name = plugin_model_provider_display_name(pid)
    +        if plugin_name:
    +            return plugin_name
    +    return pid.replace("-", " ").title()
    +
    +
    +def is_plugin_model_provider(provider_id: str) -> bool:
    +    """True for plugin-only providers (not already in WebUI static tables)."""
    +    pid = (provider_id or "").strip().lower()
    +    if not pid or pid in _webui_static_provider_ids():
    +        return False
    +    return pid in plugin_model_provider_profiles()
    
  • api/providers.py+66 15 modified
    @@ -43,10 +43,21 @@
         invalidate_models_cache,
         reload_config,
     )
    +from api.plugin_providers import (
    +    effective_provider_display_name,
    +    effective_provider_env_var,
    +    is_plugin_model_provider,
    +    plugin_model_provider_ids,
    +)
     
     logger = logging.getLogger(__name__)
     
     
    +def _provider_env_var_for(provider_id: str) -> str | None:
    +    """Resolve the API-key env var for a provider (static table + plugin profiles)."""
    +    return effective_provider_env_var(provider_id, _PROVIDER_ENV_VAR)
    +
    +
     def _custom_provider_name_matches(provider_id: str, name: object) -> bool:
         """Return True when *provider_id* refers to a named custom provider."""
         pid = str(provider_id or "").strip().lower()
    @@ -782,7 +793,7 @@ def _provider_has_shadowed_codex_oauth_value(provider_id: str) -> bool:
         if (provider_id or "").strip().lower() != "openai":
             return False
         values: list[object] = []
    -    env_var = _PROVIDER_ENV_VAR.get(provider_id)
    +    env_var = _provider_env_var_for(provider_id)
         if env_var:
             env_path = _get_hermes_home() / ".env"
             env_values = _load_env_file(env_path)
    @@ -923,7 +934,7 @@ def _provider_has_key(provider_id: str) -> bool:
         4. ``config.yaml → providers.<id>.api_key``
         5. ``config.yaml → custom_providers[].api_key`` (for custom providers)
         """
    -    env_var = _PROVIDER_ENV_VAR.get(provider_id)
    +    env_var = _provider_env_var_for(provider_id)
         if env_var:
             env_path = _get_hermes_home() / ".env"
             env_values = _load_env_file(env_path)
    @@ -973,7 +984,7 @@ def _provider_has_key(provider_id: str) -> bool:
     def _get_provider_api_key(provider_id: str) -> str | None:
         """Return a configured provider API key without exposing it to callers."""
         provider_id = (provider_id or "").strip().lower()
    -    env_var = _PROVIDER_ENV_VAR.get(provider_id)
    +    env_var = _provider_env_var_for(provider_id)
         if env_var:
             env_path = _get_hermes_home() / ".env"
             env_values = _load_env_file(env_path)
    @@ -1129,7 +1140,7 @@ def _account_usage_subprocess_env(home: Path, provider: str, api_key: str | None
             if value:
                 env[key] = value
     
    -    env_var = _PROVIDER_ENV_VAR.get((provider or "").strip().lower())
    +    env_var = _provider_env_var_for((provider or "").strip().lower())
         if env_var and api_key:
             env[env_var] = api_key
     
    @@ -1752,6 +1763,7 @@ def get_providers() -> dict[str, Any]:
     
         # Collect all known provider IDs from multiple sources
         known_ids = set(_PROVIDER_DISPLAY.keys()) | set(_PROVIDER_MODELS.keys())
    +    known_ids.update(plugin_model_provider_ids())
     
         # Also detect providers from config.yaml providers section
         cfg = get_config()
    @@ -1763,9 +1775,21 @@ def get_providers() -> dict[str, Any]:
         known_ids.update(_OAUTH_PROVIDERS)
     
         for pid in sorted(known_ids):
    -        display_name = _PROVIDER_DISPLAY.get(pid, pid.replace("-", " ").title())
    +        display_name = effective_provider_display_name(pid, _PROVIDER_DISPLAY)
             is_oauth = _provider_is_oauth(pid)
             has_key = _provider_has_key(pid)
    +        plugin_auth_status: dict[str, Any] | None = None
    +        if not has_key and is_plugin_model_provider(pid):
    +            try:
    +                from hermes_cli.auth import get_auth_status as _gas_plugin
    +                _plugin_status = _gas_plugin(pid)
    +                if isinstance(_plugin_status, dict) and (
    +                    _plugin_status.get("logged_in") or _plugin_status.get("configured")
    +                ):
    +                    has_key = True
    +                    plugin_auth_status = _plugin_status
    +            except Exception:
    +                logger.debug("Plugin provider auth check failed for %s", pid, exc_info=True)
     
             # Determine key source
             key_source = "none"
    @@ -1797,7 +1821,7 @@ def get_providers() -> dict[str, Any]:
                     logger.debug("hermes_cli auth check failed for %s", pid, exc_info=True)
                     # keep has_key from _provider_has_key()
             elif has_key:
    -            env_var = _PROVIDER_ENV_VAR.get(pid)
    +            env_var = _provider_env_var_for(pid)
                 if env_var:
                     env_path = _get_hermes_home() / ".env"
                     env_values = _load_env_file(env_path)
    @@ -1821,19 +1845,29 @@ def get_providers() -> dict[str, Any]:
                                 aliased = True
                                 break
                         if not aliased:
    -                        key_source = "config_yaml"
    +                        _plugin_ks = (
    +                            str(plugin_auth_status.get("key_source") or "").strip()
    +                            if isinstance(plugin_auth_status, dict)
    +                            else ""
    +                        )
    +                        key_source = _plugin_ks or "config_yaml"
                 else:
    -                key_source = "config_yaml"
    -        elif pid not in _PROVIDER_ENV_VAR:
    +                _plugin_ks = (
    +                    str(plugin_auth_status.get("key_source") or "").strip()
    +                    if isinstance(plugin_auth_status, dict)
    +                    else ""
    +                )
    +                key_source = _plugin_ks or "config_yaml"
    +        elif not _provider_env_var_for(pid):
                 # Fallback: provider is not a known API-key provider and not in
                 # the hardcoded _OAUTH_PROVIDERS set.  It may be a custom or
                 # newly-added OAuth provider (e.g. Anthropic connected via OAuth).
                 # Check live auth status so the Providers tab agrees with the
                 # model picker (#1212).
                 #
    -            # IMPORTANT: we skip providers in _PROVIDER_ENV_VAR because they
    -            # are pure API-key providers — calling get_auth_status() for every
    -            # unconfigured API-key provider would add unnecessary latency
    +            # IMPORTANT: we skip providers with a known API-key env var because
    +            # they are pure API-key providers — calling get_auth_status() for
    +            # every unconfigured API-key provider would add unnecessary latency
                 # (network round-trip per provider) on the Settings page.
                 # Validate pid looks like a real provider before probing
                 import re as _re
    @@ -1924,6 +1958,21 @@ def get_providers() -> dict[str, Any]:
                         models_total = len(models)
                 except Exception:
                     logger.debug("Failed to load LM Studio models from hermes_cli")
    +        if is_plugin_model_provider(pid):
    +            try:
    +                live_models = _models_from_live_provider_ids(
    +                    pid,
    +                    _read_live_provider_model_ids(pid),
    +                )
    +                if live_models:
    +                    models = live_models
    +                    models_total = len(models)
    +            except Exception:
    +                logger.debug(
    +                    "Failed to load plugin model-provider catalog for %s",
    +                    pid,
    +                    exc_info=True,
    +                )
             # Also include models from config.yaml providers section
             if isinstance(providers_cfg, dict):
                 provider_cfg = providers_cfg.get(pid, {})
    @@ -1941,11 +1990,13 @@ def get_providers() -> dict[str, Any]:
                     if pid != "nous":
                         models_total = len(models)
     
    +        _is_plugin = is_plugin_model_provider(pid)
             providers.append({
                 "id": pid,
                 "display_name": display_name,
                 "has_key": has_key,
    -            "configurable": not is_oauth and pid in _PROVIDER_ENV_VAR,
    +            "configurable": not is_oauth and bool(_provider_env_var_for(pid)),
    +            "is_plugin_provider": _is_plugin,
                 "is_oauth": is_oauth,
                 "key_source": key_source,
                 "auth_error": auth_error,
    @@ -2042,11 +2093,11 @@ def set_provider_key(provider_id: str, api_key: str | None) -> dict[str, Any]:
                          f"Use `hermes model` in the terminal to configure it.",
             }
     
    -    env_var = _PROVIDER_ENV_VAR.get(provider_id)
    +    env_var = _provider_env_var_for(provider_id)
         if not env_var:
             return {
                 "ok": False,
    -            "error": f"Cannot configure API key for '{_PROVIDER_DISPLAY.get(provider_id, provider_id)}'. "
    +            "error": f"Cannot configure API key for '{effective_provider_display_name(provider_id, _PROVIDER_DISPLAY)}'. "
                          f"This provider does not have a known env var mapping.",
             }
     
    
  • api/routes.py+10 3 modified
    @@ -8024,14 +8024,16 @@ def _llm_update_summary(system_prompt: str, user_prompt: str) -> str:
     
         if parsed.path == "/api/auth/passkey/options":
             from api.auth import _passkey_feature_flag_enabled, is_auth_enabled
    -        from api.passkeys import PasskeyError, authentication_options
    +        from api.passkeys import PasskeyError, PasskeyRateLimitError, authentication_options
     
             if not _passkey_feature_flag_enabled():
                 return j(handler, {"error": "Passkey support is disabled. Set HERMES_WEBUI_PASSKEY=1 or webui_passkey_enabled: true to enable."}, status=404)
             if not is_auth_enabled():
                 return j(handler, {"error": "Auth not enabled"}, status=400)
             try:
                 return j(handler, {"ok": True, "publicKey": authentication_options(handler)})
    +        except PasskeyRateLimitError as e:
    +            return bad(handler, str(e), status=429)
             except PasskeyError as e:
                 return bad(handler, str(e), status=400)
     
    @@ -8064,11 +8066,16 @@ def _llm_update_summary(system_prompt: str, user_prompt: str) -> str:
     
         if parsed.path == "/api/auth/passkey/register/options":
             from api.auth import _passkey_feature_flag_enabled
    -        from api.passkeys import registration_options
    +        from api.passkeys import PasskeyError, PasskeyRateLimitError, registration_options
     
             if not _passkey_feature_flag_enabled():
                 return j(handler, {"error": "Passkey support is disabled."}, status=404)
    -        return j(handler, {"ok": True, "publicKey": registration_options(handler)})
    +        try:
    +            return j(handler, {"ok": True, "publicKey": registration_options(handler)})
    +        except PasskeyRateLimitError as e:
    +            return bad(handler, str(e), status=429)
    +        except PasskeyError as e:
    +            return bad(handler, str(e), status=400)
     
         if parsed.path == "/api/auth/passkey/register":
             from api.auth import _passkey_feature_flag_enabled
    
  • CHANGELOG.md+9 0 modified
    @@ -3,6 +3,15 @@
     
     ## [Unreleased]
     
    +## [v0.51.270] — 2026-06-05 — Release IL (stage-u1 — un-hold batch: author-fixed PRs re-gated)
    +
    +### Fixed
    +- **The pending passkey-challenge cap now evicts the oldest challenges instead of rejecting new ones**, so the anti-unbounded-growth bound can no longer be turned into a lockout DoS (filling the global or per-context cap previously blocked all legitimate registration/login until TTL expiry). (#3624, @Hinotoi-agent)
    +- **Model-provider plugins (e.g. Yandex) are now surfaced in the WebUI providers panel** — env-var name and configured status only, never the value — without regressing existing custom-provider handling. (#3613, @pamnard)
    +
    +### Added
    +- **`/use <skill>` slash command** forces a specific skill for the next turn. The directive resolves as part of the next real send (awaiting the in-flight `/api/skills` lookup) and clears only once consumed, so a fast follow-up send can't apply a stale directive to the wrong message. (#3517, @rodboev; implements #2977)
    +
     ## [v0.51.269] — 2026-06-05 — Release IK (stage-b2 — sidebar perf + search scope + Windows ctl)
     
     ### Fixed
    
  • static/commands.js+40 0 modified
    @@ -17,6 +17,7 @@ const COMMANDS=[
       {name:'theme',     desc:t('cmd_theme'), fn:cmdTheme, arg:'name',  noEcho:true},
       {name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name', subArgs:'personalities'},
       {name:'skills',    desc:t('cmd_skills'),   fn:cmdSkills,   arg:'query'},
    +  {name:'use',       desc:t('cmd_use'),      fn:cmdUse,      arg:'skill-name', subArgs:'skills', noEcho:true},
       {name:'stop',      desc:t('cmd_stop'),     fn:cmdStop,      noEcho:true},
       {name:'goal',      desc:t('cmd_goal'),     fn:cmdGoal,      arg:'[status|pause|resume|clear|text]', subArgs:['status','pause','resume','clear']},
       {name:'queue',     desc:t('cmd_queue'),    fn:cmdQueue,     arg:'message', noEcho:true},
    @@ -94,6 +95,7 @@ function getMatchingCommands(prefix){
       return matches;
     }
     
    +let _forcedSkillDirectivePending=null;
     let _slashModelCache=null;
     let _slashModelCachePromise=null;
     let _slashPersonalityCache=null;
    @@ -912,6 +914,44 @@ async function cmdSkills(args){
       }
     }
     
    +async function cmdUse(args){
    +  if(!args){
    +    S.messages.push({role:'assistant',content:'Usage: `/use <skill-name>` — forces the agent to consult that skill before its next response.'});
    +    renderMessages();
    +    return;
    +  }
    +  let resolve;
    +  const pending = {sessionId:S.session&&S.session.session_id||null,promise:null};
    +  pending.promise = new Promise(r => { resolve = r; });
    +  _forcedSkillDirectivePending = pending;
    +  const isCurrentSession = () => !pending.sessionId || (S.session&&S.session.session_id)===pending.sessionId;
    +  try{
    +    const data = await api('/api/skills');
    +    const skills = data.skills || [];
    +    const match = skills.find(s => (s.name||'').toLowerCase() === args.toLowerCase());
    +    if(!match){
    +      resolve(null);
    +      if(_forcedSkillDirectivePending===pending)_forcedSkillDirectivePending = null;
    +      if(isCurrentSession()){
    +        const msg = {role:'assistant', content:`No skill named \`${args}\`. Use \`/skills\` to see available skills.`};
    +        S.messages.push(msg); renderMessages();
    +      }
    +      return;
    +    }
    +    const directive = `[USER OVERRIDE] You MUST consult skill '${match.name}' via skill_view before responding to the next message.`;
    +    resolve(directive);
    +    if(isCurrentSession()){
    +      S.messages.push({role:'assistant', content:`Next turn: skill \`${match.name}\` will be forced.`});
    +      renderMessages();
    +    }
    +    showToast(`Skill \`${match.name}\` will be used for next turn.`);
    +  }catch(e){
    +    resolve(null);
    +    if(_forcedSkillDirectivePending===pending)_forcedSkillDirectivePending = null;
    +    showToast('Failed to load skills: '+e.message);
    +  }
    +}
    +
     async function cmdPersonality(args){
       if(!S.session){showToast(t('no_active_session'));return;}
       if(!args){
    
  • static/i18n.js+12 0 modified
    @@ -205,6 +205,7 @@ const LOCALES = {
         cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
         cmd_personality: 'Switch agent personality',
         cmd_skills: 'List available Hermes skills',
    +    cmd_use: 'Force a skill for the next message',
         available_commands: 'Available commands:',
         type_slash: 'Type / to see commands',
         conversation_cleared: 'Conversation cleared',
    @@ -1541,6 +1542,7 @@ const LOCALES = {
         cmd_theme: 'Cambia aspetto (tema: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
         cmd_personality: "Cambia personalità dell'agente",
         cmd_skills: 'Elenca le skill Hermes disponibili',
    +    cmd_use: 'Forza una skill per il prossimo messaggio',
         available_commands: 'Comandi disponibili:',
         type_slash: 'Digita / per vedere i comandi',
         conversation_cleared: 'Conversazione cancellata',
    @@ -2869,6 +2871,7 @@ const LOCALES = {
         cmd_theme: '外観を切り替え (theme: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
         cmd_personality: 'エージェントのパーソナリティを切り替え',
         cmd_skills: '利用可能な Hermes スキルを一覧表示',
    +    cmd_use: '次のメッセージにスキルを強制適用',
         available_commands: '利用可能なコマンド:',
         type_slash: '/ を入力するとコマンド一覧',
         conversation_cleared: '会話をクリアしました',
    @@ -4159,6 +4162,7 @@ const LOCALES = {
         cmd_theme: 'Переключить внешний вид (тема: system/dark/light, скин: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
         cmd_personality: 'Переключить личность агента',
         cmd_skills: 'Показать доступные навыки Hermes',
    +    cmd_use: 'Принудительно применить навык к следующему сообщению',
         available_commands: 'Доступные команды:',
         type_slash: 'Введите /, чтобы увидеть команды',
         conversation_cleared: 'Беседа очищена',
    @@ -5458,6 +5462,7 @@ const LOCALES = {
         cmd_theme: 'Cambiar apariencia (tema: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
         cmd_personality: 'Cambiar la personalidad del agente',
         cmd_skills: 'Listar las skills de Hermes disponibles',
    +    cmd_use: 'Forzar una skill para el próximo mensaje',
         available_commands: 'Comandos disponibles:',
         type_slash: 'Escribe / para ver los comandos',
         conversation_cleared: 'Conversación borrada',
    @@ -6698,6 +6703,7 @@ const LOCALES = {
         cmd_theme: 'Darstellung wechseln (Theme: system/dark/light, Skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
         cmd_personality: 'Agenten-Persönlichkeit wechseln',
         cmd_skills: 'Verfügbare Hermes-Skills auflisten',
    +    cmd_use: 'Einen Skill für die nächste Nachricht erzwingen',
         available_commands: 'Verfügbare Befehle:',
         type_slash: 'Tippe / für Befehle',
         conversation_cleared: 'Konversation gelöscht',
    @@ -7989,6 +7995,7 @@ const LOCALES = {
         cmd_theme: '切换外观(主题:system/dark/light,皮肤:default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
         cmd_personality: '切换 Agent 人设',
         cmd_skills: '列出可用的 Hermes 技能',
    +    cmd_use: '为下一条消息强制使用技能',
         available_commands: '可用命令:',
         type_slash: '输入 / 可查看命令',
         conversation_cleared: '对话已清空',
    @@ -9266,6 +9273,7 @@ const LOCALES = {
         cmd_theme: '切換外觀(主題:system/dark/light,skin:default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
         cmd_personality: '切換 Agent 角色',
         cmd_skills: '列出可用的 Hermes 技能',
    +    cmd_use: '為下一則訊息強制使用技能',
         available_commands: '可用命令:',
         type_slash: '輸入 / 檢視命令',
         conversation_cleared: '已清除對話',
    @@ -10513,6 +10521,7 @@ const LOCALES = {
         cmd_theme: 'Trocar aparência (tema: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
         cmd_personality: 'Trocar personalidade do agente',
         cmd_skills: 'Listar skills disponíveis do Hermes',
    +    cmd_use: 'Forçar uma skill para a próxima mensagem',
         available_commands: 'Comandos disponíveis:',
         type_slash: 'Digite / para ver comandos',
         conversation_cleared: 'Conversa limpa',
    @@ -11726,6 +11735,7 @@ const LOCALES = {
         cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
         cmd_personality: 'Switch agent personality',
         cmd_skills: 'List available Hermes skills',
    +    cmd_use: 'Force a skill for the next message',
         available_commands: '사용 가능한 명령:',
         type_slash: '/ 를 입력해 명령 보기',
         conversation_cleared: '대화를 지웠습니다',
    @@ -13047,6 +13057,7 @@ const LOCALES = {
         cmd_theme: 'Changer d\'apparence (thème : system/dark/light, skin : default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
         cmd_personality: 'Personnalité de l\'agent de commutation',
         cmd_skills: 'Lister les compétences Hermès disponibles',
    +    cmd_use: 'Forcer une compétence pour le prochain message',
         available_commands: 'Commandes disponibles :',
         type_slash: 'Tapez / pour voir les commandes',
         conversation_cleared: 'Conversation effacée',
    @@ -14321,6 +14332,7 @@ const LOCALES = {
         cmd_theme: 'Görünümü değiştir (tema: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
         cmd_personality: 'Temsilci kişiliğini değiştir',
         cmd_skills: 'Mevcut Hermes becerilerini listele',
    +    cmd_use: 'Sonraki mesaj için bir beceriyi zorla',
         available_commands: 'Mevcut komutlar:',
         type_slash: 'Komutları görmek için / yazın',
         conversation_cleared: 'Görüşme temizlendi',
    
  • static/messages.js+10 0 modified
    @@ -568,6 +568,16 @@ async function send(){
       let msgText=text;
       if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploadedPaths.join(', ')}`;
       else if(uploaded.length)msgText=`${text}\n\n[Attached files: ${uploadedPaths.join(', ')}]`;
    +  if(_forcedSkillDirectivePending){
    +    const _pending=_forcedSkillDirectivePending;
    +    if(!_pending.sessionId||_pending.sessionId===activeSid){
    +      const _directive = await _pending.promise;
    +      if(_forcedSkillDirectivePending===_pending)_forcedSkillDirectivePending = null;
    +      if(typeof _directive==='string'&&_directive){
    +        msgText=`${_directive}\n\n${msgText||''}`.trim();
    +      }
    +    }
    +  }
       if(!msgText){setComposerStatus('Nothing to send');return;}
     
       $('msg').value='';autoResize();
    
  • static/panels.js+1 1 modified
    @@ -6716,7 +6716,7 @@ async function loadProvidersPanel(){
       try{
         const data=await api('/api/providers');
         const quota=await _fetchProviderQuotaStatus(false).catch(e=>({ok:false,status:'unavailable',quota:null,message:e.message||t('provider_quota_unavailable'),client_fetched_at:new Date().toISOString()}));
    -    const providers=(data.providers||[]).filter(p=>p.configurable||p.is_oauth||p.is_custom);
    +    const providers=(data.providers||[]).filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider);
         list.innerHTML='';
         _providerCardEls.clear();
         const quotaCard=_buildProviderQuotaCard(quota);
    
  • tests/test_custom_providers_in_panel.py+1 1 modified
    @@ -107,7 +107,7 @@ def test_custom_provider_with_models(self, monkeypatch, tmp_path):
         def test_providers_panel_renders_config_yaml_custom_providers(self):
             """Settings → Providers must not filter out read-only custom providers."""
             src = open("static/panels.js", encoding="utf-8").read()
    -        assert "filter(p=>p.configurable||p.is_oauth||p.is_custom)" in src
    +        assert "filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider)" in src
             assert "Custom provider loaded from config.yaml / hermes model" in src
             assert "if(p.configurable){" in src
     
    
  • tests/test_issue1202_oauth_provider_status.py+10 3 modified
    @@ -290,9 +290,16 @@ def test_providers_filter_includes_is_oauth(self):
                 "This excludes ALL OAuth providers (openai-codex, nous, copilot) from the "
                 "Settings → Providers panel. The filter must be 'filter(p=>p.configurable||p.is_oauth)'."
             )
    -        fixed_filter_idx = self.JS.find("filter(p=>p.configurable||p.is_oauth)")
    -        extended_filter_idx = self.JS.find("filter(p=>p.configurable||p.is_oauth||p.is_custom)")
    -        assert fixed_filter_idx != -1 or extended_filter_idx != -1, (
    +        assert "p.is_oauth" in self.JS, (
                 "The provider filter must include p.is_oauth in panels.js. "
                 "OAuth providers will not appear in Settings → Providers."
             )
    +        # Current filter also includes is_custom and is_plugin_provider; require
    +        # the full expression so partial regressions are caught.
    +        assert (
    +            "filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider)"
    +            in self.JS
    +        ), (
    +            "Settings → Providers filter must keep OAuth, custom, and plugin "
    +            "model-provider entries visible."
    +        )
    
  • tests/test_issue2977_use_command.py+116 0 added
    @@ -0,0 +1,116 @@
    +from pathlib import Path
    +
    +
    +ROOT = Path(__file__).resolve().parents[1]
    +
    +
    +def read(path):
    +    return (ROOT / path).read_text(encoding="utf-8")
    +
    +
    +def test_use_entry_in_commands_array():
    +    src = read("static/commands.js")
    +    assert "{name:'use'," in src, "COMMANDS must contain a {name:'use', ...} entry"
    +
    +
    +def test_use_entry_precedes_stop_entry():
    +    src = read("static/commands.js")
    +    use_pos = src.index("{name:'use',")
    +    stop_pos = src.index("{name:'stop',")
    +    assert use_pos < stop_pos, "/use must be registered before /stop in COMMANDS"
    +
    +
    +def test_cmdUse_function_defined():
    +    src = read("static/commands.js")
    +    assert "async function cmdUse(args)" in src, "cmdUse function must be defined"
    +
    +
    +def test_forced_skill_directive_declared():
    +    src = read("static/commands.js")
    +    assert "let _forcedSkillDirectivePending=null;" in src, "_forcedSkillDirectivePending must be declared at module scope"
    +
    +
    +def test_forced_skill_directive_set_in_cmdUse():
    +    src = read("static/commands.js")
    +    assert "pending.promise = new Promise" in src, "cmdUse must create a pending Promise"
    +    assert "_forcedSkillDirectivePending = pending;" in src, "cmdUse must publish the pending directive before awaiting"
    +
    +
    +def test_use_entry_has_noEcho():
    +    src = read("static/commands.js")
    +    # Extract the /use entry line and check noEcho:true is present
    +    idx = src.index("{name:'use',")
    +    line_end = src.index("}", idx)
    +    entry = src[idx:line_end + 1]
    +    assert "noEcho:true" in entry, "/use entry must have noEcho:true"
    +
    +
    +def test_use_entry_has_subArgs_skills():
    +    src = read("static/commands.js")
    +    idx = src.index("{name:'use',")
    +    line_end = src.index("}", idx)
    +    entry = src[idx:line_end + 1]
    +    assert "subArgs:'skills'" in entry, "/use entry must have subArgs:'skills' for autocomplete"
    +
    +
    +def test_directive_consumed_at_injection_site():
    +    """_forcedSkillDirectivePending is cleared at the consume site, not in finally."""
    +    src = read("static/messages.js")
    +    finally_part = src.split("finally")[1] if "finally" in src else ""
    +    assert "_forcedSkillDirectivePending = null;" not in finally_part, \
    +        "_forcedSkillDirectivePending must NOT be cleared in the finally block"
    +    assert "const _directive = await _pending.promise;" in src, \
    +        "consume site must await the pending promise"
    +    assert "_forcedSkillDirectivePending = null;" in src, \
    +        "_forcedSkillDirectivePending must be cleared somewhere in messages.js"
    +
    +
    +def test_directive_injection_before_empty_guard():
    +    src = read("static/messages.js")
    +    inject_pos = src.index("_forcedSkillDirectivePending")
    +    guard_pos = src.index("if(!msgText){setComposerStatus('Nothing to send');return;}")
    +    assert inject_pos < guard_pos, "directive injection must appear before the if(!msgText) guard"
    +
    +
    +def test_directive_text_uses_match_name():
    +    src = read("static/commands.js")
    +    assert "match.name" in src, "directive must use match.name (canonical casing), not raw user input"
    +    assert "[USER OVERRIDE] You MUST consult skill '" in src, "directive text must match the specified format"
    +
    +
    +def test_pending_promise_set_synchronously():
    +    """_forcedSkillDirectivePending must be set before the first await in cmdUse."""
    +    src = read("static/commands.js")
    +    fn_start = src.index("async function cmdUse(args)")
    +    fn_body = src[fn_start:]
    +    pending_pos = fn_body.index("_forcedSkillDirectivePending = pending;")
    +    first_await = fn_body.index("await ")
    +    assert pending_pos < first_await, \
    +        "_forcedSkillDirectivePending must be set before the first await to close the race window"
    +
    +
    +def test_directive_survives_local_slash_commands():
    +    """The consume block must appear after the slash-command early-return, not before."""
    +    src = read("static/messages.js")
    +    early_return = src.index("autoResize();hideCmdDropdown();return;")
    +    consume = src.index("_forcedSkillDirectivePending")
    +    assert early_return < consume, \
    +        "slash-command early-return must precede the directive consume block"
    +
    +
    +def test_directive_pending_captures_session_id():
    +    src = read("static/commands.js")
    +    assert "const pending = {sessionId:S.session&&S.session.session_id||null,promise:null};" in src, \
    +        "cmdUse must capture the session where /use was issued"
    +    assert "const isCurrentSession = () => !pending.sessionId || (S.session&&S.session.session_id)===pending.sessionId;" in src, \
    +        "async /use completion must avoid writing status messages into a different session"
    +
    +
    +def test_directive_only_consumed_by_matching_session():
    +    src = read("static/messages.js")
    +    assert "const _pending=_forcedSkillDirectivePending;" in src, \
    +        "send() must snapshot the pending directive before awaiting it"
    +    assert "if(!_pending.sessionId||_pending.sessionId===activeSid){" in src, \
    +        "send() must only consume /use directives issued for the active session"
    +    assert "if(_forcedSkillDirectivePending===_pending)_forcedSkillDirectivePending = null;" in src, \
    +        "send() must not clear a newer pending directive created while awaiting"
    
  • tests/test_passkey_auth.py+92 0 modified
    @@ -1,8 +1,11 @@
     import base64
    +import io
     import json
     import hashlib
    +from types import SimpleNamespace
     
     from cryptography.hazmat.primitives import hashes
    +
     from cryptography.hazmat.primitives.asymmetric import ec
     
     
    @@ -133,6 +136,95 @@ def test_passkey_login_verifies_signature_and_updates_usage(monkeypatch, tmp_pat
         assert cred["last_used_at"] is not None
     
     
    +def test_passkey_options_evict_oldest_challenges_per_context(monkeypatch, tmp_path):
    +    passkeys = _set_paths(monkeypatch, tmp_path)
    +    passkeys._save_credentials([{"id": b64u(b"credential-3"), "label": "This device"}])
    +    monkeypatch.setattr(passkeys, "_MAX_CHALLENGES_PER_CONTEXT", 2)
    +
    +    first = passkeys.authentication_options(FakeHandler())
    +    second = passkeys.authentication_options(FakeHandler())
    +    third = passkeys.authentication_options(FakeHandler())
    +
    +    pending = json.loads((tmp_path / ".passkey_challenges.json").read_text(encoding="utf-8"))
    +    assert set(pending) == {second["challenge"], third["challenge"]}
    +    assert first["challenge"] not in pending
    +
    +
    +def test_passkey_options_evict_oldest_challenges_globally(monkeypatch, tmp_path):
    +    passkeys = _set_paths(monkeypatch, tmp_path)
    +    passkeys._save_credentials([{"id": b64u(b"credential-4"), "label": "This device"}])
    +    monkeypatch.setattr(passkeys, "_MAX_CHALLENGES", 2)
    +    monkeypatch.setattr(passkeys, "_MAX_CHALLENGES_PER_CONTEXT", 10)
    +
    +    first = passkeys.authentication_options(FakeHandler())
    +    second = passkeys.authentication_options(FakeHandler())
    +    third = passkeys.authentication_options(FakeHandler())
    +
    +    pending = json.loads((tmp_path / ".passkey_challenges.json").read_text(encoding="utf-8"))
    +    assert set(pending) == {second["challenge"], third["challenge"]}
    +    assert first["challenge"] not in pending
    +
    +
    +class RouteFakeHandler:
    +    def __init__(self):
    +        self.headers = FakeHeaders({"Host": "localhost:8787", "Content-Length": "0"})
    +        self.rfile = io.BytesIO(b"")
    +        self.wfile = io.BytesIO()
    +        self.status = None
    +        self.sent_headers = []
    +        self.client_address = ("127.0.0.1", 12345)
    +
    +    def send_response(self, status):
    +        self.status = status
    +
    +    def send_header(self, key, value):
    +        self.sent_headers.append((key, value))
    +
    +    def end_headers(self):
    +        pass
    +
    +
    +def test_passkey_options_rate_limit_errors_return_429(monkeypatch):
    +    import api.auth as auth
    +    import api.passkeys as passkeys
    +    import api.routes as routes
    +
    +    monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
    +    monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: True)
    +    monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
    +
    +    def raise_rate_limit(_handler):
    +        raise passkeys.PasskeyRateLimitError("too many")
    +
    +    monkeypatch.setattr(passkeys, "authentication_options", raise_rate_limit)
    +    handler = RouteFakeHandler()
    +
    +    routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/options"))
    +
    +    assert handler.status == 429
    +    assert json.loads(handler.wfile.getvalue())["error"] == "too many"
    +
    +
    +def test_passkey_register_options_handles_base_passkey_errors(monkeypatch):
    +    import api.auth as auth
    +    import api.passkeys as passkeys
    +    import api.routes as routes
    +
    +    monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
    +    monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: True)
    +
    +    def raise_passkey_error(_handler):
    +        raise passkeys.PasskeyError("plain passkey error")
    +
    +    monkeypatch.setattr(passkeys, "registration_options", raise_passkey_error)
    +    handler = RouteFakeHandler()
    +
    +    routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/register/options"))
    +
    +    assert handler.status == 400
    +    assert json.loads(handler.wfile.getvalue())["error"] == "plain passkey error"
    +
    +
     def test_auth_status_reports_passkey_availability_source_contract():
         src = open("api/routes.py", encoding="utf-8").read()
         assert '"passkeys_enabled"' in src
    
  • tests/test_plugin_model_providers.py+225 0 added
    @@ -0,0 +1,225 @@
    +"""Regression tests for model-provider plugin discovery in WebUI.
    +
    +Plugin profiles under ``plugins/model-providers/<name>/`` are auto-registered
    +in the Hermes agent CLI.  WebUI must expose them in Settings → Providers and
    +the model picker without hardcoding each slug.
    +"""
    +
    +from __future__ import annotations
    +
    +import sys
    +import types
    +from types import SimpleNamespace
    +
    +import api.config as config
    +import api.profiles as profiles
    +from api.plugin_providers import invalidate_plugin_model_provider_cache
    +
    +
    +def _install_fake_yandex_plugin(monkeypatch):
    +    profile = SimpleNamespace(
    +        name="yandex",
    +        display_name="Yandex AI Studio",
    +        env_vars=("YANDEX_API_KEY", "YANDEX_FOLDER_ID"),
    +        auth_type="api_key",
    +        aliases=("yandex-ai-studio",),
    +    )
    +
    +    def _fake_list_providers():
    +        return [profile]
    +
    +    fake_providers = types.ModuleType("providers")
    +    fake_providers.list_providers = _fake_list_providers
    +    monkeypatch.setitem(sys.modules, "providers", fake_providers)
    +    invalidate_plugin_model_provider_cache()
    +
    +
    +def _install_fake_hermes_cli(monkeypatch, *, authenticated: bool = True, model_ids: list[str] | None = None):
    +    fake_pkg = types.ModuleType("hermes_cli")
    +    fake_pkg.__path__ = []
    +
    +    fake_models = types.ModuleType("hermes_cli.models")
    +    fake_models.list_available_providers = lambda: [
    +        {
    +            "id": "yandex",
    +            "label": "Yandex AI Studio",
    +            "aliases": [],
    +            "authenticated": authenticated,
    +        }
    +    ]
    +    fake_models.provider_model_ids = lambda pid: list(model_ids or []) if pid == "yandex" else []
    +
    +    fake_auth = types.ModuleType("hermes_cli.auth")
    +    fake_auth.get_auth_status = lambda pid: (
    +        {
    +            "logged_in": True,
    +            "configured": True,
    +            "key_source": "YANDEX_API_KEY",
    +        }
    +        if pid == "yandex"
    +        else {}
    +    )
    +
    +    monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
    +    monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
    +    monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
    +
    +
    +class TestPluginModelProvidersSettings:
    +    def test_get_providers_includes_plugin_model_provider(self, monkeypatch, tmp_path):
    +        _install_fake_yandex_plugin(monkeypatch)
    +        _install_fake_hermes_cli(monkeypatch, model_ids=["deepseek-v4-flash/latest"])
    +        monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
    +
    +        env_path = tmp_path / ".env"
    +        env_path.write_text("YANDEX_API_KEY=test-yandex-key-12345\n", encoding="utf-8")
    +
    +        old_cfg = dict(config.cfg)
    +        old_mtime = config._cfg_mtime
    +        config.cfg.clear()
    +        config.cfg["model"] = {"provider": "gemini"}
    +        try:
    +            config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
    +        except Exception:
    +            config._cfg_mtime = 0.0
    +
    +        from api.providers import get_providers
    +
    +        try:
    +            result = get_providers()
    +            yandex = next((p for p in result["providers"] if p["id"] == "yandex"), None)
    +            assert yandex is not None, "plugin model-provider must appear in Settings → Providers"
    +            assert yandex["display_name"] == "Yandex AI Studio"
    +            assert yandex["has_key"] is True
    +            assert yandex["configurable"] is True
    +            assert yandex.get("is_plugin_provider") is True
    +            assert yandex["models_total"] >= 1
    +        finally:
    +            config.cfg.clear()
    +            config.cfg.update(old_cfg)
    +            config._cfg_mtime = old_mtime
    +            config.invalidate_models_cache()
    +
    +    def test_get_providers_plugin_key_source_from_auth_store(self, monkeypatch, tmp_path):
    +        """Credential-pool auth must not be misreported as config_yaml."""
    +        _install_fake_yandex_plugin(monkeypatch)
    +
    +        fake_pkg = types.ModuleType("hermes_cli")
    +        fake_pkg.__path__ = []
    +        fake_models = types.ModuleType("hermes_cli.models")
    +        fake_models.list_available_providers = lambda: []
    +        fake_models.provider_model_ids = lambda pid: []
    +        fake_auth = types.ModuleType("hermes_cli.auth")
    +        fake_auth.get_auth_status = lambda pid: (
    +            {
    +                "logged_in": True,
    +                "configured": True,
    +                "key_source": "credential_pool:yandex",
    +            }
    +            if pid == "yandex"
    +            else {}
    +        )
    +        monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
    +        monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
    +        monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
    +        monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
    +
    +        old_cfg = dict(config.cfg)
    +        old_mtime = config._cfg_mtime
    +        config.cfg.clear()
    +        config.cfg["model"] = {}
    +        try:
    +            config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
    +        except Exception:
    +            config._cfg_mtime = 0.0
    +
    +        from api.providers import get_providers
    +
    +        try:
    +            result = get_providers()
    +            yandex = next((p for p in result["providers"] if p["id"] == "yandex"), None)
    +            assert yandex is not None
    +            assert yandex["has_key"] is True
    +            assert yandex["key_source"] == "credential_pool:yandex"
    +            assert yandex["key_source"] != "config_yaml"
    +        finally:
    +            config.cfg.clear()
    +            config.cfg.update(old_cfg)
    +            config._cfg_mtime = old_mtime
    +            config.invalidate_models_cache()
    +
    +    def test_set_provider_key_accepts_plugin_env_var(self, monkeypatch, tmp_path):
    +        _install_fake_yandex_plugin(monkeypatch)
    +        monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
    +
    +        from api.providers import set_provider_key
    +
    +        result = set_provider_key("yandex", "test-yandex-key-abcdef")
    +        assert result["ok"] is True
    +        env_text = (tmp_path / ".env").read_text(encoding="utf-8")
    +        assert "YANDEX_API_KEY=test-yandex-key-abcdef" in env_text
    +
    +
    +class TestPluginOnlyExcludesStaticProviders:
    +    def test_bundled_agent_profiles_are_not_plugin_only(self, monkeypatch):
    +        """Agent bundled profiles must not hijack WebUI static/custom paths."""
    +        _install_fake_yandex_plugin(monkeypatch)
    +        from api.plugin_providers import (
    +            effective_provider_display_name,
    +            is_plugin_model_provider,
    +            plugin_model_provider_ids,
    +        )
    +        from api.config import _PROVIDER_DISPLAY
    +
    +        assert is_plugin_model_provider("yandex") is True
    +        assert "yandex" in plugin_model_provider_ids()
    +        for static_pid in ("custom", "gemini", "nous", "anthropic"):
    +            assert is_plugin_model_provider(static_pid) is False, static_pid
    +            assert static_pid not in plugin_model_provider_ids()
    +        assert effective_provider_display_name("custom", _PROVIDER_DISPLAY) == "Custom"
    +        assert effective_provider_display_name("gemini", _PROVIDER_DISPLAY) == "Gemini"
    +
    +
    +class TestPluginModelProvidersPanelFilter:
    +    def test_providers_panel_includes_plugin_model_providers(self):
    +        src = open("static/panels.js", encoding="utf-8").read()
    +        assert "p.is_plugin_provider" in src
    +        assert "filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider)" in src
    +
    +
    +class TestPluginModelProvidersPicker:
    +    def test_model_picker_includes_authenticated_plugin_provider(self, monkeypatch, tmp_path):
    +        _install_fake_yandex_plugin(monkeypatch)
    +        _install_fake_hermes_cli(
    +            monkeypatch,
    +            authenticated=True,
    +            model_ids=["gpt://folder/deepseek-v4-flash/latest"],
    +        )
    +        monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +
    +        old_cfg = dict(config.cfg)
    +        old_mtime = config._cfg_mtime
    +        config.cfg.clear()
    +        config.cfg["model"] = {"provider": "gemini", "default": "gemini-2.5-flash"}
    +        config.cfg["providers"] = {}
    +        try:
    +            config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
    +        except Exception:
    +            config._cfg_mtime = 0.0
    +
    +        config.invalidate_models_cache()
    +        try:
    +            models = config.get_available_models()
    +            yandex_group = next(
    +                (g for g in models.get("groups", []) if g.get("provider_id") == "yandex"),
    +                None,
    +            )
    +            assert yandex_group is not None, "authenticated plugin provider must appear in picker"
    +            assert yandex_group["provider"] == "Yandex AI Studio"
    +            assert len(yandex_group.get("models") or []) >= 1
    +        finally:
    +            config.cfg.clear()
    +            config.cfg.update(old_cfg)
    +            config._cfg_mtime = old_mtime
    +            config.invalidate_models_cache()
    
1fc2f5329963

Merge df365d8fab99af22c95d08bb9988362bc5cf8fd0 into f1211e1f0c3759375e907e70d01e077624981f78

https://github.com/nesquena/hermes-webuiHinotobiJun 5, 2026via nvd-ref
3 files changed · +149 10
  • api/passkeys.py+47 7 modified
    @@ -12,6 +12,7 @@
     import os
     import secrets
     import tempfile
    +import threading
     import time
     from dataclasses import dataclass
     from pathlib import Path
    @@ -29,14 +30,21 @@
     
     _CREDENTIALS_FILE = STATE_DIR / "passkeys.json"
     _CHALLENGES_FILE = STATE_DIR / ".passkey_challenges.json"
    -_CHALLENGE_TTL = 300
    +_CHALLENGE_TTL = 90
    +_MAX_CHALLENGES = 128
    +_MAX_CHALLENGES_PER_CONTEXT = 8
    +_CHALLENGES_LOCK = threading.Lock()
     _RP_NAME = "Hermes WebUI"
     
     
     class PasskeyError(ValueError):
         """Raised for user-correctable WebAuthn failures."""
     
     
    +class PasskeyRateLimitError(PasskeyError):
    +    """Raised when too many outstanding WebAuthn challenges are pending."""
    +
    +
     def _b64u(data: bytes) -> str:
         return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
     
    @@ -104,6 +112,8 @@ def passkeys_available() -> bool:
     
     
     def _load_challenges() -> dict[str, dict[str, Any]]:
    +    # May prune and rewrite the challenge file; callers that mutate the store
    +    # must hold _CHALLENGES_LOCK across load→mutate→write.
         raw = _json_load(_CHALLENGES_FILE, {})
         if not isinstance(raw, dict):
             return {}
    @@ -117,16 +127,46 @@ def _load_challenges() -> dict[str, dict[str, Any]]:
         return clean
     
     
    +def _oldest_challenge_key(data: dict[str, dict[str, Any]], keys: list[str]) -> str | None:
    +    if not keys:
    +        return None
    +    return min(keys, key=lambda k: float(data.get(k, {}).get("ts", 0)))
    +
    +
    +def _evict_oldest_challenges(data: dict[str, dict[str, Any]], kind: str, rp_id: str, origin: str) -> None:
    +    """Keep the challenge store bounded while admitting the newest challenge."""
    +    while True:
    +        same_context = [
    +            k for k, v in data.items()
    +            if v.get("kind") == kind and v.get("rp_id") == rp_id and v.get("origin") == origin
    +        ]
    +        if len(same_context) < _MAX_CHALLENGES_PER_CONTEXT:
    +            break
    +        oldest = _oldest_challenge_key(data, same_context)
    +        if oldest is None:
    +            break
    +        data.pop(oldest, None)
    +
    +    while len(data) >= _MAX_CHALLENGES:
    +        oldest = _oldest_challenge_key(data, list(data))
    +        if oldest is None:
    +            break
    +        data.pop(oldest, None)
    +
    +
     def _store_challenge(challenge: str, kind: str, rp_id: str, origin: str) -> None:
    -    data = _load_challenges()
    -    data[challenge] = {"kind": kind, "rp_id": rp_id, "origin": origin, "ts": time.time()}
    -    _atomic_write_json(_CHALLENGES_FILE, data)
    +    with _CHALLENGES_LOCK:
    +        data = _load_challenges()
    +        _evict_oldest_challenges(data, kind, rp_id, origin)
    +        data[challenge] = {"kind": kind, "rp_id": rp_id, "origin": origin, "ts": time.time()}
    +        _atomic_write_json(_CHALLENGES_FILE, data)
     
     
     def _consume_challenge(challenge: str, kind: str) -> dict[str, Any]:
    -    data = _load_challenges()
    -    entry = data.pop(challenge, None)
    -    _atomic_write_json(_CHALLENGES_FILE, data)
    +    with _CHALLENGES_LOCK:
    +        data = _load_challenges()
    +        entry = data.pop(challenge, None)
    +        _atomic_write_json(_CHALLENGES_FILE, data)
         if not entry or entry.get("kind") != kind:
             raise PasskeyError("Passkey challenge expired. Try again.")
         return entry
    
  • api/routes.py+10 3 modified
    @@ -7994,14 +7994,16 @@ def _llm_update_summary(system_prompt: str, user_prompt: str) -> str:
     
         if parsed.path == "/api/auth/passkey/options":
             from api.auth import _passkey_feature_flag_enabled, is_auth_enabled
    -        from api.passkeys import PasskeyError, authentication_options
    +        from api.passkeys import PasskeyError, PasskeyRateLimitError, authentication_options
     
             if not _passkey_feature_flag_enabled():
                 return j(handler, {"error": "Passkey support is disabled. Set HERMES_WEBUI_PASSKEY=1 or webui_passkey_enabled: true to enable."}, status=404)
             if not is_auth_enabled():
                 return j(handler, {"error": "Auth not enabled"}, status=400)
             try:
                 return j(handler, {"ok": True, "publicKey": authentication_options(handler)})
    +        except PasskeyRateLimitError as e:
    +            return bad(handler, str(e), status=429)
             except PasskeyError as e:
                 return bad(handler, str(e), status=400)
     
    @@ -8034,11 +8036,16 @@ def _llm_update_summary(system_prompt: str, user_prompt: str) -> str:
     
         if parsed.path == "/api/auth/passkey/register/options":
             from api.auth import _passkey_feature_flag_enabled
    -        from api.passkeys import registration_options
    +        from api.passkeys import PasskeyError, PasskeyRateLimitError, registration_options
     
             if not _passkey_feature_flag_enabled():
                 return j(handler, {"error": "Passkey support is disabled."}, status=404)
    -        return j(handler, {"ok": True, "publicKey": registration_options(handler)})
    +        try:
    +            return j(handler, {"ok": True, "publicKey": registration_options(handler)})
    +        except PasskeyRateLimitError as e:
    +            return bad(handler, str(e), status=429)
    +        except PasskeyError as e:
    +            return bad(handler, str(e), status=400)
     
         if parsed.path == "/api/auth/passkey/register":
             from api.auth import _passkey_feature_flag_enabled
    
  • tests/test_passkey_auth.py+92 0 modified
    @@ -1,8 +1,11 @@
     import base64
    +import io
     import json
     import hashlib
    +from types import SimpleNamespace
     
     from cryptography.hazmat.primitives import hashes
    +
     from cryptography.hazmat.primitives.asymmetric import ec
     
     
    @@ -133,6 +136,95 @@ def test_passkey_login_verifies_signature_and_updates_usage(monkeypatch, tmp_pat
         assert cred["last_used_at"] is not None
     
     
    +def test_passkey_options_evict_oldest_challenges_per_context(monkeypatch, tmp_path):
    +    passkeys = _set_paths(monkeypatch, tmp_path)
    +    passkeys._save_credentials([{"id": b64u(b"credential-3"), "label": "This device"}])
    +    monkeypatch.setattr(passkeys, "_MAX_CHALLENGES_PER_CONTEXT", 2)
    +
    +    first = passkeys.authentication_options(FakeHandler())
    +    second = passkeys.authentication_options(FakeHandler())
    +    third = passkeys.authentication_options(FakeHandler())
    +
    +    pending = json.loads((tmp_path / ".passkey_challenges.json").read_text(encoding="utf-8"))
    +    assert set(pending) == {second["challenge"], third["challenge"]}
    +    assert first["challenge"] not in pending
    +
    +
    +def test_passkey_options_evict_oldest_challenges_globally(monkeypatch, tmp_path):
    +    passkeys = _set_paths(monkeypatch, tmp_path)
    +    passkeys._save_credentials([{"id": b64u(b"credential-4"), "label": "This device"}])
    +    monkeypatch.setattr(passkeys, "_MAX_CHALLENGES", 2)
    +    monkeypatch.setattr(passkeys, "_MAX_CHALLENGES_PER_CONTEXT", 10)
    +
    +    first = passkeys.authentication_options(FakeHandler())
    +    second = passkeys.authentication_options(FakeHandler())
    +    third = passkeys.authentication_options(FakeHandler())
    +
    +    pending = json.loads((tmp_path / ".passkey_challenges.json").read_text(encoding="utf-8"))
    +    assert set(pending) == {second["challenge"], third["challenge"]}
    +    assert first["challenge"] not in pending
    +
    +
    +class RouteFakeHandler:
    +    def __init__(self):
    +        self.headers = FakeHeaders({"Host": "localhost:8787", "Content-Length": "0"})
    +        self.rfile = io.BytesIO(b"")
    +        self.wfile = io.BytesIO()
    +        self.status = None
    +        self.sent_headers = []
    +        self.client_address = ("127.0.0.1", 12345)
    +
    +    def send_response(self, status):
    +        self.status = status
    +
    +    def send_header(self, key, value):
    +        self.sent_headers.append((key, value))
    +
    +    def end_headers(self):
    +        pass
    +
    +
    +def test_passkey_options_rate_limit_errors_return_429(monkeypatch):
    +    import api.auth as auth
    +    import api.passkeys as passkeys
    +    import api.routes as routes
    +
    +    monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
    +    monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: True)
    +    monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
    +
    +    def raise_rate_limit(_handler):
    +        raise passkeys.PasskeyRateLimitError("too many")
    +
    +    monkeypatch.setattr(passkeys, "authentication_options", raise_rate_limit)
    +    handler = RouteFakeHandler()
    +
    +    routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/options"))
    +
    +    assert handler.status == 429
    +    assert json.loads(handler.wfile.getvalue())["error"] == "too many"
    +
    +
    +def test_passkey_register_options_handles_base_passkey_errors(monkeypatch):
    +    import api.auth as auth
    +    import api.passkeys as passkeys
    +    import api.routes as routes
    +
    +    monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
    +    monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: True)
    +
    +    def raise_passkey_error(_handler):
    +        raise passkeys.PasskeyError("plain passkey error")
    +
    +    monkeypatch.setattr(passkeys, "registration_options", raise_passkey_error)
    +    handler = RouteFakeHandler()
    +
    +    routes.handle_post(handler, SimpleNamespace(path="/api/auth/passkey/register/options"))
    +
    +    assert handler.status == 400
    +    assert json.loads(handler.wfile.getvalue())["error"] == "plain passkey error"
    +
    +
     def test_auth_status_reports_passkey_availability_source_contract():
         src = open("api/routes.py", encoding="utf-8").read()
         assert '"passkeys_enabled"' in src
    

Vulnerability mechanics

Root cause

"The passkey authentication endpoint did not limit the number of pending challenges, allowing unbounded growth of the challenge store."

Attack vector

An unauthenticated remote attacker can repeatedly send POST requests to the authentication endpoint without completing the passkey assertion process. This floods the system with new challenge requests, leading to excessive resource consumption. The attacker can exploit this by continuously calling the passkey options endpoint, causing the challenge store file to grow indefinitely and consuming significant CPU and disk I/O through repeated JSON file rewrites [ref_id=1].

Affected code

The vulnerability lies within the passkey authentication flow, specifically in how pending challenges are managed. The `api/passkeys.py` file handles the storage and consumption of challenges, and the `api/routes.py` file routes requests to these functions. The fix in `api/passkeys.py` introduces limits and eviction logic for challenges, while `api/routes.py` handles potential rate limit errors [patch_id=5390382].

What the fix does

The patch introduces limits to the number of pending passkey challenges that can be stored. It now evicts the oldest challenges, both globally and per-context, when these limits are reached, preventing unbounded growth of the challenge store file. This change ensures that the system does not exhaust resources due to an excessive number of uncompleted authentication attempts [patch_id=5390382].

Preconditions

  • configPasskey support must be enabled in the application configuration.
  • authThe attacker must not be authenticated.
  • networkThe attacker must have network access to the authentication endpoint.

Generated on Jun 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.