diff --git a/agent/credential_pool.py b/agent/credential_pool.py index d8ca2b1720e..ea15d790905 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1884,11 +1884,16 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token token, source = resolve_copilot_token() if token: - api_token = get_copilot_api_token(token) + api_token, enterprise_base_url = get_copilot_api_token(token) source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}" if not _is_suppressed(provider, source_name): active_sources.add(source_name) pconfig = PROVIDER_REGISTRY.get(provider) + # Use enterprise base URL from token exchange if available, + # otherwise fall back to the provider's default. + effective_base_url = enterprise_base_url or ( + pconfig.inference_base_url if pconfig else "" + ) changed |= _upsert_entry( entries, provider, @@ -1897,7 +1902,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup "source": source_name, "auth_type": AUTH_TYPE_API_KEY, "access_token": api_token, - "base_url": pconfig.inference_base_url if pconfig else "", + "base_url": effective_base_url, "label": source, }, ) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index a76dfe750a0..8ed80a777a8 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -568,7 +568,8 @@ def _resolve_api_key_provider_secret( from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token token, source = resolve_copilot_token() if token: - return get_copilot_api_token(token), source + api_token, _base_url = get_copilot_api_token(token) + return api_token, source except ValueError as exc: logger.warning("Copilot token validation failed: %s", exc) except Exception: diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py index dd4643d2425..69e472c8f85 100644 --- a/hermes_cli/copilot_auth.py +++ b/hermes_cli/copilot_auth.py @@ -31,8 +31,14 @@ from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags logger = logging.getLogger(__name__) -# OAuth device code flow constants (same client ID as opencode/Copilot CLI) -COPILOT_OAUTH_CLIENT_ID = "Ov23li8tweQw6odWQebz" +# OAuth device code flow constants — VS Code's GitHub App client ID. +# The previous opencode OAuth App ID (Ov23li8tweQw6odWQebz) produces gho_* +# tokens that cannot be exchanged for Copilot API JWTs (404 on +# /copilot_internal/v2/token). VS Code's App ID produces ghu_* tokens +# that support exchange, which is required to access internal-only models +# (e.g. claude-opus-4.6-1m) and enterprise endpoints. +# Tested on Individual and Enterprise accounts. +COPILOT_OAUTH_CLIENT_ID = "Iv1.b507a08c87ecfe98" # Token type prefixes _CLASSIC_PAT_PREFIX = "ghp_" _SUPPORTED_PREFIXES = ("gho_", "github_pat_", "ghu_") @@ -282,8 +288,8 @@ def copilot_device_code_login( # ─── Copilot Token Exchange ──────────────────────────────────────────────── # Module-level cache for exchanged Copilot API tokens. -# Maps raw_token_fingerprint -> (api_token, expires_at_epoch). -_jwt_cache: dict[str, tuple[str, float]] = {} +# Maps raw_token_fingerprint -> (api_token, expires_at_epoch, base_url). +_jwt_cache: dict[str, tuple[str, float, Optional[str]]] = {} _JWT_REFRESH_MARGIN_SECONDS = 120 # refresh 2 min before expiry # Token exchange endpoint and headers (matching VS Code / Copilot CLI) @@ -298,14 +304,16 @@ def _token_fingerprint(raw_token: str) -> str: return hashlib.sha256(raw_token.encode()).hexdigest()[:16] -def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[str, float]: +def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[str, float, Optional[str]]: """Exchange a raw GitHub token for a short-lived Copilot API token. Calls ``GET https://api.github.com/copilot_internal/v2/token`` with - the raw GitHub token and returns ``(api_token, expires_at)``. + the raw GitHub token and returns ``(api_token, expires_at, base_url)``. The returned token is a semicolon-separated string (not a standard JWT) used as ``Authorization: Bearer `` for Copilot API requests. + If the token contains a ``proxy-ep`` field (enterprise accounts), the + derived API base URL is returned; otherwise ``base_url`` is None. Results are cached in-process and reused until close to expiry. Raises ``ValueError`` on failure. @@ -317,9 +325,9 @@ def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[st # Check cache first cached = _jwt_cache.get(fp) if cached: - api_token, expires_at = cached + api_token, expires_at, base_url = cached if time.time() < expires_at - _JWT_REFRESH_MARGIN_SECONDS: - return api_token, expires_at + return api_token, expires_at, base_url req = urllib.request.Request( _TOKEN_EXCHANGE_URL, @@ -346,30 +354,71 @@ def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[st # Convert expires_at to float if needed expires_at = float(expires_at) if expires_at else time.time() + 1800 - _jwt_cache[fp] = (api_token, expires_at) + # Derive enterprise base URL from proxy-ep in the token. + # The token is semicolon-separated: "tid=xxx;exp=xxx;proxy-ep=proxy.enterprise.githubcopilot.com;..." + # Replace leading "proxy." with "api." to get the API base URL. + base_url = _derive_base_url_from_proxy_ep(api_token) + + _jwt_cache[fp] = (api_token, expires_at, base_url) logger.debug( - "Copilot token exchanged, expires_at=%s", + "Copilot token exchanged, expires_at=%s, base_url=%s", expires_at, + base_url, ) - return api_token, expires_at + return api_token, expires_at, base_url -def get_copilot_api_token(raw_token: str) -> str: +def _derive_base_url_from_proxy_ep(token: str) -> Optional[str]: + """Derive the Copilot API base URL from a proxy-ep field in the token. + + The exchanged Copilot token is a semicolon-separated string like + ``tid=xxx;exp=xxx;proxy-ep=proxy.enterprise.githubcopilot.com;...``. + This extracts ``proxy-ep`` and converts it to an API base URL by + replacing the leading ``proxy.`` with ``api.``. + + Returns ``https://{api_hostname}`` or None if proxy-ep is absent. + """ + import re + m = re.search(r'(?:^|;)\s*proxy-ep=([^;\s]+)', token) + if not m: + return None + + proxy_ep = m.group(1) + # Strip scheme if present + for prefix in ("https://", "http://"): + if proxy_ep.startswith(prefix): + proxy_ep = proxy_ep[len(prefix):] + break + proxy_ep = proxy_ep.rstrip("/") + + # Replace leading "proxy." with "api." + if proxy_ep.startswith("proxy."): + api_host = "api." + proxy_ep[len("proxy."):] + else: + api_host = proxy_ep + + return f"https://{api_host}" + + +def get_copilot_api_token(raw_token: str) -> tuple[str, Optional[str]]: """Exchange a raw GitHub token for a Copilot API token, with fallback. - Convenience wrapper: returns the exchanged token on success, or the - raw token unchanged if the exchange fails (e.g. network error, unsupported + Convenience wrapper: returns ``(api_token, base_url)`` on success, or + ``(raw_token, None)`` if the exchange fails (e.g. network error, unsupported account type). This preserves existing behaviour for accounts that don't need exchange while enabling access to internal-only models for those that do. + + ``base_url`` is the enterprise API endpoint derived from the token's + ``proxy-ep`` field, or None for individual accounts. """ if not raw_token: - return raw_token + return raw_token, None try: - api_token, _ = exchange_copilot_token(raw_token) - return api_token + api_token, _, base_url = exchange_copilot_token(raw_token) + return api_token, base_url except Exception as exc: logger.debug("Copilot token exchange failed, using raw token: %s", exc) - return raw_token + return raw_token, None # ─── Copilot API Headers ─────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_copilot_token_exchange.py b/tests/hermes_cli/test_copilot_token_exchange.py index 9c6a219ab66..9ff14cf5fca 100644 --- a/tests/hermes_cli/test_copilot_token_exchange.py +++ b/tests/hermes_cli/test_copilot_token_exchange.py @@ -37,10 +37,11 @@ class TestExchangeCopilotToken: from hermes_cli.copilot_auth import exchange_copilot_token mock_urlopen.return_value = self._mock_urlopen(token="tid=abc;exp=999") - api_token, expires_at = exchange_copilot_token("gho_test123") + api_token, expires_at, base_url = exchange_copilot_token("gho_test123") assert api_token == "tid=abc;exp=999" assert isinstance(expires_at, float) + assert base_url is None # no proxy-ep in this token # Verify request was made with correct headers call_args = mock_urlopen.call_args @@ -66,12 +67,12 @@ class TestExchangeCopilotToken: # Seed cache with expired entry fp = _token_fingerprint("gho_test123") - _jwt_cache[fp] = ("old_token", time.time() - 10) + _jwt_cache[fp] = ("old_token", time.time() - 10, None) mock_urlopen.return_value = self._mock_urlopen( token="new_token", expires_at=time.time() + 1800 ) - api_token, _ = exchange_copilot_token("gho_test123") + api_token, _, _ = exchange_copilot_token("gho_test123") assert api_token == "new_token" assert mock_urlopen.call_count == 1 @@ -105,19 +106,25 @@ class TestGetCopilotApiToken: def test_returns_exchanged_token(self, mock_exchange): from hermes_cli.copilot_auth import get_copilot_api_token - mock_exchange.return_value = ("exchanged_jwt", time.time() + 1800) - assert get_copilot_api_token("gho_raw") == "exchanged_jwt" + mock_exchange.return_value = ("exchanged_jwt", time.time() + 1800, None) + api_token, base_url = get_copilot_api_token("gho_raw") + assert api_token == "exchanged_jwt" + assert base_url is None @patch("hermes_cli.copilot_auth.exchange_copilot_token", side_effect=ValueError("fail")) def test_falls_back_to_raw_token(self, mock_exchange): from hermes_cli.copilot_auth import get_copilot_api_token - assert get_copilot_api_token("gho_raw") == "gho_raw" + api_token, base_url = get_copilot_api_token("gho_raw") + assert api_token == "gho_raw" + assert base_url is None def test_empty_token_passthrough(self): from hermes_cli.copilot_auth import get_copilot_api_token - assert get_copilot_api_token("") == "" + api_token, base_url = get_copilot_api_token("") + assert api_token == "" + assert base_url is None class TestTokenFingerprint: @@ -147,7 +154,7 @@ class TestCallerIntegration: """Test that callers correctly use token exchange.""" @patch("hermes_cli.copilot_auth.resolve_copilot_token", return_value=("gho_raw", "GH_TOKEN")) - @patch("hermes_cli.copilot_auth.get_copilot_api_token", return_value="exchanged_jwt") + @patch("hermes_cli.copilot_auth.get_copilot_api_token", return_value=("exchanged_jwt", None)) def test_auth_resolve_uses_exchange(self, mock_exchange, mock_resolve): from hermes_cli.auth import _resolve_api_key_provider_secret @@ -157,3 +164,65 @@ class TestCallerIntegration: assert token == "exchanged_jwt" assert source == "GH_TOKEN" mock_exchange.assert_called_once_with("gho_raw") + + +class TestDeriveBaseUrlFromProxyEp: + """Tests for _derive_base_url_from_proxy_ep().""" + + def test_extracts_enterprise_url(self): + from hermes_cli.copilot_auth import _derive_base_url_from_proxy_ep + + token = "tid=abc;exp=999;proxy-ep=proxy.enterprise.githubcopilot.com;sku=copilot_enterprise" + assert _derive_base_url_from_proxy_ep(token) == "https://api.enterprise.githubcopilot.com" + + def test_returns_none_without_proxy_ep(self): + from hermes_cli.copilot_auth import _derive_base_url_from_proxy_ep + + token = "tid=abc;exp=999;sku=copilot_individual" + assert _derive_base_url_from_proxy_ep(token) is None + + def test_handles_https_prefix(self): + from hermes_cli.copilot_auth import _derive_base_url_from_proxy_ep + + token = "proxy-ep=https://proxy.enterprise.githubcopilot.com/" + assert _derive_base_url_from_proxy_ep(token) == "https://api.enterprise.githubcopilot.com" + + def test_no_proxy_prefix(self): + from hermes_cli.copilot_auth import _derive_base_url_from_proxy_ep + + token = "proxy-ep=custom.copilot.example.com" + assert _derive_base_url_from_proxy_ep(token) == "https://custom.copilot.example.com" + + @patch("urllib.request.urlopen") + def test_exchange_returns_enterprise_base_url(self, mock_urlopen, _clear_jwt_cache): + """exchange_copilot_token returns base_url from proxy-ep.""" + from hermes_cli.copilot_auth import exchange_copilot_token + + token_with_ep = "tid=abc;exp=999;proxy-ep=proxy.enterprise.githubcopilot.com" + expires_at = time.time() + 1800 + resp_data = json.dumps({"token": token_with_ep, "expires_at": expires_at}).encode() + mock_resp = MagicMock() + mock_resp.read.return_value = resp_data + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + + api_token, _, base_url = exchange_copilot_token("gho_test") + assert base_url == "https://api.enterprise.githubcopilot.com" + + @patch("urllib.request.urlopen") + def test_exchange_returns_none_base_url_for_individual(self, mock_urlopen, _clear_jwt_cache): + """exchange_copilot_token returns None base_url for individual accounts.""" + from hermes_cli.copilot_auth import exchange_copilot_token + + token_no_ep = "tid=abc;exp=999;sku=copilot_individual" + expires_at = time.time() + 1800 + resp_data = json.dumps({"token": token_no_ep, "expires_at": expires_at}).encode() + mock_resp = MagicMock() + mock_resp.read.return_value = resp_data + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + + api_token, _, base_url = exchange_copilot_token("gho_test") + assert base_url is None