diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index c124205c1782..bf56345d816d 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1209,6 +1209,82 @@ def _resolve_claude_code_token_from_credentials(creds: Optional[Dict[str, Any]] return None +_claude_code_cli_refresh_last_attempt: float = 0.0 +_CLAUDE_CODE_CLI_REFRESH_COOLDOWN_SECONDS = 300.0 + + +def _refresh_claude_code_credentials_via_cli(*, timeout: float = 120.0, force: bool = False) -> Optional[Dict[str, Any]]: + """Ask the official Claude Code CLI to refresh its credential store once. + + Hermes normally reads Claude Code credentials directly. In long-lived + gateway/cron/subprocess environments we can still hit a stale state: both + persisted Claude Code sources are expired, Hermes' pure refresh loses the + single-use refresh-token race, but the official ``claude`` CLI can recover + by using its own keychain/session logic. This bounded fallback mirrors the + documented operator fix (run a tiny Claude Code prompt), but makes it a + programmatic last resort so Fable/Anthropic subprocesses do not fail with a + misleading ``No Anthropic credentials found``. + + Returns fresh credentials after the CLI run, or ``None``. The CLI output is + deliberately ignored so tokens are never logged. + """ + import shutil + import time + + global _claude_code_cli_refresh_last_attempt + + if os.getenv("HERMES_ANTHROPIC_DISABLE_CLAUDE_CLI_REFRESH", "").strip().lower() in {"1", "true", "yes"}: + logger.debug("Claude Code CLI credential refresh disabled by env") + return None + + now = time.monotonic() + if not force and _claude_code_cli_refresh_last_attempt and ( + now - _claude_code_cli_refresh_last_attempt < _CLAUDE_CODE_CLI_REFRESH_COOLDOWN_SECONDS + ): + logger.debug("Claude Code CLI credential refresh skipped during cooldown") + return None + _claude_code_cli_refresh_last_attempt = now + + cli = shutil.which("claude") or shutil.which("claude-code") + if not cli: + logger.debug("Claude Code CLI not found; cannot refresh credentials via CLI") + return None + + cmd = [ + cli, + "-p", + "Reply exactly: OK_CLAUDE_REFRESH", + "--model", + "sonnet", + "--max-turns", + "1", + "--output-format", + "text", + ] + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + stdin=subprocess.DEVNULL, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.debug("Claude Code CLI credential refresh failed to run: %s", exc) + return None + + if result.returncode != 0: + logger.debug("Claude Code CLI credential refresh exited %s", result.returncode) + return None + + creds = read_claude_code_credentials() + if creds and is_claude_code_token_valid(creds): + logger.debug("Claude Code CLI refreshed usable credentials") + return creds + logger.debug("Claude Code CLI ran but no fresh credentials were found") + return None + + def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[str, Any]]) -> Optional[str]: """Prefer Claude Code creds when a persisted env OAuth token would shadow refresh. @@ -1279,8 +1355,9 @@ def resolve_anthropic_token() -> Optional[str]: 2. CLAUDE_CODE_OAUTH_TOKEN env var 3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json) — with automatic refresh if expired and a refresh token is available - 4. Anthropic credential_pool OAuth entry (~/.hermes/auth.json) - 5. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback) + 4. One bounded official Claude Code CLI refresh attempt, then re-read + 5. Anthropic credential_pool OAuth entry (~/.hermes/auth.json) + 6. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback) Returns the token string or None. """ @@ -1307,12 +1384,22 @@ def resolve_anthropic_token() -> Optional[str]: if resolved_claude_token: return resolved_claude_token - # 4. Hermes credential_pool OAuth entry. + # 4. Last-resort recovery through the official Claude Code CLI. This is + # intentionally after Hermes' pure refresh so the hot path stays fast and + # deterministic, but before pool/API-key fallbacks so Claude Max/Fable keeps + # using the documented refreshable Claude Code store when possible. + refreshed_creds = _refresh_claude_code_credentials_via_cli() + if refreshed_creds: + resolved_claude_token = _resolve_claude_code_token_from_credentials(refreshed_creds) + if resolved_claude_token: + return resolved_claude_token + + # 5. Hermes credential_pool OAuth entry. resolved_pool_token = _resolve_anthropic_pool_token() if resolved_pool_token: return resolved_pool_token - # 5. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY. + # 6. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY. # This remains as a compatibility fallback for pre-migration Hermes configs. api_key = os.getenv("ANTHROPIC_API_KEY", "").strip() if api_key: diff --git a/tests/agent/test_anthropic_claude_code_refresh_fallback.py b/tests/agent/test_anthropic_claude_code_refresh_fallback.py new file mode 100644 index 000000000000..401f15d8daf1 --- /dev/null +++ b/tests/agent/test_anthropic_claude_code_refresh_fallback.py @@ -0,0 +1,159 @@ +import shutil +import types + +import pytest + +import agent.anthropic_adapter as aa + + +@pytest.fixture(autouse=True) +def _clean_anthropic_refresh_state(monkeypatch): + for key in ( + "ANTHROPIC_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_API_KEY", + "HERMES_ANTHROPIC_DISABLE_CLAUDE_CLI_REFRESH", + ): + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(aa, "_claude_code_cli_refresh_last_attempt", 0.0) + + +def test_resolve_anthropic_token_uses_official_claude_cli_refresh(monkeypatch): + """If Hermes cannot resolve creds directly, run Claude CLI once. + + This protects Fable/Anthropic subprocesses from failing with + "No Anthropic credentials found" when the official Claude Code session can + refresh its store. + """ + calls = {"read": 0, "run": 0} + + def fake_read_creds(): + calls["read"] += 1 + if calls["run"] == 0: + return None + return { + "accessToken": "fresh-cli-token", + "refreshToken": "fresh-refresh-token", + "expiresAt": 9999999999999, + "source": "claude_code_credentials_file", + } + + def fake_run(*args, **kwargs): + calls["run"] += 1 + return types.SimpleNamespace(returncode=0, stdout="OK_CLAUDE_REFRESH\n", stderr="") + + monkeypatch.setattr(aa, "read_claude_code_credentials", fake_read_creds) + monkeypatch.setattr( + aa, + "is_claude_code_token_valid", + lambda creds: bool(creds and creds.get("accessToken") == "fresh-cli-token"), + ) + monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/claude" if name == "claude" else None) + monkeypatch.setattr(aa.subprocess, "run", fake_run) + monkeypatch.setattr(aa, "_resolve_anthropic_pool_token", lambda: None) + + assert aa.resolve_anthropic_token() == "fresh-cli-token" + assert calls["run"] == 1 + + +def test_expired_claude_code_creds_with_failed_pure_refresh_use_cli(monkeypatch): + calls = {"run": 0} + + expired = { + "accessToken": "expired-token", + "refreshToken": "stale-refresh", + "expiresAt": 1, + "source": "macos_keychain", + } + fresh = { + "accessToken": "fresh-after-cli", + "refreshToken": "fresh-refresh", + "expiresAt": 9999999999999, + "source": "macos_keychain", + } + + def fake_read_creds(): + return fresh if calls["run"] else expired + + def fake_run(*args, **kwargs): + calls["run"] += 1 + return types.SimpleNamespace(returncode=0, stdout="OK_CLAUDE_REFRESH\n", stderr="") + + monkeypatch.setattr(aa, "read_claude_code_credentials", fake_read_creds) + monkeypatch.setattr(aa, "is_claude_code_token_valid", lambda creds: creds.get("accessToken") == "fresh-after-cli") + monkeypatch.setattr(aa, "_refresh_oauth_token", lambda creds: None) + monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/claude" if name == "claude" else None) + monkeypatch.setattr(aa.subprocess, "run", fake_run) + monkeypatch.setattr(aa, "_resolve_anthropic_pool_token", lambda: None) + + assert aa.resolve_anthropic_token() == "fresh-after-cli" + assert calls["run"] == 1 + + +def test_claude_cli_nonzero_falls_through_to_pool(monkeypatch): + calls = {"run": 0} + + monkeypatch.setattr(aa, "read_claude_code_credentials", lambda: None) + monkeypatch.setattr(aa, "is_claude_code_token_valid", lambda creds: False) + monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/claude" if name == "claude" else None) + + def fake_run(*args, **kwargs): + calls["run"] += 1 + return types.SimpleNamespace(returncode=1, stdout="", stderr="login required") + + monkeypatch.setattr(aa.subprocess, "run", fake_run) + monkeypatch.setattr(aa, "_resolve_anthropic_pool_token", lambda: "pool-token") + + assert aa.resolve_anthropic_token() == "pool-token" + assert calls["run"] == 1 + + +def test_cli_refresh_disabled_by_env(monkeypatch): + calls = {"run": 0} + monkeypatch.setenv("HERMES_ANTHROPIC_DISABLE_CLAUDE_CLI_REFRESH", "1") + monkeypatch.setattr(aa, "read_claude_code_credentials", lambda: None) + monkeypatch.setattr(aa, "is_claude_code_token_valid", lambda creds: False) + monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/claude" if name == "claude" else None) + monkeypatch.setattr(aa.subprocess, "run", lambda *a, **k: calls.__setitem__("run", calls["run"] + 1)) + monkeypatch.setattr(aa, "_resolve_anthropic_pool_token", lambda: "pool-token") + + assert aa.resolve_anthropic_token() == "pool-token" + assert calls["run"] == 0 + + +def test_cli_refresh_cooldown_prevents_repeated_subprocesses(monkeypatch): + calls = {"run": 0} + monkeypatch.setattr(aa, "read_claude_code_credentials", lambda: None) + monkeypatch.setattr(aa, "is_claude_code_token_valid", lambda creds: False) + monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/claude" if name == "claude" else None) + + def fake_run(*args, **kwargs): + calls["run"] += 1 + return types.SimpleNamespace(returncode=1, stdout="", stderr="login required") + + monkeypatch.setattr(aa.subprocess, "run", fake_run) + monkeypatch.setattr(aa, "_resolve_anthropic_pool_token", lambda: "pool-token") + + assert aa.resolve_anthropic_token() == "pool-token" + assert aa.resolve_anthropic_token() == "pool-token" + assert calls["run"] == 1 + + +def test_env_tokens_do_not_trigger_cli_refresh(monkeypatch): + calls = {"run": 0} + monkeypatch.setenv("ANTHROPIC_TOKEN", "env-token") + monkeypatch.setattr(aa, "read_claude_code_credentials", lambda: None) + monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/claude" if name == "claude" else None) + monkeypatch.setattr(aa.subprocess, "run", lambda *a, **k: calls.__setitem__("run", calls["run"] + 1)) + + assert aa.resolve_anthropic_token() == "env-token" + assert calls["run"] == 0 + + +def test_resolve_anthropic_token_falls_through_when_claude_cli_unavailable(monkeypatch): + monkeypatch.setattr(aa, "read_claude_code_credentials", lambda: None) + monkeypatch.setattr(aa, "is_claude_code_token_valid", lambda creds: False) + monkeypatch.setattr(shutil, "which", lambda name: None) + monkeypatch.setattr(aa, "_resolve_anthropic_pool_token", lambda: "pool-token") + + assert aa.resolve_anthropic_token() == "pool-token"