From 1dde7e2f2a1ab5be17df5046afdbfcd042955493 Mon Sep 17 00:00:00 2001 From: Chaz Dinkle Date: Sat, 27 Jun 2026 18:49:33 -0700 Subject: [PATCH] fix(anthropic): adopt Claude Code's already-refreshed token before racing refresh Claude Code OAuth refresh tokens are single-use; Claude Code refreshes on its own schedule, so by the time Hermes notices an expired token Claude Code may have already rotated it. Re-read live credential sources first and adopt a valid token rather than POSTing a possibly-stale refresh token. Ports the _refresh_oauth_token hardening from PR #40107 (chazmaniandinkle) on top of the keychain/file reconciliation from PR #21112 (nodejun). Adds AUTHOR_MAP entry for nodejun. --- agent/anthropic_adapter.py | 36 ++++++++++++- scripts/release.py | 1 + tests/agent/test_anthropic_keychain.py | 70 ++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index d83944c76d3..0dbdbfa3bfb 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1062,8 +1062,40 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False) def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]: - """Attempt to refresh an expired Claude Code OAuth token.""" - refresh_token = creds.get("refreshToken", "") + """Attempt to refresh an expired Claude Code OAuth token. + + Claude Code's OAuth refresh tokens are single-use: a successful refresh + rotates the pair and invalidates the old refresh token. Claude Code itself + also refreshes on its own schedule (IDE/CLI activity), so by the time + Hermes notices an expired token, Claude Code may have already rotated it. + POSTing our now-stale refresh token in that window races Claude Code and + fails with ``invalid_grant``. + + So before refreshing, re-read the live credential sources. If Claude Code + has already produced a valid token, adopt it and skip the POST entirely. + Only fall back to refreshing ourselves when no fresh credential is found. + """ + # Claude Code may have already refreshed — adopt its token rather than + # racing it with our (possibly already-rotated) refresh token. Only adopt + # when the live re-read produced a DIFFERENT token with a real future + # expiry: re-adopting the same credential we were just handed would be a + # no-op, and a 0/absent ``expiresAt`` means "managed key / unknown expiry" + # (see is_claude_code_token_valid) which must NOT be treated as a fresh + # refresh here. + current = read_claude_code_credentials() + if current: + current_token = current.get("accessToken", "") + current_exp = current.get("expiresAt", 0) or 0 + if ( + current_token + and current_token != creds.get("accessToken", "") + and current_exp > 0 + and is_claude_code_token_valid(current) + ): + logger.debug("Adopted Claude Code's already-refreshed OAuth token") + return current_token + + refresh_token = (current or {}).get("refreshToken", "") or creds.get("refreshToken", "") if not refresh_token: logger.debug("No refresh token available — cannot refresh") return None diff --git a/scripts/release.py b/scripts/release.py index c42e0e287ec..e324fb805d0 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1575,6 +1575,7 @@ AUTHOR_MAP = { "david@loadmagic.ai": "davidcampbelldc", # PR #26834 (web_server proxy_headers=False) "165905879+davidcampbelldc@users.noreply.github.com": "davidcampbelldc", "chazmaniandinkle@gmail.com": "chazmaniandinkle", # PR #43888 (launchd /restart detection) + "sksmsghkdud1@gmail.com": "nodejun", # PR #21112 (anthropic keychain/file credential desync) "hoangv.pham0803@gmail.com": "hehehe0803", # PR #26212 salvage (codex kanban writable root) "26063003+hehehe0803@users.noreply.github.com": "hehehe0803", "kasunvinod@users.noreply.github.com": "kasunvinod", # PR #24126 salvage (codex timeout propagation) diff --git a/tests/agent/test_anthropic_keychain.py b/tests/agent/test_anthropic_keychain.py index 1cbe46c4934..97faca04a69 100644 --- a/tests/agent/test_anthropic_keychain.py +++ b/tests/agent/test_anthropic_keychain.py @@ -7,6 +7,7 @@ from unittest.mock import patch, MagicMock from agent.anthropic_adapter import ( _read_claude_code_credentials_from_keychain, read_claude_code_credentials, + _refresh_oauth_token, ) @@ -265,3 +266,72 @@ class TestReadClaudeCodeCredentialsDesync: assert creds is not None assert creds["accessToken"] == "newer-expired-file" + + +class TestRefreshOAuthTokenAdoptsFreshCredential: + """``_refresh_oauth_token`` should adopt a credential Claude Code has + already refreshed rather than POSTing a (possibly already-rotated) + single-use refresh token and racing Claude Code into ``invalid_grant``. + """ + + _FRESH = 9_999_999_999_999 + + def test_adopts_already_refreshed_token_without_posting(self, monkeypatch): + """When a live source already holds a valid token, return it and skip + the network refresh entirely. + """ + fresh = { + "accessToken": "already-refreshed-token", + "refreshToken": "live-refresh", + "expiresAt": self._FRESH, + } + monkeypatch.setattr( + "agent.anthropic_adapter.read_claude_code_credentials", + lambda: fresh, + ) + + def _should_not_be_called(*args, **kwargs): # pragma: no cover - guard + raise AssertionError("refresh_anthropic_oauth_pure must not be called") + + monkeypatch.setattr( + "agent.anthropic_adapter.refresh_anthropic_oauth_pure", + _should_not_be_called, + ) + + # Stale creds passed in by the caller — should be ignored in favor + # of the live, already-refreshed token. + result = _refresh_oauth_token({"refreshToken": "stale", "expiresAt": 1}) + assert result == "already-refreshed-token" + + def test_falls_back_to_network_refresh_when_no_fresh_credential(self, monkeypatch): + """When no live source has a valid token, fall back to refreshing + ourselves using the freshest available refresh token. + """ + # Live read returns an expired credential carrying a refresh token. + monkeypatch.setattr( + "agent.anthropic_adapter.read_claude_code_credentials", + lambda: {"accessToken": "expired", "refreshToken": "live-refresh", "expiresAt": 1}, + ) + captured = {} + + def _fake_refresh(refresh_token, **kwargs): + captured["refresh_token"] = refresh_token + return { + "access_token": "newly-minted", + "refresh_token": "rotated", + "expires_at_ms": self._FRESH, + } + + monkeypatch.setattr( + "agent.anthropic_adapter.refresh_anthropic_oauth_pure", _fake_refresh + ) + monkeypatch.setattr( + "agent.anthropic_adapter._write_claude_code_credentials", + lambda *a, **k: None, + ) + + result = _refresh_oauth_token({"refreshToken": "caller-refresh", "expiresAt": 1}) + assert result == "newly-minted" + # Prefers the live source's refresh token over the caller's stale copy. + assert captured["refresh_token"] == "live-refresh" +