From 428056e7b98a4cd45876951286edc841f3836079 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:07:28 -0700 Subject: [PATCH] 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. --- agent/anthropic_credentials.py | 22 +++++++++--- .../agent/test_anthropic_dead_grant_hints.py | 35 +++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/agent/anthropic_credentials.py b/agent/anthropic_credentials.py index dbc54e24d3..5deb9ff10b 100644 --- a/agent/anthropic_credentials.py +++ b/agent/anthropic_credentials.py @@ -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 diff --git a/tests/agent/test_anthropic_dead_grant_hints.py b/tests/agent/test_anthropic_dead_grant_hints.py index bee94a3f16..a1256a9b5a 100644 --- a/tests/agent/test_anthropic_dead_grant_hints.py +++ b/tests/agent/test_anthropic_dead_grant_hints.py @@ -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