diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index f73506371..3a80780f3 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -116,7 +116,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { name="GitHub Copilot", auth_type="api_key", inference_base_url=DEFAULT_GITHUB_MODELS_BASE_URL, - api_key_env_vars=("GITHUB_TOKEN", "GH_TOKEN"), + api_key_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"), ), "copilot-acp": ProviderConfig( id="copilot-acp", @@ -282,16 +282,24 @@ def _resolve_api_key_provider_secret( provider_id: str, pconfig: ProviderConfig ) -> tuple[str, str]: """Resolve an API-key provider's token and indicate where it came from.""" + if provider_id == "copilot": + # Use the dedicated copilot auth module for proper token validation + try: + from hermes_cli.copilot_auth import resolve_copilot_token + token, source = resolve_copilot_token() + if token: + return token, source + except ValueError as exc: + logger.warning("Copilot token validation failed: %s", exc) + except Exception: + pass + return "", "" + for env_var in pconfig.api_key_env_vars: val = os.getenv(env_var, "").strip() if val: return val, env_var - if provider_id == "copilot": - token = _try_gh_cli_token() - if token: - return token, "gh auth token" - return "", "" diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py new file mode 100644 index 000000000..d0b7adea1 --- /dev/null +++ b/hermes_cli/copilot_auth.py @@ -0,0 +1,295 @@ +"""GitHub Copilot authentication utilities. + +Implements the OAuth device code flow used by the Copilot CLI and handles +token validation/exchange for the Copilot API. + +Token type support (per GitHub docs): + gho_ OAuth token ✓ (default via copilot login) + github_pat_ Fine-grained PAT ✓ (needs Copilot Requests permission) + ghu_ GitHub App token ✓ (via environment variable) + ghp_ Classic PAT ✗ NOT SUPPORTED + +Credential search order (matching Copilot CLI behaviour): + 1. COPILOT_GITHUB_TOKEN env var + 2. GH_TOKEN env var + 3. GITHUB_TOKEN env var + 4. gh auth token CLI fallback +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import shutil +import subprocess +import time +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +# OAuth device code flow constants (same client ID as opencode/Copilot CLI) +COPILOT_OAUTH_CLIENT_ID = "Ov23li8tweQw6odWQebz" +COPILOT_DEVICE_CODE_URL = "https://github.com/login/device/code" +COPILOT_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" + +# Copilot API constants +COPILOT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token" +COPILOT_API_BASE_URL = "https://api.githubcopilot.com" + +# Token type prefixes +_CLASSIC_PAT_PREFIX = "ghp_" +_SUPPORTED_PREFIXES = ("gho_", "github_pat_", "ghu_") + +# Env var search order (matches Copilot CLI) +COPILOT_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN") + +# Polling constants +_DEVICE_CODE_POLL_INTERVAL = 5 # seconds +_DEVICE_CODE_POLL_SAFETY_MARGIN = 3 # seconds + + +def is_classic_pat(token: str) -> bool: + """Check if a token is a classic PAT (ghp_*), which Copilot doesn't support.""" + return token.strip().startswith(_CLASSIC_PAT_PREFIX) + + +def validate_copilot_token(token: str) -> tuple[bool, str]: + """Validate that a token is usable with the Copilot API. + + Returns (valid, message). + """ + token = token.strip() + if not token: + return False, "Empty token" + + if token.startswith(_CLASSIC_PAT_PREFIX): + return False, ( + "Classic Personal Access Tokens (ghp_*) are not supported by the " + "Copilot API. Use one of:\n" + " → `copilot login` or `hermes model` to authenticate via OAuth\n" + " → A fine-grained PAT (github_pat_*) with Copilot Requests permission\n" + " → `gh auth login` with the default device code flow (produces gho_* tokens)" + ) + + return True, "OK" + + +def resolve_copilot_token() -> tuple[str, str]: + """Resolve a GitHub token suitable for Copilot API use. + + Returns (token, source) where source describes where the token came from. + Raises ValueError if only a classic PAT is available. + """ + # 1. Check env vars in priority order + for env_var in COPILOT_ENV_VARS: + val = os.getenv(env_var, "").strip() + if val: + valid, msg = validate_copilot_token(val) + if not valid: + logger.warning( + "Token from %s is not supported: %s", env_var, msg + ) + continue + return val, env_var + + # 2. Fall back to gh auth token + token = _try_gh_cli_token() + if token: + valid, msg = validate_copilot_token(token) + if not valid: + raise ValueError( + f"Token from `gh auth token` is a classic PAT (ghp_*). {msg}" + ) + return token, "gh auth token" + + return "", "" + + +def _gh_cli_candidates() -> list[str]: + """Return candidate ``gh`` binary paths, including common Homebrew installs.""" + candidates: list[str] = [] + + resolved = shutil.which("gh") + if resolved: + candidates.append(resolved) + + for candidate in ( + "/opt/homebrew/bin/gh", + "/usr/local/bin/gh", + str(Path.home() / ".local" / "bin" / "gh"), + ): + if candidate in candidates: + continue + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + candidates.append(candidate) + + return candidates + + +def _try_gh_cli_token() -> Optional[str]: + """Return a token from ``gh auth token`` when the GitHub CLI is available.""" + for gh_path in _gh_cli_candidates(): + try: + result = subprocess.run( + [gh_path, "auth", "token"], + capture_output=True, + text=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc) + continue + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + return None + + +# ─── OAuth Device Code Flow ──────────────────────────────────────────────── + +def copilot_device_code_login( + *, + host: str = "github.com", + timeout_seconds: float = 300, +) -> Optional[str]: + """Run the GitHub OAuth device code flow for Copilot. + + Prints instructions for the user, polls for completion, and returns + the OAuth access token on success, or None on failure/cancellation. + + This replicates the flow used by opencode and the Copilot CLI. + """ + import urllib.request + import urllib.parse + + domain = host.rstrip("/") + device_code_url = f"https://{domain}/login/device/code" + access_token_url = f"https://{domain}/login/oauth/access_token" + + # Step 1: Request device code + data = urllib.parse.urlencode({ + "client_id": COPILOT_OAUTH_CLIENT_ID, + "scope": "read:user", + }).encode() + + req = urllib.request.Request( + device_code_url, + data=data, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "HermesAgent/1.0", + }, + ) + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + device_data = json.loads(resp.read().decode()) + except Exception as exc: + logger.error("Failed to initiate device authorization: %s", exc) + print(f" ✗ Failed to start device authorization: {exc}") + return None + + verification_uri = device_data.get("verification_uri", "https://github.com/login/device") + user_code = device_data.get("user_code", "") + device_code = device_data.get("device_code", "") + interval = max(device_data.get("interval", _DEVICE_CODE_POLL_INTERVAL), 1) + + if not device_code or not user_code: + print(" ✗ GitHub did not return a device code.") + return None + + # Step 2: Show instructions + print() + print(f" Open this URL in your browser: {verification_uri}") + print(f" Enter this code: {user_code}") + print() + print(" Waiting for authorization...", end="", flush=True) + + # Step 3: Poll for completion + deadline = time.time() + timeout_seconds + + while time.time() < deadline: + time.sleep(interval + _DEVICE_CODE_POLL_SAFETY_MARGIN) + + poll_data = urllib.parse.urlencode({ + "client_id": COPILOT_OAUTH_CLIENT_ID, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }).encode() + + poll_req = urllib.request.Request( + access_token_url, + data=poll_data, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "HermesAgent/1.0", + }, + ) + + try: + with urllib.request.urlopen(poll_req, timeout=10) as resp: + result = json.loads(resp.read().decode()) + except Exception: + print(".", end="", flush=True) + continue + + if result.get("access_token"): + print(" ✓") + return result["access_token"] + + error = result.get("error", "") + if error == "authorization_pending": + print(".", end="", flush=True) + continue + elif error == "slow_down": + # RFC 8628: add 5 seconds to polling interval + server_interval = result.get("interval") + if isinstance(server_interval, (int, float)) and server_interval > 0: + interval = int(server_interval) + else: + interval += 5 + print(".", end="", flush=True) + continue + elif error == "expired_token": + print() + print(" ✗ Device code expired. Please try again.") + return None + elif error == "access_denied": + print() + print(" ✗ Authorization was denied.") + return None + elif error: + print() + print(f" ✗ Authorization failed: {error}") + return None + + print() + print(" ✗ Timed out waiting for authorization.") + return None + + +# ─── Copilot API Headers ─────────────────────────────────────────────────── + +def copilot_request_headers( + *, + is_agent_turn: bool = True, + is_vision: bool = False, +) -> dict[str, str]: + """Build the standard headers for Copilot API requests. + + Replicates the header set used by opencode and the Copilot CLI. + """ + headers: dict[str, str] = { + "Editor-Version": "vscode/1.104.1", + "User-Agent": "HermesAgent/1.0", + "Openai-Intent": "conversation-edits", + "x-initiator": "agent" if is_agent_turn else "user", + } + if is_vision: + headers["Copilot-Vision-Request"] = "true" + + return headers diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a578c4d7d..f493b8c78 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1579,7 +1579,7 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""): def _model_flow_copilot(config, current_model=""): - """GitHub Copilot flow using env vars or ``gh auth token``.""" + """GitHub Copilot flow using env vars, gh CLI, or OAuth device code.""" from hermes_cli.auth import ( PROVIDER_REGISTRY, _prompt_model_selection, @@ -1605,18 +1605,63 @@ def _model_flow_copilot(config, current_model=""): if not api_key: print("No GitHub token configured for GitHub Copilot.") - print(" Hermes can use GITHUB_TOKEN, GH_TOKEN, or your gh CLI login.") + print() + print(" Supported token types:") + print(" → OAuth token (gho_*) via `copilot login` or device code flow") + print(" → Fine-grained PAT (github_pat_*) with Copilot Requests permission") + print(" → GitHub App token (ghu_*) via environment variable") + print(" ✗ Classic PAT (ghp_*) NOT supported by Copilot API") + print() + print(" Options:") + print(" 1. Login with GitHub (OAuth device code flow)") + print(" 2. Enter a token manually") + print(" 3. Cancel") + print() try: - new_key = input("GITHUB_TOKEN (or Enter to cancel): ").strip() + choice = input(" Choice [1-3]: ").strip() except (KeyboardInterrupt, EOFError): print() return - if not new_key: - print("Cancelled.") + + if choice == "1": + try: + from hermes_cli.copilot_auth import copilot_device_code_login + token = copilot_device_code_login() + if token: + save_env_value("COPILOT_GITHUB_TOKEN", token) + print(" Copilot token saved.") + print() + else: + print(" Login cancelled or failed.") + return + except Exception as exc: + print(f" Login failed: {exc}") + return + elif choice == "2": + try: + new_key = input(" Token (COPILOT_GITHUB_TOKEN): ").strip() + except (KeyboardInterrupt, EOFError): + print() + return + if not new_key: + print(" Cancelled.") + return + # Validate token type + try: + from hermes_cli.copilot_auth import validate_copilot_token + valid, msg = validate_copilot_token(new_key) + if not valid: + print(f" ✗ {msg}") + return + except ImportError: + pass + save_env_value("COPILOT_GITHUB_TOKEN", new_key) + print(" Token saved.") + print() + else: + print(" Cancelled.") return - save_env_value("GITHUB_TOKEN", new_key) - print("GitHub token saved.") - print() + creds = resolve_api_key_provider_credentials(provider_id) api_key = creds.get("api_key", "") source = creds.get("source", "") diff --git a/hermes_cli/models.py b/hermes_cli/models.py index e6f4bc5d5..77acb99ab 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -617,10 +617,21 @@ def _extract_model_ids(payload: Any) -> list[str]: def copilot_default_headers() -> dict[str, str]: - return { - "Editor-Version": COPILOT_EDITOR_VERSION, - "User-Agent": "HermesAgent/1.0", - } + """Standard headers for Copilot API requests. + + Includes Openai-Intent and x-initiator headers that opencode and the + Copilot CLI send on every request. + """ + try: + from hermes_cli.copilot_auth import copilot_request_headers + return copilot_request_headers(is_agent_turn=True) + except ImportError: + return { + "Editor-Version": COPILOT_EDITOR_VERSION, + "User-Agent": "HermesAgent/1.0", + "Openai-Intent": "conversation-edits", + "x-initiator": "agent", + } def _copilot_catalog_item_is_text_model(item: dict[str, Any]) -> bool: diff --git a/tests/hermes_cli/test_copilot_auth.py b/tests/hermes_cli/test_copilot_auth.py new file mode 100644 index 000000000..b28afeddc --- /dev/null +++ b/tests/hermes_cli/test_copilot_auth.py @@ -0,0 +1,178 @@ +"""Tests for hermes_cli.copilot_auth — Copilot token validation and resolution.""" + +import os +import pytest +from unittest.mock import patch, MagicMock + + +class TestTokenValidation: + """Token type validation.""" + + def test_classic_pat_rejected(self): + from hermes_cli.copilot_auth import validate_copilot_token + valid, msg = validate_copilot_token("ghp_abcdefghijklmnop1234") + assert valid is False + assert "Classic Personal Access Tokens" in msg + assert "ghp_" in msg + + def test_oauth_token_accepted(self): + from hermes_cli.copilot_auth import validate_copilot_token + valid, msg = validate_copilot_token("gho_abcdefghijklmnop1234") + assert valid is True + + def test_fine_grained_pat_accepted(self): + from hermes_cli.copilot_auth import validate_copilot_token + valid, msg = validate_copilot_token("github_pat_abcdefghijklmnop1234") + assert valid is True + + def test_github_app_token_accepted(self): + from hermes_cli.copilot_auth import validate_copilot_token + valid, msg = validate_copilot_token("ghu_abcdefghijklmnop1234") + assert valid is True + + def test_empty_token_rejected(self): + from hermes_cli.copilot_auth import validate_copilot_token + valid, msg = validate_copilot_token("") + assert valid is False + + def test_is_classic_pat(self): + from hermes_cli.copilot_auth import is_classic_pat + assert is_classic_pat("ghp_abc123") is True + assert is_classic_pat("gho_abc123") is False + assert is_classic_pat("github_pat_abc") is False + assert is_classic_pat("") is False + + +class TestResolveToken: + """Token resolution with env var priority.""" + + def test_copilot_github_token_first_priority(self, monkeypatch): + from hermes_cli.copilot_auth import resolve_copilot_token + monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "gho_copilot_first") + monkeypatch.setenv("GH_TOKEN", "gho_gh_second") + monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third") + token, source = resolve_copilot_token() + assert token == "gho_copilot_first" + assert source == "COPILOT_GITHUB_TOKEN" + + def test_gh_token_second_priority(self, monkeypatch): + from hermes_cli.copilot_auth import resolve_copilot_token + monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False) + monkeypatch.setenv("GH_TOKEN", "gho_gh_second") + monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third") + token, source = resolve_copilot_token() + assert token == "gho_gh_second" + assert source == "GH_TOKEN" + + def test_github_token_third_priority(self, monkeypatch): + from hermes_cli.copilot_auth import resolve_copilot_token + monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third") + token, source = resolve_copilot_token() + assert token == "gho_github_third" + assert source == "GITHUB_TOKEN" + + def test_classic_pat_in_env_skipped(self, monkeypatch): + """Classic PATs in env vars should be skipped, not returned.""" + from hermes_cli.copilot_auth import resolve_copilot_token + monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_classic_pat_nope") + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.setenv("GITHUB_TOKEN", "gho_valid_oauth") + token, source = resolve_copilot_token() + # Should skip the ghp_ token and find the gho_ one + assert token == "gho_valid_oauth" + assert source == "GITHUB_TOKEN" + + def test_gh_cli_fallback(self, monkeypatch): + from hermes_cli.copilot_auth import resolve_copilot_token + monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="gho_from_cli"): + token, source = resolve_copilot_token() + assert token == "gho_from_cli" + assert source == "gh auth token" + + def test_gh_cli_classic_pat_raises(self, monkeypatch): + from hermes_cli.copilot_auth import resolve_copilot_token + monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="ghp_classic"): + with pytest.raises(ValueError, match="classic PAT"): + resolve_copilot_token() + + def test_no_token_returns_empty(self, monkeypatch): + from hermes_cli.copilot_auth import resolve_copilot_token + monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value=None): + token, source = resolve_copilot_token() + assert token == "" + assert source == "" + + +class TestRequestHeaders: + """Copilot API header generation.""" + + def test_default_headers_include_openai_intent(self): + from hermes_cli.copilot_auth import copilot_request_headers + headers = copilot_request_headers() + assert headers["Openai-Intent"] == "conversation-edits" + assert headers["User-Agent"] == "HermesAgent/1.0" + assert "Editor-Version" in headers + + def test_agent_turn_sets_initiator(self): + from hermes_cli.copilot_auth import copilot_request_headers + headers = copilot_request_headers(is_agent_turn=True) + assert headers["x-initiator"] == "agent" + + def test_user_turn_sets_initiator(self): + from hermes_cli.copilot_auth import copilot_request_headers + headers = copilot_request_headers(is_agent_turn=False) + assert headers["x-initiator"] == "user" + + def test_vision_header(self): + from hermes_cli.copilot_auth import copilot_request_headers + headers = copilot_request_headers(is_vision=True) + assert headers["Copilot-Vision-Request"] == "true" + + def test_no_vision_header_by_default(self): + from hermes_cli.copilot_auth import copilot_request_headers + headers = copilot_request_headers() + assert "Copilot-Vision-Request" not in headers + + +class TestCopilotDefaultHeaders: + """The models.py copilot_default_headers uses copilot_auth.""" + + def test_includes_openai_intent(self): + from hermes_cli.models import copilot_default_headers + headers = copilot_default_headers() + assert "Openai-Intent" in headers + assert headers["Openai-Intent"] == "conversation-edits" + + def test_includes_x_initiator(self): + from hermes_cli.models import copilot_default_headers + headers = copilot_default_headers() + assert "x-initiator" in headers + + +class TestEnvVarOrder: + """PROVIDER_REGISTRY has correct env var order.""" + + def test_copilot_env_vars_include_copilot_github_token(self): + from hermes_cli.auth import PROVIDER_REGISTRY + copilot = PROVIDER_REGISTRY["copilot"] + assert "COPILOT_GITHUB_TOKEN" in copilot.api_key_env_vars + # COPILOT_GITHUB_TOKEN should be first + assert copilot.api_key_env_vars[0] == "COPILOT_GITHUB_TOKEN" + + def test_copilot_env_vars_order_matches_docs(self): + from hermes_cli.auth import PROVIDER_REGISTRY + copilot = PROVIDER_REGISTRY["copilot"] + assert copilot.api_key_env_vars == ( + "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN" + ) diff --git a/tests/test_api_key_providers.py b/tests/test_api_key_providers.py index 631a7051c..1bb91eefe 100644 --- a/tests/test_api_key_providers.py +++ b/tests/test_api_key_providers.py @@ -59,7 +59,7 @@ class TestProviderRegistry: def test_copilot_env_vars(self): pconfig = PROVIDER_REGISTRY["copilot"] - assert pconfig.api_key_env_vars == ("GITHUB_TOKEN", "GH_TOKEN") + assert pconfig.api_key_env_vars == ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN") assert pconfig.base_url_env_var == "" def test_kimi_env_vars(self): @@ -279,7 +279,7 @@ class TestApiKeyProviderStatus: assert status["base_url"] == "https://custom.kimi.example/v1" def test_copilot_status_uses_gh_cli_token(self, monkeypatch): - monkeypatch.setattr("hermes_cli.auth._try_gh_cli_token", lambda: "gh-cli-token") + monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_gh_cli_token") status = get_api_key_provider_status("copilot") assert status["configured"] is True assert status["logged_in"] is True @@ -341,10 +341,10 @@ class TestResolveApiKeyProviderCredentials: assert creds["source"] == "GITHUB_TOKEN" def test_resolve_copilot_with_gh_cli_fallback(self, monkeypatch): - monkeypatch.setattr("hermes_cli.auth._try_gh_cli_token", lambda: "gh-cli-secret") + monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret") creds = resolve_api_key_provider_credentials("copilot") assert creds["provider"] == "copilot" - assert creds["api_key"] == "gh-cli-secret" + assert creds["api_key"] == "gho_cli_secret" assert creds["base_url"] == "https://api.githubcopilot.com" assert creds["source"] == "gh auth token" @@ -515,16 +515,16 @@ class TestRuntimeProviderResolution: assert result["api_key"] == "auto-kimi-key" def test_runtime_copilot_uses_gh_cli_token(self, monkeypatch): - monkeypatch.setattr("hermes_cli.auth._try_gh_cli_token", lambda: "gh-cli-secret") + monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret") from hermes_cli.runtime_provider import resolve_runtime_provider result = resolve_runtime_provider(requested="copilot") assert result["provider"] == "copilot" assert result["api_mode"] == "chat_completions" - assert result["api_key"] == "gh-cli-secret" + assert result["api_key"] == "gho_cli_secret" assert result["base_url"] == "https://api.githubcopilot.com" def test_runtime_copilot_uses_responses_for_gpt_5_4(self, monkeypatch): - monkeypatch.setattr("hermes_cli.auth._try_gh_cli_token", lambda: "gh-cli-secret") + monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret") monkeypatch.setattr( "hermes_cli.runtime_provider._get_model_config", lambda: {"provider": "copilot", "default": "gpt-5.4"}, @@ -590,7 +590,7 @@ class TestHasAnyProviderConfigured: def test_gh_cli_token_counts(self, monkeypatch, tmp_path): from hermes_cli import config as config_module - monkeypatch.setattr("hermes_cli.auth._try_gh_cli_token", lambda: "gh-cli-secret") + monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret") hermes_home = tmp_path / ".hermes" hermes_home.mkdir() monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")