fix(auth): Claude Code dead refresh token is reported once; CLAUDE_CONFIG_DIR honoured; no 'claude setup-token' hints
Review follow-up on the sibling refresher path (_refresh_oauth_token), which the PR title already claimed but only the pool path delivered: - A refresh token the endpoint rejected terminally (invalid_grant etc.) is fingerprinted in a process-local set; later attempts skip the POST and log at DEBUG instead of replaying the dead token and re-firing the WARNING on every resolve. A rotated token (user re-logs into Claude Code) has a new fingerprint and is tried normally. - claude_code_credentials_path() honours CLAUDE_CONFIG_DIR (blank = unset, same rule as hermes_cli.foreign_sessions), so the opt-out the PR body documents actually exists and matches the Claude CLI's own relocation. - The three remaining 're-run claude setup-token' hints inside the refresher now say 'hermes auth add anthropic' like the rest of the PR; setup-token does not repair the Claude Code file anyway.
This commit is contained in:
@@ -100,6 +100,9 @@ def _commit_private_json(path: Path, payload: Any, what: str) -> None:
|
||||
# reached its store. Two scopes: process-local (OrderedDict) and a durable sidecar next to the shared singleton
|
||||
# file so OTHER processes fail closed too. Non-reversible digests; never cleared.
|
||||
_SPENT_ROTATION_LOCK = threading.Lock()
|
||||
# Fingerprints of Claude Code refresh tokens the endpoint rejected terminally: the WARNING fires once per token
|
||||
# per process and later attempts skip the POST (a re-login rotates the token, so a new one is tried normally).
|
||||
_DEAD_REFRESH_TOKEN_FINGERPRINTS: set = set()
|
||||
_SPENT_ROTATION_FINGERPRINTS: "OrderedDict[str, None]" = OrderedDict()
|
||||
_SPENT_ROTATION_MAX_TRACKED = 64
|
||||
_SPENT_ROTATION_SIDECAR_COMMENT = (
|
||||
@@ -225,8 +228,12 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
def claude_code_credentials_path() -> Path:
|
||||
"""Claude Code's shared OAuth file; every profile reads/writes this same path."""
|
||||
return Path.home() / ".claude" / ".credentials.json"
|
||||
"""Claude Code's shared OAuth file; every profile reads/writes this same path. Honours ``CLAUDE_CONFIG_DIR``
|
||||
like the Claude CLI itself (blank = unset, as in ``hermes_cli.foreign_sessions``), so pointing it at an
|
||||
empty directory opts a Hermes process out of borrowing the login."""
|
||||
override = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
|
||||
root = Path(override).expanduser() if override else Path.home() / ".claude"
|
||||
return root / ".credentials.json"
|
||||
|
||||
|
||||
def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]:
|
||||
@@ -368,12 +375,17 @@ def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
|
||||
# Another process may have spent this token and lost the commit; its sidecar verdict is authoritative.
|
||||
if is_rotation_consumed_uncommitted(refresh_token, source_path=cred_path):
|
||||
logger.debug("Refresh token was already consumed by an uncommitted rotation "
|
||||
"- refusing to replay it; re-run 'claude setup-token'")
|
||||
"- refusing to replay it; run 'hermes auth add anthropic'")
|
||||
return None
|
||||
fingerprint = hashlib.sha256(refresh_token.encode("utf-8")).hexdigest()[:32]
|
||||
if fingerprint in _DEAD_REFRESH_TOKEN_FINGERPRINTS:
|
||||
logger.debug("Claude Code refresh token was already rejected as terminally invalid - not replaying it")
|
||||
return None
|
||||
try:
|
||||
refreshed = refresh_anthropic_oauth_pure(refresh_token, use_json=False)
|
||||
except Exception as e:
|
||||
if is_terminal_anthropic_refresh_error(e):
|
||||
_DEAD_REFRESH_TOKEN_FINGERPRINTS.add(fingerprint)
|
||||
logger.warning(
|
||||
"Claude Code OAuth refresh token is terminally invalid (%s); Hermes cannot use this "
|
||||
"login. Run 'hermes auth add anthropic' to give Hermes its own login.", e)
|
||||
@@ -388,7 +400,7 @@ def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
|
||||
logger.error(
|
||||
"Anthropic OAuth refresh rotated the single-use token but could not "
|
||||
"commit it to %s (%s) — treating the refresh as failed; "
|
||||
"re-run 'claude setup-token' to reauthenticate",
|
||||
"run 'hermes auth add anthropic' to give Hermes its own login",
|
||||
cred_path, e,
|
||||
)
|
||||
mark_rotation_consumed_uncommitted(
|
||||
@@ -443,7 +455,7 @@ def _resolve_claude_code_token_from_credentials(creds: Optional[Dict[str, Any]]
|
||||
logger.debug("Claude Code credentials expired — attempting refresh")
|
||||
refreshed = _refresh_oauth_token(creds)
|
||||
if not refreshed:
|
||||
logger.debug("Token refresh failed — re-run 'claude setup-token' to reauthenticate")
|
||||
logger.debug("Token refresh failed — run 'hermes auth add anthropic' to give Hermes its own login")
|
||||
return refreshed or None
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import io
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -51,6 +52,40 @@ def test_claude_code_refresher_warns_on_dead_grant(monkeypatch, caplog):
|
||||
assert len(warnings) == 1 and "hermes auth add anthropic" in warnings[0]
|
||||
|
||||
|
||||
def test_claude_code_refresher_reports_dead_grant_once_per_process(monkeypatch, caplog):
|
||||
"""Later attempts with the same dead refresh token neither replay it at the endpoint nor re-warn; a rotated
|
||||
(re-login) token is tried again."""
|
||||
monkeypatch.setattr(ac, "_DEAD_REFRESH_TOKEN_FINGERPRINTS", set())
|
||||
monkeypatch.setattr(ac, "read_claude_code_credentials", lambda: {"accessToken": "old", "refreshToken": "rt-dead", "expiresAt": 1})
|
||||
posts = []
|
||||
|
||||
def dead(refresh_token, *, use_json=False):
|
||||
posts.append(refresh_token)
|
||||
raise ac.AnthropicOAuthError(400, "invalid_grant", "", what="refresh")
|
||||
|
||||
monkeypatch.setattr(ac, "refresh_anthropic_oauth_pure", dead)
|
||||
creds = {"accessToken": "old", "refreshToken": "rt-dead"}
|
||||
with caplog.at_level(logging.DEBUG, logger=ac.logger.name):
|
||||
for _ in range(3):
|
||||
assert ac._refresh_oauth_token(creds) is None
|
||||
assert posts == ["rt-dead"]
|
||||
assert sum(1 for r in caplog.records if r.levelno == logging.WARNING) == 1
|
||||
assert not any("claude setup-token" in r.getMessage() for r in caplog.records)
|
||||
monkeypatch.setattr(ac, "read_claude_code_credentials", lambda: {"accessToken": "old", "refreshToken": "rt-new", "expiresAt": 1})
|
||||
assert ac._refresh_oauth_token({"accessToken": "old", "refreshToken": "rt-new"}) is None
|
||||
assert posts == ["rt-dead", "rt-new"]
|
||||
|
||||
|
||||
def test_claude_code_credentials_path_honours_claude_config_dir(monkeypatch, tmp_path):
|
||||
"""The documented opt-out: CLAUDE_CONFIG_DIR relocates the borrowed file exactly as the Claude CLI does."""
|
||||
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cc"))
|
||||
assert ac.claude_code_credentials_path() == tmp_path / "cc" / ".credentials.json"
|
||||
monkeypatch.setenv("CLAUDE_CONFIG_DIR", " ")
|
||||
assert ac.claude_code_credentials_path() == Path.home() / ".claude" / ".credentials.json"
|
||||
monkeypatch.delenv("CLAUDE_CONFIG_DIR")
|
||||
assert ac.claude_code_credentials_path() == Path.home() / ".claude" / ".credentials.json"
|
||||
|
||||
|
||||
def test_anthropic_401_troubleshooting_points_at_hermes_auth(capsys):
|
||||
from agent.turn_recovery import _print_anthropic_401_diagnostics
|
||||
|
||||
|
||||
Reference in New Issue
Block a user