mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
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.
This commit is contained in:
parent
5a5396aecb
commit
1dde7e2f2a
3 changed files with 105 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue