From 0d0ad3f9d908e453007c9614f7caa51432dee52b Mon Sep 17 00:00:00 2001 From: Fangliquan Date: Sat, 25 Jul 2026 20:57:33 +0800 Subject: [PATCH] fix(hermes_cli): lock dashboard xAI active_provider contracts and setup unsuppress Cover preserve-vs-mark-if-unset for dashboard OAuth, assert TTS setup clears device_code suppression, and clarify set_active docstring callers. --- hermes_cli/auth.py | 5 +- tests/hermes_cli/test_setup_tts_xai_oauth.py | 8 ++- tests/hermes_cli/test_web_oauth_dispatch.py | 73 ++++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index fa1113d512f..81bf0e897bd 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -4458,8 +4458,9 @@ def _save_xai_oauth_tokens( When *set_active* is True (default), also promote ``xai-oauth`` to ``active_provider`` — appropriate for intentional model/auth login. - Pass ``set_active=False`` for side-tool credential bootstrap (TTS, STT, - dashboard token save, token refresh) so inference routing is unchanged. + Pass ``set_active=False`` for side-tool credential bootstrap (TTS/setup, + tools config, dashboard token save, token refresh) so inference routing + is unchanged. """ if last_refresh is None: last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/tests/hermes_cli/test_setup_tts_xai_oauth.py b/tests/hermes_cli/test_setup_tts_xai_oauth.py index d8416b83194..2b327beecf4 100644 --- a/tests/hermes_cli/test_setup_tts_xai_oauth.py +++ b/tests/hermes_cli/test_setup_tts_xai_oauth.py @@ -1,4 +1,4 @@ -"""Regression: TTS/setup xAI OAuth must not hijack the active chat provider.""" +"""Regression: TTS/setup xAI OAuth must not hijack the active chat provider.""" import json @@ -62,10 +62,16 @@ def test_run_xai_oauth_login_from_setup_does_not_hijack_active_provider( ) monkeypatch.setattr("hermes_cli.auth._is_remote_session", lambda: True) + from hermes_cli.auth import is_source_suppressed, suppress_credential_source from hermes_cli.setup import _run_xai_oauth_login_from_setup + suppress_credential_source("xai-oauth", "device_code") + assert is_source_suppressed("xai-oauth", "device_code") is True + assert _run_xai_oauth_login_from_setup() is True + assert is_source_suppressed("xai-oauth", "device_code") is False + auth = json.loads(auth_path.read_text(encoding="utf-8")) assert auth["active_provider"] == "openrouter" xai_state = auth["providers"]["xai-oauth"] diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 46d770410fd..a8ec8c669c9 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -20,6 +20,7 @@ The fix: These tests pin the corrected behavior. """ import asyncio +import json import time from datetime import datetime, timezone from unittest.mock import patch @@ -665,6 +666,19 @@ def test_xai_dashboard_poller_seeds_single_entry_and_clears_suppression(tmp_path monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False) monkeypatch.delenv("XAI_BASE_URL", raising=False) + # Existing chat provider must not be overwritten by dashboard OAuth. + auth_path = tmp_path / "auth.json" + auth_path.write_text( + json.dumps( + { + "version": 1, + "active_provider": "openrouter", + "providers": {}, + } + ), + encoding="utf-8", + ) + # Prior `hermes auth remove xai-oauth` left the source suppressed. auth_mod.suppress_credential_source("xai-oauth", "device_code") assert auth_mod.is_source_suppressed("xai-oauth", "device_code") is True @@ -707,6 +721,10 @@ def test_xai_dashboard_poller_seeds_single_entry_and_clears_suppression(tmp_path # The interactive dashboard login cleared the suppression marker. assert auth_mod.is_source_suppressed("xai-oauth", "device_code") is False + after = json.loads(auth_path.read_text(encoding="utf-8")) + assert after["active_provider"] == "openrouter" + assert after["providers"]["xai-oauth"]["tokens"]["access_token"] == "xai-dashboard-access" + # The credential pool has exactly one entry, seeded from the # singleton as ``device_code`` — no parallel ``manual:dashboard_*`` # duplicate sharing the single-use refresh token. @@ -719,6 +737,61 @@ def test_xai_dashboard_poller_seeds_single_entry_and_clears_suppression(tmp_path ) +def test_xai_dashboard_poller_marks_active_when_unset(tmp_path, monkeypatch): + """First dashboard xAI login may set active_provider when none is set yet.""" + from hermes_cli import auth as auth_mod + from hermes_cli import web_server as ws + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False) + monkeypatch.delenv("XAI_BASE_URL", raising=False) + + auth_path = tmp_path / "auth.json" + auth_path.write_text( + json.dumps({"version": 1, "providers": {}}), + encoding="utf-8", + ) + + monkeypatch.setattr( + auth_mod, + "_xai_oauth_discovery", + lambda *a, **k: {"token_endpoint": "https://auth.x.ai/token"}, + ) + monkeypatch.setattr( + auth_mod, + "_xai_oauth_poll_device_token", + lambda client, **kwargs: { + "access_token": "xai-dashboard-first", + "refresh_token": "rt-first", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + }, + ) + + session_id = "xai-dashboard-first-active-test" + ws._oauth_sessions[session_id] = { + "session_id": session_id, + "provider": "xai-oauth", + "flow": "device_code", + "created_at": time.time(), + "status": "pending", + "error_message": None, + "device_code": "device-code", + "interval": 5, + "expires_at": time.time() + 600, + } + try: + ws._xai_device_poller(session_id) + assert ws._oauth_sessions[session_id]["status"] == "approved" + finally: + ws._oauth_sessions.pop(session_id, None) + + after = json.loads(auth_path.read_text(encoding="utf-8")) + assert after["active_provider"] == "xai-oauth" + assert after["providers"]["xai-oauth"]["tokens"]["access_token"] == "xai-dashboard-first" + + def test_unknown_pkce_provider_rejected_cleanly(): """A future PKCE provider without an explicit branch must NOT silently route to Anthropic.