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

* feat(commands): add /use to force a skill for the next turn (#3517, #2977)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* fix(auth): cap pending passkey challenges by evicting oldest, not rejecting (#3624)

Co-authored-by: Hinotobi <paperlantern.agent@gmail.com>

* fix(providers): expose model-provider plugins in WebUI (#3613)

Co-authored-by: Pamnard <pamnard@users.noreply.github.com>

* docs(changelog): v0.51.270 — Release IL (stage-u1, 3-PR un-hold batch; #3448 + #3618 dropped)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hinotobi <paperlantern.agent@gmail.com>
Co-authored-by: Pamnard <pamnard@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-06-05 11:35:26 -07:00
1 parent 2c7b530071
commit 58528a4d88
15 files changed
+799 -33

No files matched your search

+9
View File
@@ -3,6 +3,15 @@
## [Unreleased] ## [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) ## [v0.51.269] — 2026-06-05 — Release IK (stage-b2 — sidebar perf + search scope + Windows ctl)
### Fixed ### Fixed
+19 -3
View File
@@ -27,6 +27,10 @@ from urllib.parse import parse_qs, urlparse
# ── Basic layout ────────────────────────────────────────────────────────────── # ── Basic layout ──────────────────────────────────────────────────────────────
import api.paths as _paths 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 HOME = _paths.HOME
_hermes_home_has_webui_state = _paths._hermes_home_has_webui_state _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. # 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. # Disk delete is outside the lock — file I/O shouldn't block other readers.
_delete_models_cache_on_disk() _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): def invalidate_credential_pool_cache(provider_id: str):
@@ -3787,7 +3797,9 @@ def get_available_models() -> dict:
# aliases; ``isinstance(_provider_cfg, dict)`` accepts custom # aliases; ``isinstance(_provider_cfg, dict)`` accepts custom
# entries that supply their own models/api_key/base_url. (#2399) # entries that supply their own models/api_key/base_url. (#2399)
_is_known_provider = ( _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) _is_provider_config = isinstance(_provider_cfg, dict)
if not (_is_known_provider or _is_provider_config): if not (_is_known_provider or _is_provider_config):
@@ -4242,7 +4254,7 @@ def get_available_models() -> dict:
group["models_endpoint_error"] = _named_custom_errors[pid] group["models_endpoint_error"] = _named_custom_errors[pid]
groups.append(group) groups.append(group)
continue continue
provider_name = _PROVIDER_DISPLAY.get(pid, pid.title()) provider_name = _effective_provider_display_name(pid, _PROVIDER_DISPLAY)
if pid == "openrouter": if pid == "openrouter":
# OpenRouter has two model surfaces: # OpenRouter has two model surfaces:
# (1) curated tool-supporting catalog via hermes_cli.models.fetch_openrouter_models() # (1) curated tool-supporting catalog via hermes_cli.models.fetch_openrouter_models()
@@ -4545,7 +4557,11 @@ def get_available_models() -> dict:
"models": 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 # Look up provider_cfg using the original raw key from
# config.yaml so that mixed-case / underscore keys like # config.yaml so that mixed-case / underscore keys like
# ``CLIPpoxy`` or ``snake_case_provider`` still resolve # ``CLIPpoxy`` or ``snake_case_provider`` still resolve
+47 -7
View File
@@ -12,6 +12,7 @@ import json
import os import os
import secrets import secrets
import tempfile import tempfile
import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -29,7 +30,10 @@ except Exception: # pragma: no cover - exercised by source tests instead
_CREDENTIALS_FILE = STATE_DIR / "passkeys.json" _CREDENTIALS_FILE = STATE_DIR / "passkeys.json"
_CHALLENGES_FILE = STATE_DIR / ".passkey_challenges.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" _RP_NAME = "Hermes WebUI"
@@ -37,6 +41,10 @@ class PasskeyError(ValueError):
"""Raised for user-correctable WebAuthn failures.""" """Raised for user-correctable WebAuthn failures."""
class PasskeyRateLimitError(PasskeyError):
"""Raised when too many outstanding WebAuthn challenges are pending."""
def _b64u(data: bytes) -> str: def _b64u(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") 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]]: 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, {}) raw = _json_load(_CHALLENGES_FILE, {})
if not isinstance(raw, dict): if not isinstance(raw, dict):
return {} return {}
@@ -117,16 +127,46 @@ def _load_challenges() -> dict[str, dict[str, Any]]:
return clean 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: def _store_challenge(challenge: str, kind: str, rp_id: str, origin: str) -> None:
data = _load_challenges() with _CHALLENGES_LOCK:
data[challenge] = {"kind": kind, "rp_id": rp_id, "origin": origin, "ts": time.time()} data = _load_challenges()
_atomic_write_json(_CHALLENGES_FILE, data) _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]: def _consume_challenge(challenge: str, kind: str) -> dict[str, Any]:
data = _load_challenges() with _CHALLENGES_LOCK:
entry = data.pop(challenge, None) data = _load_challenges()
_atomic_write_json(_CHALLENGES_FILE, data) entry = data.pop(challenge, None)
_atomic_write_json(_CHALLENGES_FILE, data)
if not entry or entry.get("kind") != kind: if not entry or entry.get("kind") != kind:
raise PasskeyError("Passkey challenge expired. Try again.") raise PasskeyError("Passkey challenge expired. Try again.")
return entry return entry
+141
View File
@@ -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()
+66 -15
View File
@@ -43,10 +43,21 @@ from api.config import (
invalidate_models_cache, invalidate_models_cache,
reload_config, 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__) 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: def _custom_provider_name_matches(provider_id: str, name: object) -> bool:
"""Return True when *provider_id* refers to a named custom provider.""" """Return True when *provider_id* refers to a named custom provider."""
pid = str(provider_id or "").strip().lower() 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": if (provider_id or "").strip().lower() != "openai":
return False return False
values: list[object] = [] values: list[object] = []
env_var = _PROVIDER_ENV_VAR.get(provider_id) env_var = _provider_env_var_for(provider_id)
if env_var: if env_var:
env_path = _get_hermes_home() / ".env" env_path = _get_hermes_home() / ".env"
env_values = _load_env_file(env_path) 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`` 4. ``config.yaml → providers.<id>.api_key``
5. ``config.yaml → custom_providers[].api_key`` (for custom providers) 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: if env_var:
env_path = _get_hermes_home() / ".env" env_path = _get_hermes_home() / ".env"
env_values = _load_env_file(env_path) 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: def _get_provider_api_key(provider_id: str) -> str | None:
"""Return a configured provider API key without exposing it to callers.""" """Return a configured provider API key without exposing it to callers."""
provider_id = (provider_id or "").strip().lower() 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: if env_var:
env_path = _get_hermes_home() / ".env" env_path = _get_hermes_home() / ".env"
env_values = _load_env_file(env_path) 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: if value:
env[key] = 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: if env_var and api_key:
env[env_var] = 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 # Collect all known provider IDs from multiple sources
known_ids = set(_PROVIDER_DISPLAY.keys()) | set(_PROVIDER_MODELS.keys()) 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 # Also detect providers from config.yaml providers section
cfg = get_config() cfg = get_config()
@@ -1763,9 +1775,21 @@ def get_providers() -> dict[str, Any]:
known_ids.update(_OAUTH_PROVIDERS) known_ids.update(_OAUTH_PROVIDERS)
for pid in sorted(known_ids): 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) is_oauth = _provider_is_oauth(pid)
has_key = _provider_has_key(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 # Determine key source
key_source = "none" 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) logger.debug("hermes_cli auth check failed for %s", pid, exc_info=True)
# keep has_key from _provider_has_key() # keep has_key from _provider_has_key()
elif has_key: elif has_key:
env_var = _PROVIDER_ENV_VAR.get(pid) env_var = _provider_env_var_for(pid)
if env_var: if env_var:
env_path = _get_hermes_home() / ".env" env_path = _get_hermes_home() / ".env"
env_values = _load_env_file(env_path) env_values = _load_env_file(env_path)
@@ -1821,19 +1845,29 @@ def get_providers() -> dict[str, Any]:
aliased = True aliased = True
break break
if not aliased: 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: else:
key_source = "config_yaml" _plugin_ks = (
elif pid not in _PROVIDER_ENV_VAR: 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 # Fallback: provider is not a known API-key provider and not in
# the hardcoded _OAUTH_PROVIDERS set. It may be a custom or # the hardcoded _OAUTH_PROVIDERS set. It may be a custom or
# newly-added OAuth provider (e.g. Anthropic connected via OAuth). # newly-added OAuth provider (e.g. Anthropic connected via OAuth).
# Check live auth status so the Providers tab agrees with the # Check live auth status so the Providers tab agrees with the
# model picker (#1212). # model picker (#1212).
# #
# IMPORTANT: we skip providers in _PROVIDER_ENV_VAR because they # IMPORTANT: we skip providers with a known API-key env var because
# are pure API-key providers — calling get_auth_status() for every # they are pure API-key providers — calling get_auth_status() for
# unconfigured API-key provider would add unnecessary latency # every unconfigured API-key provider would add unnecessary latency
# (network round-trip per provider) on the Settings page. # (network round-trip per provider) on the Settings page.
# Validate pid looks like a real provider before probing # Validate pid looks like a real provider before probing
import re as _re import re as _re
@@ -1924,6 +1958,21 @@ def get_providers() -> dict[str, Any]:
models_total = len(models) models_total = len(models)
except Exception: except Exception:
logger.debug("Failed to load LM Studio models from hermes_cli") 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 # Also include models from config.yaml providers section
if isinstance(providers_cfg, dict): if isinstance(providers_cfg, dict):
provider_cfg = providers_cfg.get(pid, {}) provider_cfg = providers_cfg.get(pid, {})
@@ -1941,11 +1990,13 @@ def get_providers() -> dict[str, Any]:
if pid != "nous": if pid != "nous":
models_total = len(models) models_total = len(models)
_is_plugin = is_plugin_model_provider(pid)
providers.append({ providers.append({
"id": pid, "id": pid,
"display_name": display_name, "display_name": display_name,
"has_key": has_key, "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, "is_oauth": is_oauth,
"key_source": key_source, "key_source": key_source,
"auth_error": auth_error, "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.", 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: if not env_var:
return { return {
"ok": False, "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.", f"This provider does not have a known env var mapping.",
} }
+10 -3
View File
@@ -8024,7 +8024,7 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/auth/passkey/options": if parsed.path == "/api/auth/passkey/options":
from api.auth import _passkey_feature_flag_enabled, is_auth_enabled 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(): 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) return j(handler, {"error": "Passkey support is disabled. Set HERMES_WEBUI_PASSKEY=1 or webui_passkey_enabled: true to enable."}, status=404)
@@ -8032,6 +8032,8 @@ def handle_post(handler, parsed) -> bool:
return j(handler, {"error": "Auth not enabled"}, status=400) return j(handler, {"error": "Auth not enabled"}, status=400)
try: try:
return j(handler, {"ok": True, "publicKey": authentication_options(handler)}) return j(handler, {"ok": True, "publicKey": authentication_options(handler)})
except PasskeyRateLimitError as e:
return bad(handler, str(e), status=429)
except PasskeyError as e: except PasskeyError as e:
return bad(handler, str(e), status=400) return bad(handler, str(e), status=400)
@@ -8064,11 +8066,16 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/auth/passkey/register/options": if parsed.path == "/api/auth/passkey/register/options":
from api.auth import _passkey_feature_flag_enabled 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(): if not _passkey_feature_flag_enabled():
return j(handler, {"error": "Passkey support is disabled."}, status=404) 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": if parsed.path == "/api/auth/passkey/register":
from api.auth import _passkey_feature_flag_enabled from api.auth import _passkey_feature_flag_enabled
+40
View File
@@ -17,6 +17,7 @@ const COMMANDS=[
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name', noEcho:true}, {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:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name', subArgs:'personalities'},
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'}, {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:'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:'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}, {name:'queue', desc:t('cmd_queue'), fn:cmdQueue, arg:'message', noEcho:true},
@@ -94,6 +95,7 @@ function getMatchingCommands(prefix){
return matches; return matches;
} }
let _forcedSkillDirectivePending=null;
let _slashModelCache=null; let _slashModelCache=null;
let _slashModelCachePromise=null; let _slashModelCachePromise=null;
let _slashPersonalityCache=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){ async function cmdPersonality(args){
if(!S.session){showToast(t('no_active_session'));return;} if(!S.session){showToast(t('no_active_session'));return;}
if(!args){ if(!args){
+12
View File
@@ -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_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_personality: 'Switch agent personality',
cmd_skills: 'List available Hermes skills', cmd_skills: 'List available Hermes skills',
cmd_use: 'Force a skill for the next message',
available_commands: 'Available commands:', available_commands: 'Available commands:',
type_slash: 'Type / to see commands', type_slash: 'Type / to see commands',
conversation_cleared: 'Conversation cleared', 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_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_personality: "Cambia personalità dell'agente",
cmd_skills: 'Elenca le skill Hermes disponibili', cmd_skills: 'Elenca le skill Hermes disponibili',
cmd_use: 'Forza una skill per il prossimo messaggio',
available_commands: 'Comandi disponibili:', available_commands: 'Comandi disponibili:',
type_slash: 'Digita / per vedere i comandi', type_slash: 'Digita / per vedere i comandi',
conversation_cleared: 'Conversazione cancellata', 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_theme: '外観を切り替え (theme: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: 'エージェントのパーソナリティを切り替え', cmd_personality: 'エージェントのパーソナリティを切り替え',
cmd_skills: '利用可能な Hermes スキルを一覧表示', cmd_skills: '利用可能な Hermes スキルを一覧表示',
cmd_use: '次のメッセージにスキルを強制適用',
available_commands: '利用可能なコマンド:', available_commands: '利用可能なコマンド:',
type_slash: '/ を入力するとコマンド一覧', type_slash: '/ を入力するとコマンド一覧',
conversation_cleared: '会話をクリアしました', 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_theme: 'Переключить внешний вид (тема: system/dark/light, скин: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: 'Переключить личность агента', cmd_personality: 'Переключить личность агента',
cmd_skills: 'Показать доступные навыки Hermes', cmd_skills: 'Показать доступные навыки Hermes',
cmd_use: 'Принудительно применить навык к следующему сообщению',
available_commands: 'Доступные команды:', available_commands: 'Доступные команды:',
type_slash: 'Введите /, чтобы увидеть команды', type_slash: 'Введите /, чтобы увидеть команды',
conversation_cleared: 'Беседа очищена', 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_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_personality: 'Cambiar la personalidad del agente',
cmd_skills: 'Listar las skills de Hermes disponibles', cmd_skills: 'Listar las skills de Hermes disponibles',
cmd_use: 'Forzar una skill para el próximo mensaje',
available_commands: 'Comandos disponibles:', available_commands: 'Comandos disponibles:',
type_slash: 'Escribe / para ver los comandos', type_slash: 'Escribe / para ver los comandos',
conversation_cleared: 'Conversación borrada', 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_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_personality: 'Agenten-Persönlichkeit wechseln',
cmd_skills: 'Verfügbare Hermes-Skills auflisten', cmd_skills: 'Verfügbare Hermes-Skills auflisten',
cmd_use: 'Einen Skill für die nächste Nachricht erzwingen',
available_commands: 'Verfügbare Befehle:', available_commands: 'Verfügbare Befehle:',
type_slash: 'Tippe / für Befehle', type_slash: 'Tippe / für Befehle',
conversation_cleared: 'Konversation gelöscht', 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_theme: '切换外观(主题:system/dark/light,皮肤:default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: '切换 Agent 人设', cmd_personality: '切换 Agent 人设',
cmd_skills: '列出可用的 Hermes 技能', cmd_skills: '列出可用的 Hermes 技能',
cmd_use: '为下一条消息强制使用技能',
available_commands: '可用命令:', available_commands: '可用命令:',
type_slash: '输入 / 可查看命令', type_slash: '输入 / 可查看命令',
conversation_cleared: '对话已清空', 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_theme: '切換外觀(主題:system/dark/light,skin:default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus)',
cmd_personality: '切換 Agent 角色', cmd_personality: '切換 Agent 角色',
cmd_skills: '列出可用的 Hermes 技能', cmd_skills: '列出可用的 Hermes 技能',
cmd_use: '為下一則訊息強制使用技能',
available_commands: '可用命令:', available_commands: '可用命令:',
type_slash: '輸入 / 檢視命令', type_slash: '輸入 / 檢視命令',
conversation_cleared: '已清除對話', 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_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_personality: 'Trocar personalidade do agente',
cmd_skills: 'Listar skills disponíveis do Hermes', cmd_skills: 'Listar skills disponíveis do Hermes',
cmd_use: 'Forçar uma skill para a próxima mensagem',
available_commands: 'Comandos disponíveis:', available_commands: 'Comandos disponíveis:',
type_slash: 'Digite / para ver comandos', type_slash: 'Digite / para ver comandos',
conversation_cleared: 'Conversa limpa', 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_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_personality: 'Switch agent personality',
cmd_skills: 'List available Hermes skills', cmd_skills: 'List available Hermes skills',
cmd_use: 'Force a skill for the next message',
available_commands: '사용 가능한 명령:', available_commands: '사용 가능한 명령:',
type_slash: '/ 를 입력해 명령 보기', type_slash: '/ 를 입력해 명령 보기',
conversation_cleared: '대화를 지웠습니다', 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_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_personality: 'Personnalité de l\'agent de commutation',
cmd_skills: 'Lister les compétences Hermès disponibles', cmd_skills: 'Lister les compétences Hermès disponibles',
cmd_use: 'Forcer une compétence pour le prochain message',
available_commands: 'Commandes disponibles :', available_commands: 'Commandes disponibles :',
type_slash: 'Tapez / pour voir les commandes', type_slash: 'Tapez / pour voir les commandes',
conversation_cleared: 'Conversation effacée', 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_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_personality: 'Temsilci kişiliğini değiştir',
cmd_skills: 'Mevcut Hermes becerilerini listele', cmd_skills: 'Mevcut Hermes becerilerini listele',
cmd_use: 'Sonraki mesaj için bir beceriyi zorla',
available_commands: 'Mevcut komutlar:', available_commands: 'Mevcut komutlar:',
type_slash: 'Komutları görmek için / yazın', type_slash: 'Komutları görmek için / yazın',
conversation_cleared: 'Görüşme temizlendi', conversation_cleared: 'Görüşme temizlendi',
+10
View File
@@ -568,6 +568,16 @@ async function send(){
let msgText=text; let msgText=text;
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploadedPaths.join(', ')}`; 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(', ')}]`; 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;} if(!msgText){setComposerStatus('Nothing to send');return;}
$('msg').value='';autoResize(); $('msg').value='';autoResize();
+1 -1
View File
@@ -6716,7 +6716,7 @@ async function loadProvidersPanel(){
try{ try{
const data=await api('/api/providers'); 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 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=''; list.innerHTML='';
_providerCardEls.clear(); _providerCardEls.clear();
const quotaCard=_buildProviderQuotaCard(quota); const quotaCard=_buildProviderQuotaCard(quota);
+1 -1
View File
@@ -107,7 +107,7 @@ class TestCustomProvidersInGetProviders:
def test_providers_panel_renders_config_yaml_custom_providers(self): def test_providers_panel_renders_config_yaml_custom_providers(self):
"""Settings → Providers must not filter out read-only custom providers.""" """Settings → Providers must not filter out read-only custom providers."""
src = open("static/panels.js", encoding="utf-8").read() 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 "Custom provider loaded from config.yaml / hermes model" in src
assert "if(p.configurable){" in src assert "if(p.configurable){" in src
+10 -3
View File
@@ -290,9 +290,16 @@ class TestProviderListFilter:
"This excludes ALL OAuth providers (openai-codex, nous, copilot) from the " "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)'." "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)") assert "p.is_oauth" in self.JS, (
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, (
"The provider filter must include p.is_oauth in panels.js. " "The provider filter must include p.is_oauth in panels.js. "
"OAuth providers will not appear in Settings → Providers." "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."
)
+116
View File
@@ -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"
+92
View File
@@ -1,8 +1,11 @@
import base64 import base64
import io
import json import json
import hashlib import hashlib
from types import SimpleNamespace
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec 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 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(): def test_auth_status_reports_passkey_availability_source_contract():
src = open("api/routes.py", encoding="utf-8").read() src = open("api/routes.py", encoding="utf-8").read()
assert '"passkeys_enabled"' in src assert '"passkeys_enabled"' in src
+225
View File
@@ -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()