diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f30b1..10d7b6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Hermes Web UI -- Changelog +## [v0.50.63] — 2026-04-16 + +### Fixed +- **Onboarding wizard no longer fires for non-standard providers** — providers outside the quick-setup list (`minimax-cn`, `deepseek`, `xai`, `gemini`, etc.) were always evaluated as `chat_ready=False` because `_provider_api_key_present()` only knew the four built-in env-var names. Those users saw the wizard on every page load and risked `config.yaml` being silently overwritten if the provider dropdown defaulted. The fix adds a `hermes_cli.auth.get_auth_status()` fallback covering every API-key provider in the full registry, and tightens the frontend guard so an unchanged unsupported-provider form never POSTs. (Fixes #572, PR #575) +- **MCP server toolsets now included in WebUI agent sessions** — previously the WebUI read `platform_toolsets.cli` directly from `config.yaml`, which only carries built-in toolset names. MCP server names (`tidb`, `kyuubi`, etc.) were silently dropped, so MCP tools configured via `~/.hermes/config.yaml` were unavailable in chat. The fix delegates to `hermes_cli.tools_config._get_platform_tools()` — the same code the CLI uses — which merges all enabled MCP servers automatically. Falls back gracefully when `hermes_cli` is unavailable. (PR #574 by @renheqiang) + ## [v0.50.62] — 2026-04-16 ### Fixed diff --git a/api/config.py b/api/config.py index f5567a2..1051e17 100644 --- a/api/config.py +++ b/api/config.py @@ -342,7 +342,6 @@ MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # ── File type maps ─────────────────────────────────────────────────────────── IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".bmp"} MD_EXTS = {".md", ".markdown", ".mdown"} -OFFICE_EXTS = {".xls", ".xlsx", ".doc", ".docx"} CODE_EXTS = { ".py", ".js", @@ -404,7 +403,19 @@ _DEFAULT_TOOLSETS = [ "web", "webhook", ] -CLI_TOOLSETS = get_config().get("platform_toolsets", {}).get("cli", _DEFAULT_TOOLSETS) +def _resolve_cli_toolsets(cfg=None): + """Resolve CLI toolsets using the agent's _get_platform_tools() so that + MCP server toolsets are automatically included, matching CLI behaviour.""" + if cfg is None: + cfg = get_config() + try: + from hermes_cli.tools_config import _get_platform_tools + return list(_get_platform_tools(cfg, "cli")) + except Exception: + # Fallback: read raw list from config (MCP toolsets will be missing) + return cfg.get("platform_toolsets", {}).get("cli", _DEFAULT_TOOLSETS) + +CLI_TOOLSETS = _resolve_cli_toolsets() # ── Model / provider discovery ─────────────────────────────────────────────── diff --git a/api/onboarding.py b/api/onboarding.py index 8119d18..e55e887 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -210,6 +210,22 @@ def _provider_api_key_present( and str(custom_cfg.get("api_key") or "").strip() ): return True + + # For providers not in _SUPPORTED_PROVIDER_SETUPS (e.g. minimax-cn, deepseek, + # xai, etc.), ask the hermes_cli auth registry — it knows every provider's env + # var names and can check os.environ for a valid key. + # Exclude known OAuth/token-flow providers — those are handled separately by + # _provider_oauth_authenticated() and should not be short-circuited here. + _known_oauth = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous"} + if provider not in _SUPPORTED_PROVIDER_SETUPS and provider not in _known_oauth: + try: + from hermes_cli.auth import get_auth_status as _gas + status = _gas(provider) + if isinstance(status, dict) and status.get("logged_in"): + return True + except Exception: + pass + return False @@ -288,11 +304,13 @@ def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict: elif provider in _SUPPORTED_PROVIDER_SETUPS: provider_ready = _provider_api_key_present(provider, cfg, env_values) else: - # Unknown / OAuth provider (e.g. openai-codex, copilot, qwen-oauth). - # These do not use a plain API key; auth lives in auth.json or a - # credential pool managed by hermes_cli. - provider_ready = _provider_oauth_authenticated( - provider, _get_active_hermes_home() + # Unknown provider — may be an OAuth flow (openai-codex, copilot, etc.) + # OR an API-key provider not in the quick-setup list (minimax-cn, deepseek, + # xai, etc.). Check both: api key presence first (covers the majority of + # third-party providers), then OAuth auth.json. + provider_ready = ( + _provider_api_key_present(provider, cfg, env_values) + or _provider_oauth_authenticated(provider, _get_active_hermes_home()) ) chat_ready = bool(_HERMES_FOUND and imports_ok and provider_ready) diff --git a/api/routes.py b/api/routes.py index 6361cd4..81153da 100644 --- a/api/routes.py +++ b/api/routes.py @@ -29,7 +29,7 @@ from api.config import ( STREAMS_LOCK, CANCEL_FLAGS, SERVER_START_TIME, - CLI_TOOLSETS, + _resolve_cli_toolsets, _INDEX_HTML_PATH, get_available_models, IMAGE_EXTS, @@ -2070,7 +2070,7 @@ def _handle_chat_sync(handler, body): api_key=_api_key, platform="cli", quiet_mode=True, - enabled_toolsets=CLI_TOOLSETS, + enabled_toolsets=_resolve_cli_toolsets(), session_id=s.session_id, ) workspace_ctx = f"[Workspace: {s.workspace}]\n" diff --git a/api/streaming.py b/api/streaming.py index 213da1b..4a456a7 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -16,7 +16,7 @@ from typing import Optional logger = logging.getLogger(__name__) from api.config import ( - STREAMS, STREAMS_LOCK, CANCEL_FLAGS, AGENT_INSTANCES, CLI_TOOLSETS, + STREAMS, STREAMS_LOCK, CANCEL_FLAGS, AGENT_INSTANCES, LOCK, SESSIONS, SESSION_DIR, _get_session_agent_lock, _set_thread_env, _clear_thread_env, resolve_model_provider, @@ -804,9 +804,10 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta from api.config import get_config as _get_config _cfg = _get_config() - # Per-profile toolsets (fall back to module-level CLI_TOOLSETS) - _pt = _cfg.get('platform_toolsets', {}) - _toolsets = _pt.get('cli', CLI_TOOLSETS) if isinstance(_pt, dict) else CLI_TOOLSETS + # Per-profile toolsets — use _resolve_cli_toolsets() so MCP + # server toolsets are included, matching native CLI behaviour. + from api.config import _resolve_cli_toolsets + _toolsets = _resolve_cli_toolsets(_cfg) # Fallback model from profile config (e.g. for rate-limit recovery) _fallback = _cfg.get('fallback_model') or None diff --git a/static/index.html b/static/index.html index df19319..44d9a47 100644 --- a/static/index.html +++ b/static/index.html @@ -553,7 +553,7 @@
System
Instance version and access controls.
- v0.50.62 + v0.50.63
diff --git a/static/onboarding.js b/static/onboarding.js index 00c5ac0..85ea8d0 100644 --- a/static/onboarding.js +++ b/static/onboarding.js @@ -296,7 +296,14 @@ async function _saveOnboardingProviderSetup(){ const baseUrl=(ONBOARDING.form.baseUrl||'').trim(); const current=_getOnboardingCurrentSetup(); const isUnchanged=current.provider===provider&&((current.model||'')===model)&&((current.base_url||'')===baseUrl); - if(isUnchanged && !apiKey && (ONBOARDING.status.system||{}).chat_ready) return; + // Skip the POST when nothing changed. We also skip when the provider is + // unsupported/OAuth-based and already working — chat_ready may be false for + // providers not in the quick-setup list (e.g. minimax-cn) even though they are + // fully configured. Posting in that case would either be a no-op (the server + // just marks complete for unsupported providers) or could silently overwrite + // config.yaml if the user accidentally changed the provider dropdown. + const currentIsOauth=!!(ONBOARDING.status&&ONBOARDING.status.setup&&ONBOARDING.status.setup.current_is_oauth); + if(isUnchanged && !apiKey && ((ONBOARDING.status.system||{}).chat_ready || currentIsOauth)) return; const body={provider,model}; if(apiKey) body.api_key=apiKey; if(baseUrl) body.base_url=baseUrl; diff --git a/tests/test_issue572.py b/tests/test_issue572.py new file mode 100644 index 0000000..137ab8a --- /dev/null +++ b/tests/test_issue572.py @@ -0,0 +1,205 @@ +"""Tests for issue #572: onboarding must not fire or overwrite config for +providers not in the quick-setup list (minimax-cn, deepseek, xai, etc.). + +Root cause: _provider_api_key_present() only knew about the four providers in +_SUPPORTED_PROVIDER_SETUPS. For any other provider it returned False, causing +chat_ready=False, which made the wizard fire even when the user was fully +configured. The second part of the fix ensures _saveOnboardingProviderSetup() +in the frontend also skips the POST when current_is_oauth is set. + +Covers: + 1. _provider_api_key_present returns True for minimax-cn when + MINIMAX_CN_API_KEY is in env (via hermes_cli.auth.get_auth_status) + 2. _status_from_runtime gives chat_ready=True for minimax-cn with a key set + 3. get_onboarding_status returns completed=True for a fully-configured + unsupported provider when config.yaml exists + 4. The hermes_cli import failure path is safe (falls back gracefully) +""" +from __future__ import annotations + +import os +import pathlib +import sys +import types +from unittest import mock + +import pytest + + +def _inject_hermes_cli_auth(get_auth_status_return): + """Inject a minimal hermes_cli.auth stub into sys.modules. + + CI doesn't install hermes_cli (it's a separate package). Tests that + exercise the hermes_cli fallback path must inject the module themselves + rather than relying on mock.patch('hermes_cli.auth.get_auth_status') + which fails with ModuleNotFoundError when the module isn't installed. + """ + mock_auth = types.ModuleType("hermes_cli.auth") + mock_auth.get_auth_status = mock.MagicMock(return_value=get_auth_status_return) + mock_hermes_cli = types.ModuleType("hermes_cli") + + return mock.patch.dict(sys.modules, { + "hermes_cli": mock_hermes_cli, + "hermes_cli.auth": mock_auth, + }) + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + +def _call_provider_api_key_present(provider: str, cfg: dict = None, env_values: dict = None): + from api.onboarding import _provider_api_key_present + return _provider_api_key_present(provider, cfg or {}, env_values or {}) + + +# --------------------------------------------------------------------------- +# 1. _provider_api_key_present via hermes_cli fallback +# --------------------------------------------------------------------------- + +class TestProviderApiKeyPresentFallback: + + def test_minimax_cn_logged_in_returns_true(self): + """minimax-cn: if hermes_cli.auth.get_auth_status returns logged_in, must be True.""" + with mock.patch("api.onboarding._SUPPORTED_PROVIDER_SETUPS", { + "openrouter": {}, "anthropic": {}, "openai": {}, "custom": {} + }): + with _inject_hermes_cli_auth({"logged_in": True}): + result = _call_provider_api_key_present("minimax-cn") + assert result is True + + def test_unsupported_provider_logged_out_returns_false(self): + """Unsupported provider with no key → False, no crash.""" + with mock.patch("api.onboarding._SUPPORTED_PROVIDER_SETUPS", { + "openrouter": {}, "anthropic": {}, "openai": {}, "custom": {} + }): + with _inject_hermes_cli_auth({"logged_in": False}): + result = _call_provider_api_key_present("deepseek") + assert result is False + + def test_hermes_cli_import_failure_is_safe(self): + """If hermes_cli is unavailable, falls back silently to False.""" + import builtins + real_import = builtins.__import__ + + def _block_hermes_cli(name, *args, **kwargs): + if name.startswith("hermes_cli"): + raise ImportError("hermes_cli not available") + return real_import(name, *args, **kwargs) + + with mock.patch("api.onboarding._SUPPORTED_PROVIDER_SETUPS", { + "openrouter": {}, "anthropic": {}, "openai": {}, "custom": {} + }): + with mock.patch("builtins.__import__", side_effect=_block_hermes_cli): + result = _call_provider_api_key_present("minimax-cn") + assert result is False # safe fallback + + def test_supported_provider_still_works_without_fallback(self): + """openrouter with env key must still succeed via the original path.""" + from api.onboarding import _provider_api_key_present, _SUPPORTED_PROVIDER_SETUPS + env_values = {"OPENROUTER_API_KEY": "sk-test"} + result = _provider_api_key_present("openrouter", {}, env_values) + assert result is True + + def test_inline_api_key_in_cfg_still_works(self): + """model.api_key in config.yaml must be recognized for any provider.""" + cfg = {"model": {"provider": "minimax-cn", "default": "MiniMax-M2.7", "api_key": "key123"}} + result = _call_provider_api_key_present("minimax-cn", cfg) + assert result is True + + +# --------------------------------------------------------------------------- +# 2. _status_from_runtime: unsupported provider with key → chat_ready=True +# --------------------------------------------------------------------------- + +class TestStatusFromRuntimeUnsupportedProvider: + + def _run(self, provider: str, model: str, api_key_present: bool, oauth_present: bool = False): + from api.onboarding import _status_from_runtime + cfg = {"model": {"provider": provider, "default": model}} + with ( + mock.patch("api.onboarding._HERMES_FOUND", True), + mock.patch("api.onboarding._load_env_file", return_value={}), + mock.patch("api.onboarding._get_active_hermes_home", return_value=pathlib.Path("/tmp")), + mock.patch("api.onboarding._provider_api_key_present", return_value=api_key_present), + mock.patch("api.onboarding._provider_oauth_authenticated", return_value=oauth_present), + ): + return _status_from_runtime(cfg, True) + + def test_minimax_cn_with_key_gives_chat_ready(self): + """minimax-cn + api key present → chat_ready must be True.""" + result = self._run("minimax-cn", "MiniMax-M2.7", api_key_present=True) + assert result["chat_ready"] is True, f"Expected chat_ready=True, got: {result}" + assert result["provider_ready"] is True + assert result["setup_state"] == "ready" + + def test_deepseek_with_key_gives_chat_ready(self): + """deepseek + api key → chat_ready.""" + result = self._run("deepseek", "deepseek-chat", api_key_present=True) + assert result["chat_ready"] is True + + def test_unsupported_provider_no_key_no_oauth_gives_not_ready(self): + """No key, no oauth → provider_ready=False.""" + result = self._run("minimax-cn", "MiniMax-M2.7", api_key_present=False, oauth_present=False) + assert result["chat_ready"] is False + assert result["provider_ready"] is False + + def test_oauth_provider_still_works_via_oauth_path(self): + """openai-codex (OAuth) with no api_key but oauth present → ready.""" + result = self._run("openai-codex", "codex-model", api_key_present=False, oauth_present=True) + assert result["chat_ready"] is True + + +# --------------------------------------------------------------------------- +# 3. get_onboarding_status: minimax-cn fully configured → completed=True +# --------------------------------------------------------------------------- + +class TestOnboardingStatusUnsupportedProvider: + + def _make_status(self, chat_ready: bool, provider: str = "minimax-cn"): + import api.onboarding as mod + fake_config_path = pathlib.Path("/tmp/_test_572_config.yaml") + cfg = {"model": {"provider": provider, "default": "MiniMax-M2.7"}} + runtime = { + "chat_ready": chat_ready, + "provider_configured": True, + "provider_ready": chat_ready, + "setup_state": "ready" if chat_ready else "provider_incomplete", + "provider_note": "test", + "current_provider": provider, + "current_model": "MiniMax-M2.7", + "current_base_url": None, + "env_path": "/tmp/.env", + } + with ( + mock.patch.object(mod, "load_settings", return_value={}), + mock.patch.object(mod, "get_config", return_value=cfg), + mock.patch.object(mod, "verify_hermes_imports", return_value=(True, [], {})), + mock.patch.object(mod, "_status_from_runtime", return_value=runtime), + mock.patch.object(mod, "load_workspaces", return_value=[]), + mock.patch.object(mod, "get_last_workspace", return_value=None), + mock.patch.object(mod, "get_available_models", return_value=[]), + mock.patch.object(mod, "_get_config_path", return_value=fake_config_path), + mock.patch.object(pathlib.Path, "exists", return_value=True), + ): + return mod.get_onboarding_status() + + def test_minimax_cn_chat_ready_skips_wizard(self): + """minimax-cn + chat_ready=True + config.yaml exists → wizard must NOT fire.""" + result = self._make_status(chat_ready=True) + assert result["completed"] is True, ( + "Wizard fired for minimax-cn user with valid config! " + "config.yaml + chat_ready=True must auto-complete onboarding regardless of provider." + ) + + def test_minimax_cn_not_ready_shows_wizard(self): + """minimax-cn + chat_ready=False → wizard fires so user can fix it.""" + result = self._make_status(chat_ready=False) + assert result["completed"] is False + + def test_current_is_oauth_set_for_unsupported_provider(self): + """setup.current_is_oauth must be True for minimax-cn (not in quick-setup list).""" + result = self._make_status(chat_ready=True) + assert result["setup"]["current_is_oauth"] is True, ( + "current_is_oauth should be True for providers not in _SUPPORTED_PROVIDER_SETUPS" + )