From e052c747275a5fb399078f754dc4b3d2ba370cd8 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 19:22:31 -0700 Subject: [PATCH] fix: refresh Anthropic OAuth before stale env tokens --- agent/anthropic_adapter.py | 59 +++++++++++++++++++++++++------ run_agent.py | 40 +++++++++++++++++---- tests/test_anthropic_adapter.py | 48 ++++++++++++++++++++++++++ tests/test_run_agent.py | 61 +++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 17 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index ae47422cf..39efa219c 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -236,6 +236,43 @@ def _write_claude_code_credentials(access_token: str, refresh_token: str, expire logger.debug("Failed to write refreshed credentials: %s", e) +def _resolve_claude_code_token_from_credentials(creds: Optional[Dict[str, Any]] = None) -> Optional[str]: + """Resolve a token from Claude Code credential files, refreshing if needed.""" + creds = creds or read_claude_code_credentials() + if creds and is_claude_code_token_valid(creds): + logger.debug("Using Claude Code credentials (auto-detected)") + return creds["accessToken"] + if creds: + logger.debug("Claude Code credentials expired — attempting refresh") + refreshed = _refresh_oauth_token(creds) + if refreshed: + return refreshed + logger.debug("Token refresh failed — re-run 'claude setup-token' to reauthenticate") + return None + + +def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[str, Any]]) -> Optional[str]: + """Prefer Claude Code creds when a persisted env OAuth token would shadow refresh. + + Hermes historically persisted setup tokens into ANTHROPIC_TOKEN. That makes + later refresh impossible because the static env token wins before we ever + inspect Claude Code's refreshable credential file. If we have a refreshable + Claude Code credential record, prefer it over the static env OAuth token. + """ + if not env_token or not _is_oauth_token(env_token) or not isinstance(creds, dict): + return None + if not creds.get("refreshToken"): + return None + + resolved = _resolve_claude_code_token_from_credentials(creds) + if resolved and resolved != env_token: + logger.debug( + "Preferring Claude Code credential file over static env OAuth token so refresh can proceed" + ) + return resolved + return None + + def resolve_anthropic_token() -> Optional[str]: """Resolve an Anthropic token from all available sources. @@ -248,28 +285,28 @@ def resolve_anthropic_token() -> Optional[str]: Returns the token string or None. """ + creds = read_claude_code_credentials() + # 1. Hermes-managed OAuth/setup token env var token = os.getenv("ANTHROPIC_TOKEN", "").strip() if token: + preferred = _prefer_refreshable_claude_code_token(token, creds) + if preferred: + return preferred return token # 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens) cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip() if cc_token: + preferred = _prefer_refreshable_claude_code_token(cc_token, creds) + if preferred: + return preferred return cc_token # 3. Claude Code credential file - creds = read_claude_code_credentials() - if creds and is_claude_code_token_valid(creds): - logger.debug("Using Claude Code credentials (auto-detected)") - return creds["accessToken"] - elif creds: - # Token expired — attempt to refresh - logger.debug("Claude Code credentials expired — attempting refresh") - refreshed = _refresh_oauth_token(creds) - if refreshed: - return refreshed - logger.debug("Token refresh failed — re-run 'claude setup-token' to reauthenticate") + resolved_claude_token = _resolve_claude_code_token_from_credentials(creds) + if resolved_claude_token: + return resolved_claude_token # 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY. # This remains as a compatibility fallback for pre-migration Hermes configs. diff --git a/run_agent.py b/run_agent.py index bdf049655..002ed0553 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2613,6 +2613,38 @@ class AIAgent: return True + def _try_refresh_anthropic_client_credentials(self) -> bool: + if self.api_mode != "anthropic_messages" or not hasattr(self, "_anthropic_api_key"): + return False + + try: + from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client + + new_token = resolve_anthropic_token() + except Exception as exc: + logger.debug("Anthropic credential refresh failed: %s", exc) + return False + + if not isinstance(new_token, str) or not new_token.strip(): + return False + new_token = new_token.strip() + if new_token == self._anthropic_api_key: + return False + + try: + self._anthropic_client.close() + except Exception: + pass + + try: + self._anthropic_client = build_anthropic_client(new_token, getattr(self, "_anthropic_base_url", None)) + except Exception as exc: + logger.warning("Failed to rebuild Anthropic client after credential refresh: %s", exc) + return False + + self._anthropic_api_key = new_token + return True + def _interruptible_api_call(self, api_kwargs: dict): """ Run the API call in a background thread so the main conversation loop @@ -4822,12 +4854,8 @@ class AIAgent: and not anthropic_auth_retry_attempted ): anthropic_auth_retry_attempted = True - # Try re-reading Claude Code credentials (they may have been refreshed) - from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client, _is_oauth_token - new_token = resolve_anthropic_token() - if new_token and new_token != self._anthropic_api_key: - self._anthropic_api_key = new_token - self._anthropic_client = build_anthropic_client(new_token, getattr(self, "_anthropic_base_url", None)) + from agent.anthropic_adapter import _is_oauth_token + if self._try_refresh_anthropic_client_credentials(): print(f"{self.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...") continue # Credential refresh didn't help — show diagnostic info diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 9ede37e41..541d8e2bc 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -181,6 +181,33 @@ class TestResolveAnthropicToken: monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() == "cc-auto-token" + def test_prefers_refreshable_claude_code_credentials_over_static_anthropic_token(self, monkeypatch, tmp_path): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-static-token") + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + cred_file = tmp_path / ".claude" / ".credentials.json" + cred_file.parent.mkdir(parents=True) + cred_file.write_text(json.dumps({ + "claudeAiOauth": { + "accessToken": "cc-auto-token", + "refreshToken": "refresh-token", + "expiresAt": int(time.time() * 1000) + 3600_000, + } + })) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + + assert resolve_anthropic_token() == "cc-auto-token" + + def test_keeps_static_anthropic_token_when_only_non_refreshable_claude_key_exists(self, monkeypatch, tmp_path): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-static-token") + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + claude_json = tmp_path / ".claude.json" + claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-managed-key"})) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + + assert resolve_anthropic_token() == "sk-ant-oat01-static-token" + class TestRefreshOauthToken: def test_returns_none_without_refresh_token(self): @@ -279,6 +306,27 @@ class TestResolveWithRefresh: assert result == "refreshed-token" + def test_static_env_oauth_token_does_not_block_refreshable_claude_creds(self, monkeypatch, tmp_path): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-expired-env-token") + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + + cred_file = tmp_path / ".claude" / ".credentials.json" + cred_file.parent.mkdir(parents=True) + cred_file.write_text(json.dumps({ + "claudeAiOauth": { + "accessToken": "expired-claude-creds-token", + "refreshToken": "valid-refresh", + "expiresAt": int(time.time() * 1000) - 3600_000, + } + })) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + + with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"): + result = resolve_anthropic_token() + + assert result == "refreshed-token" + class TestRunOauthSetupToken: def test_raises_when_claude_not_installed(self, monkeypatch): diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index 59c4a052a..44a315cef 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -2085,6 +2085,67 @@ class TestAnthropicBaseUrlPassthrough: assert not passed_url or passed_url is None +class TestAnthropicCredentialRefresh: + def test_try_refresh_anthropic_client_credentials_rebuilds_client(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, + ): + old_client = MagicMock() + new_client = MagicMock() + mock_build.side_effect = [old_client, new_client] + agent = AIAgent( + api_key="sk-ant-oat01-stale-token", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent._anthropic_client = old_client + agent._anthropic_api_key = "sk-ant-oat01-stale-token" + agent._anthropic_base_url = "https://api.anthropic.com" + + with ( + patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat01-fresh-token"), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=new_client) as rebuild, + ): + assert agent._try_refresh_anthropic_client_credentials() is True + + old_client.close.assert_called_once() + rebuild.assert_called_once_with("sk-ant-oat01-fresh-token", "https://api.anthropic.com") + assert agent._anthropic_client is new_client + assert agent._anthropic_api_key == "sk-ant-oat01-fresh-token" + + def test_try_refresh_anthropic_client_credentials_returns_false_when_token_unchanged(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + ): + agent = AIAgent( + api_key="sk-ant-oat01-same-token", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + old_client = MagicMock() + agent._anthropic_client = old_client + agent._anthropic_api_key = "sk-ant-oat01-same-token" + + with ( + patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat01-same-token"), + patch("agent.anthropic_adapter.build_anthropic_client") as rebuild, + ): + assert agent._try_refresh_anthropic_client_credentials() is False + + old_client.close.assert_not_called() + rebuild.assert_not_called() + + # =================================================================== # _streaming_api_call tests # ===================================================================