diff --git a/CHANGELOG.md b/CHANGELOG.md index aa12cf9a..e6fb671a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/api/config.py b/api/config.py index a13cd745..4e39ba8f 100644 --- a/api/config.py +++ b/api/config.py @@ -27,6 +27,10 @@ from urllib.parse import parse_qs, urlparse # ── 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 get_available_models() -> dict: # 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 get_available_models() -> dict: 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 get_available_models() -> dict: "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 diff --git a/api/passkeys.py b/api/passkeys.py index 2aadf980..1bdc800f 100644 --- a/api/passkeys.py +++ b/api/passkeys.py @@ -12,6 +12,7 @@ import json import os import secrets import tempfile +import threading import time from dataclasses import dataclass from pathlib import Path @@ -29,7 +30,10 @@ except Exception: # pragma: no cover - exercised by source tests instead _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" @@ -37,6 +41,10 @@ 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 diff --git a/api/plugin_providers.py b/api/plugin_providers.py new file mode 100644 index 00000000..af9421ce --- /dev/null +++ b/api/plugin_providers.py @@ -0,0 +1,141 @@ +"""Helpers for model-provider plugins (``plugins/model-providers//``). + +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() diff --git a/api/providers.py b/api/providers.py index 23c86d9c..12e6f580 100644 --- a/api/providers.py +++ b/api/providers.py @@ -43,10 +43,21 @@ from api.config import ( 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..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.", } diff --git a/api/routes.py b/api/routes.py index 433317ae..cc1d5360 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8024,7 +8024,7 @@ def handle_post(handler, parsed) -> bool: 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) @@ -8032,6 +8032,8 @@ def handle_post(handler, parsed) -> bool: 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 handle_post(handler, parsed) -> bool: 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 diff --git a/static/commands.js b/static/commands.js index f9a4f058..e196e342 100644 --- a/static/commands.js +++ b/static/commands.js @@ -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 ` — 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){ diff --git a/static/i18n.js b/static/i18n.js index 5e7d52cc..ea964db6 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/messages.js b/static/messages.js index e429df82..5915b348 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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(); diff --git a/static/panels.js b/static/panels.js index a0a07ccf..9315c1bd 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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); diff --git a/tests/test_custom_providers_in_panel.py b/tests/test_custom_providers_in_panel.py index 1cf7c750..600b5b38 100644 --- a/tests/test_custom_providers_in_panel.py +++ b/tests/test_custom_providers_in_panel.py @@ -107,7 +107,7 @@ class TestCustomProvidersInGetProviders: 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 diff --git a/tests/test_issue1202_oauth_provider_status.py b/tests/test_issue1202_oauth_provider_status.py index 4b66bdbf..6e952f99 100644 --- a/tests/test_issue1202_oauth_provider_status.py +++ b/tests/test_issue1202_oauth_provider_status.py @@ -290,9 +290,16 @@ class TestProviderListFilter: "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." + ) diff --git a/tests/test_issue2977_use_command.py b/tests/test_issue2977_use_command.py new file mode 100644 index 00000000..6e32823d --- /dev/null +++ b/tests/test_issue2977_use_command.py @@ -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" diff --git a/tests/test_passkey_auth.py b/tests/test_passkey_auth.py index 46ac3641..ef54f854 100644 --- a/tests/test_passkey_auth.py +++ b/tests/test_passkey_auth.py @@ -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 diff --git a/tests/test_plugin_model_providers.py b/tests/test_plugin_model_providers.py new file mode 100644 index 00000000..f9134b51 --- /dev/null +++ b/tests/test_plugin_model_providers.py @@ -0,0 +1,225 @@ +"""Regression tests for model-provider plugin discovery in WebUI. + +Plugin profiles under ``plugins/model-providers//`` 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()