diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 98b354715f80..fa1113d512fc 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -4452,7 +4452,15 @@ def _save_xai_oauth_tokens( redirect_uri: str = "", last_refresh: Optional[str] = None, auth_mode: str = "oauth_device_code", + set_active: bool = True, ) -> None: + """Persist xAI OAuth tokens into the auth store. + + 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. + """ if last_refresh is None: last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") with _auth_store_lock(): @@ -4470,7 +4478,9 @@ def _save_xai_oauth_tokens( state["discovery"] = discovery if redirect_uri: state["redirect_uri"] = redirect_uri - _save_provider_state(auth_store, "xai-oauth", state) + _store_provider_state( + auth_store, "xai-oauth", state, set_active=set_active + ) _save_auth_store(auth_store) if write_through_to_root: _write_through_xai_oauth_to_global_root(state) @@ -4808,6 +4818,9 @@ def _refresh_xai_oauth_tokens( redirect_uri=redirect_uri, last_refresh=refreshed["last_refresh"], auth_mode=auth_mode, + # Refresh must not flip active_provider — TTS/side tools can refresh + # xAI tokens while chat still routes through another provider. + set_active=False, ) return updated_tokens diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 0f4d413d795b..d782d89016f5 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -896,15 +896,17 @@ def _xai_oauth_logged_in_for_setup() -> bool: def _run_xai_oauth_login_from_setup() -> bool: """Run the xAI Grok OAuth device-code login from inside the setup wizard. + Saves OAuth tokens only. Does **not** switch the active inference + provider or rewrite ``model.provider`` — callers (TTS setup, tools + config) only need credentials for side tools. + Returns True on success, False on any failure (the caller falls back to whatever the user picked next, e.g. Edge TTS). """ try: from hermes_cli.auth import ( - DEFAULT_XAI_OAUTH_BASE_URL, _is_remote_session, _save_xai_oauth_tokens, - _update_config_for_provider, _xai_oauth_device_code_login, ) except Exception as exc: @@ -922,9 +924,7 @@ def _run_xai_oauth_login_from_setup() -> bool: redirect_uri=creds.get("redirect_uri", ""), last_refresh=creds.get("last_refresh"), auth_mode="oauth_device_code", - ) - _update_config_for_provider( - "xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL) + set_active=False, ) return True except Exception as exc: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8925827637ec..b98cf8ce9fb4 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -11153,6 +11153,9 @@ def _xai_device_poller(session_id: str) -> None: discovery=discovery, last_refresh=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), auth_mode="oauth_device_code", + # Dashboard OAuth only bootstraps credentials for side tools; + # do not hijack the active chat inference provider. + set_active=False, ) # The singleton write above is the single source of truth: the # credential-pool load seeds it as the canonical ``device_code`` diff --git a/tests/hermes_cli/test_auth_xai_oauth_provider.py b/tests/hermes_cli/test_auth_xai_oauth_provider.py index eae404c28c05..7210fd5e03e7 100644 --- a/tests/hermes_cli/test_auth_xai_oauth_provider.py +++ b/tests/hermes_cli/test_auth_xai_oauth_provider.py @@ -281,6 +281,40 @@ def test_save_and_read_xai_oauth_tokens_roundtrip(tmp_path, monkeypatch): assert data["discovery"]["token_endpoint"] == "https://auth.x.ai/oauth2/token" +def test_save_xai_oauth_tokens_set_active_false_preserves_active_provider( + tmp_path, monkeypatch +): + """Side-tool credential saves must not flip auth.json active_provider.""" + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + auth_path = hermes_home / "auth.json" + auth_path.write_text( + json.dumps( + { + "version": 1, + "active_provider": "openrouter", + "providers": {}, + } + ) + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + _save_xai_oauth_tokens( + { + "access_token": "side-tool-at", + "refresh_token": "side-tool-rt", + "id_token": "", + "token_type": "Bearer", + }, + discovery={"token_endpoint": "https://auth.x.ai/oauth2/token"}, + set_active=False, + ) + + raw = json.loads(auth_path.read_text()) + assert raw["active_provider"] == "openrouter" + assert raw["providers"]["xai-oauth"]["tokens"]["access_token"] == "side-tool-at" + + def test_read_xai_oauth_tokens_missing(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) diff --git a/tests/hermes_cli/test_setup_tts_xai_oauth.py b/tests/hermes_cli/test_setup_tts_xai_oauth.py new file mode 100644 index 000000000000..d8416b831944 --- /dev/null +++ b/tests/hermes_cli/test_setup_tts_xai_oauth.py @@ -0,0 +1,78 @@ +"""Regression: TTS/setup xAI OAuth must not hijack the active chat provider.""" + +import json + +import yaml + + +def test_run_xai_oauth_login_from_setup_does_not_hijack_active_provider( + tmp_path, monkeypatch +): + """TTS/setup OAuth must save tokens without switching chat inference routing. + + Regression: `_run_xai_oauth_login_from_setup` used to call + `_update_config_for_provider("xai-oauth")` (and token save flipped + `active_provider`), so `hermes setup tts` OAuth login hijacked the main + chat provider. + """ + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + auth_path = hermes_home / "auth.json" + auth_path.write_text( + json.dumps( + { + "version": 1, + "active_provider": "openrouter", + "providers": {}, + } + ), + encoding="utf-8", + ) + config_path = hermes_home / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "model": { + "provider": "openrouter", + "default": "anthropic/claude-sonnet-4", + "base_url": "https://openrouter.ai/api/v1", + }, + "tts": {"provider": "edge"}, + } + ), + encoding="utf-8", + ) + + monkeypatch.setattr( + "hermes_cli.auth._xai_oauth_device_code_login", + lambda **kwargs: { + "tokens": { + "access_token": "tts-xai-access", + "refresh_token": "tts-xai-refresh", + "id_token": "", + "token_type": "Bearer", + }, + "discovery": {"token_endpoint": "https://auth.x.ai/oauth2/token"}, + "redirect_uri": "", + "base_url": "https://api.x.ai/v1", + "last_refresh": "2026-07-25T12:00:00Z", + }, + ) + monkeypatch.setattr("hermes_cli.auth._is_remote_session", lambda: True) + + from hermes_cli.setup import _run_xai_oauth_login_from_setup + + assert _run_xai_oauth_login_from_setup() is True + + auth = json.loads(auth_path.read_text(encoding="utf-8")) + assert auth["active_provider"] == "openrouter" + xai_state = auth["providers"]["xai-oauth"] + assert xai_state["tokens"]["access_token"] == "tts-xai-access" + assert xai_state["tokens"]["refresh_token"] == "tts-xai-refresh" + + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert config["model"]["provider"] == "openrouter" + assert config["model"]["base_url"] == "https://openrouter.ai/api/v1" + assert config["model"]["default"] == "anthropic/claude-sonnet-4"