fix(anthropic): write scopes field to Claude Code credentials on token refresh (#4126)

Claude Code >=2.1.81 checks for a 'scopes' array containing 'user:inference'
in ~/.claude/.credentials.json before accepting stored OAuth tokens as valid.

When Hermes refreshes the token, it writes only accessToken, refreshToken, and
expiresAt — omitting the scopes field. This causes Claude Code to report
'loggedIn: false' and refuse to start, even though the token is valid.

This commit:
- Parses the 'scope' field from the OAuth refresh response
- Passes it to _write_claude_code_credentials() as a keyword argument
- Persists the scopes array in the claudeAiOauth credential store
- Preserves existing scopes when the refresh response omits the field

Tested against Claude Code v2.1.87 on Linux — auth status correctly reports
loggedIn: true and claude --print works after this fix.

Co-authored-by: Nick <git@flybynight.io>
This commit is contained in:
Teknium
2026-03-30 18:35:16 -07:00
committed by GitHub
parent ffd5d37f9b
commit b2e1a095f8

View File

@@ -342,7 +342,14 @@ def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
if new_access:
new_expires_ms = int(time.time() * 1000) + (expires_in * 1000)
_write_claude_code_credentials(new_access, new_refresh, new_expires_ms)
# Parse scopes from refresh response — Claude Code >=2.1.81
# requires a "scopes" field in the credential store and checks
# for "user:inference" before accepting the token as valid.
scope_str = result.get("scope", "")
scopes = scope_str.split() if scope_str else None
_write_claude_code_credentials(
new_access, new_refresh, new_expires_ms, scopes=scopes,
)
logger.debug("Refreshed Claude Code OAuth token via %s", endpoint)
return new_access
except Exception as e:
@@ -351,8 +358,20 @@ def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
return None
def _write_claude_code_credentials(access_token: str, refresh_token: str, expires_at_ms: int) -> None:
"""Write refreshed credentials back to ~/.claude/.credentials.json."""
def _write_claude_code_credentials(
access_token: str,
refresh_token: str,
expires_at_ms: int,
*,
scopes: Optional[list] = None,
) -> None:
"""Write refreshed credentials back to ~/.claude/.credentials.json.
The optional *scopes* list (e.g. ``["user:inference", "user:profile", ...]``)
is persisted so that Claude Code's own auth check recognises the credential
as valid. Claude Code >=2.1.81 gates on the presence of ``"user:inference"``
in the stored scopes before it will use the token.
"""
cred_path = Path.home() / ".claude" / ".credentials.json"
try:
# Read existing file to preserve other fields
@@ -360,11 +379,19 @@ def _write_claude_code_credentials(access_token: str, refresh_token: str, expire
if cred_path.exists():
existing = json.loads(cred_path.read_text(encoding="utf-8"))
existing["claudeAiOauth"] = {
oauth_data: Dict[str, Any] = {
"accessToken": access_token,
"refreshToken": refresh_token,
"expiresAt": expires_at_ms,
}
if scopes is not None:
oauth_data["scopes"] = scopes
elif "claudeAiOauth" in existing and "scopes" in existing["claudeAiOauth"]:
# Preserve previously-stored scopes when the refresh response
# does not include a scope field.
oauth_data["scopes"] = existing["claudeAiOauth"]["scopes"]
existing["claudeAiOauth"] = oauth_data
cred_path.parent.mkdir(parents=True, exist_ok=True)
cred_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")