diff --git a/agent/client_lifecycle.py b/agent/client_lifecycle.py index 96527aa696..93f6f11df5 100644 --- a/agent/client_lifecycle.py +++ b/agent/client_lifecycle.py @@ -872,16 +872,21 @@ class ClientLifecycleMixin: def _try_refresh_anthropic_client_credentials(self) -> bool: # Only native Anthropic rotates OAuth tokens; other anthropic_messages providers (MiniMax, Alibaba, ...) # and Azure use static keys — a refresh would pick up the ~/.claude OAuth token and break auth. - # Any other host under provider 'anthropic' (a URL-bearing alias, #28660) holds no Anthropic - # credential either: refreshing would ship ANTHROPIC_API_KEY / the OAuth token to it. Match - # the hostname, not a substring, so ``proxy.example/anthropic.com`` stays foreign. anthropic_base_url = getattr(self, "_anthropic_base_url", "") or "" if ( self.api_mode != "anthropic_messages" or not hasattr(self, "_anthropic_api_key") - or self.provider != "anthropic" - or (anthropic_base_url and not base_url_host_matches(anthropic_base_url, "anthropic.com")) + or self.provider != "anthropic" or base_url_host_matches(anthropic_base_url, "azure.com") ): return False + # Off the official hosts (a /anthropic proxy the resolver accepts, or a URL-bearing alias, + # #28660) rotate only a credential the endpoint already holds: swapping a custom key for + # ANTHROPIC_API_KEY / the OAuth token would leak it (#17829). Hostname match, not substring, + # so ``proxy.example/anthropic.com`` stays foreign. + official_host = not anthropic_base_url or any( + base_url_host_matches(anthropic_base_url, host) for host in ("anthropic.com", "claude.com")) + current_key = str(self._anthropic_api_key or "") + if not official_host and not (current_key.startswith("sk-ant-") or getattr(self, "_is_anthropic_oauth", False)): + return False try: from agent.anthropic_credentials import resolve_anthropic_token new_token = resolve_anthropic_token(model=self.model) diff --git a/hermes_cli/runtime_provider_backends.py b/hermes_cli/runtime_provider_backends.py index 01e7024079..de765274b2 100644 --- a/hermes_cli/runtime_provider_backends.py +++ b/hermes_cli/runtime_provider_backends.py @@ -118,8 +118,9 @@ def _resolve_openrouter_runtime( ) -> Dict[str, Any]: """Terminal resolver: OpenRouter, or a bare/aliased ``custom`` endpoint. base_url precedence: explicit > CUSTOM_BASE_URL > trusted ``model.base_url`` > OPENROUTER_BASE_URL > default. - OPENAI_BASE_URL is deliberately NOT consulted — config.yaml is the single source of truth for - endpoint URLs. OpenRouter contexts prefer OPENROUTER_API_KEY; custom endpoints never receive the + OPENAI_BASE_URL never picks the endpoint (config.yaml is the single source of truth for endpoint + URLs); it is read only to keep an OPENAI_API_KEY bound to another host out of the OpenRouter + fallback. OpenRouter contexts prefer OPENROUTER_API_KEY; custom endpoints never receive the OpenRouter key and only get env keys gated on their authoritative hosts.""" rp = _rp() model_cfg = rp._get_model_config() diff --git a/tests/agent/test_anthropic_third_party_oauth_guard.py b/tests/agent/test_anthropic_third_party_oauth_guard.py index 29251d02d4..19959b73b0 100644 --- a/tests/agent/test_anthropic_third_party_oauth_guard.py +++ b/tests/agent/test_anthropic_third_party_oauth_guard.py @@ -78,6 +78,7 @@ class TestOAuthFlagOnRefresh: @pytest.mark.parametrize("base_url", [ "https://llmbox.bytedance.net", "http://127.0.0.1:8080/anthropic.com", # substring spoof: the host is still foreign + "https://llmbox.bytedance.net/anthropic", # accepted proxy shape, but holds a custom key ]) def test_third_party_endpoint_skips_refresh(self, agent, base_url): """provider == 'anthropic' on a third-party endpoint must not refresh: the refresh @@ -101,6 +102,30 @@ class TestOAuthFlagOnRefresh: assert agent._anthropic_api_key == "custom-api-key" assert agent._is_anthropic_oauth is False + @pytest.mark.parametrize("base_url", [ + "https://api.claude.com", + "https://llm.corp.example/anthropic", + ]) + def test_accepted_native_proxy_keeps_rotating_anthropic_token(self, agent, base_url): + """Hosts the resolver accepts as native Anthropic already hold the Anthropic token, so + blocking the refresh would strand an expiring OAuth token (401 with no recovery).""" + old, new = "sk-ant-oat01-old-token-aaaaaaaa", "sk-ant-oat01-new-token-bbbbbbbb" + agent.api_mode = "anthropic_messages" + agent.provider = "anthropic" + agent._anthropic_api_key = old + agent._anthropic_base_url = base_url + agent._anthropic_client = MagicMock() + agent._is_anthropic_oauth = True + + with ( + patch("agent.anthropic_credentials.resolve_anthropic_token", return_value=new), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + ): + result = agent._try_refresh_anthropic_client_credentials() + + assert result is True + assert agent._anthropic_api_key == new + class TestOAuthFlagOnCredentialSwap: