Files
hermes-agent/tests/agent/test_anthropic_keychain.py
T
ethernet 30da5d0a89 test: run os-specific tests on their real host, not a faked one
many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.

this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.

each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
  dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has

bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.

running on real hosts found real errors: a chrome-sandbox failure in
test_gui_command that main hides, and two windows failures fixed here.
the agents.md testing section now documents the policy.
2026-08-09 22:09:49 -04:00

266 lines
11 KiB
Python

"""Tests for Bug #12905 fixes in agent/anthropic_adapter.py — macOS Keychain support."""
import json
from unittest.mock import patch, MagicMock
import pytest
from agent.anthropic_adapter import (
_read_claude_code_credentials_from_keychain,
read_claude_code_credentials,
_refresh_oauth_token,
)
# This module exercises the reader itself with explicit platform and subprocess
# mocks, so it opts out of the suite-wide guard without touching a real Keychain.
pytestmark = pytest.mark.allow_macos_keychain
@pytest.mark.macos_only
class TestReadClaudeCodeCredentialsFromKeychain:
"""Bug 4: macOS Keychain support for Claude Code >=2.1.114.
``macos_only``: the reader is gated on ``platform.system() == "Darwin"``
and shells out to the ``security`` CLI. Faking Darwin on Linux selected
the branch but proved nothing about the host it exists for; on the real
macOS runner only ``subprocess.run`` is mocked (via the
``allow_macos_keychain`` opt-out of the suite-wide guard), so no real
Keychain is ever touched.
"""
def test_returns_none_when_security_command_not_found(self):
"""OSError from missing security binary must be handled gracefully."""
with patch("agent.anthropic_adapter.subprocess.run",
side_effect=OSError("security not found")):
assert _read_claude_code_credentials_from_keychain() is None
def test_returns_none_on_nonzero_exit_code(self):
"""security returns non-zero when the Keychain entry doesn't exist."""
with patch("agent.anthropic_adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
assert _read_claude_code_credentials_from_keychain() is None
@pytest.mark.macos_only
class TestReadClaudeCodeCredentialsPriority:
"""Bug 4: Keychain must be checked before the JSON file."""
def test_keychain_takes_priority_over_json_file(self, tmp_path, monkeypatch):
"""When both Keychain and JSON file have credentials, Keychain wins."""
# Set up JSON file with "older" token
json_cred_file = tmp_path / ".claude" / ".credentials.json"
json_cred_file.parent.mkdir(parents=True)
json_cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": "json-token",
"refreshToken": "json-refresh",
"expiresAt": 9999999999999,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
# Mock Keychain to return a "newer" token
with patch("agent.anthropic_adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps({
"claudeAiOauth": {
"accessToken": "keychain-token",
"refreshToken": "keychain-refresh",
"expiresAt": 9999999999999,
}
}),
stderr="",
)
creds = read_claude_code_credentials()
# Keychain token should be returned, not JSON file token
assert creds is not None
assert creds["accessToken"] == "keychain-token"
assert creds["source"] == "macos_keychain"
def test_falls_back_to_json_when_keychain_returns_none(self, tmp_path, monkeypatch):
"""When Keychain has no entry, JSON file is used as fallback."""
json_cred_file = tmp_path / ".claude" / ".credentials.json"
json_cred_file.parent.mkdir(parents=True)
json_cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": "json-fallback-token",
"refreshToken": "json-refresh",
"expiresAt": 9999999999999,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
with patch("agent.anthropic_adapter.subprocess.run") as mock_run:
# Simulate Keychain entry not found
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
creds = read_claude_code_credentials()
assert creds is not None
assert creds["accessToken"] == "json-fallback-token"
assert creds["source"] == "claude_code_credentials_file"
def test_returns_none_when_neither_keychain_nor_json_has_creds(self, tmp_path, monkeypatch):
"""No credentials anywhere — must return None cleanly."""
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
with patch("agent.anthropic_adapter.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
creds = read_claude_code_credentials()
assert creds is None
@pytest.mark.macos_only
class TestReadClaudeCodeCredentialsDesync:
"""Reconciliation when Keychain and JSON file disagree.
Observed in the wild on Claude Code 2.1.x: a refresh updates one source
(commonly the JSON file) but leaves the other holding an expired token.
The reader must not blindly return whichever source it consulted first;
it must prefer the non-expired credential.
"""
# Far-future ms-epoch — comfortably valid under is_claude_code_token_valid.
_FRESH = 9_999_999_999_999
# Past ms-epoch — comfortably expired (with the 60s buffer).
_EXPIRED = 1
def _setup(self, tmp_path, monkeypatch, *, file_expires_at, file_token="json-token"):
json_cred_file = tmp_path / ".claude" / ".credentials.json"
json_cred_file.parent.mkdir(parents=True)
json_cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": file_token,
"refreshToken": "json-refresh",
"expiresAt": file_expires_at,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
def _keychain_payload(self, *, access_token, expires_at, refresh_token="kc-refresh"):
return MagicMock(
returncode=0,
stdout=json.dumps({
"claudeAiOauth": {
"accessToken": access_token,
"refreshToken": refresh_token,
"expiresAt": expires_at,
}
}),
stderr="",
)
def test_keychain_expired_file_fresh_returns_file(self, tmp_path, monkeypatch):
"""Regression: when the Keychain holds an expired token but the JSON
file has a valid one, callers must receive the valid file token rather
than None. (Pre-fix behavior returned the expired Keychain token, and
downstream validity checks then yielded None — surfacing the misleading
``No Anthropic credentials found`` error.)
"""
self._setup(tmp_path, monkeypatch, file_expires_at=self._FRESH, file_token="fresh-file-token")
with patch("agent.anthropic_adapter.subprocess.run") as mock_run:
mock_run.return_value = self._keychain_payload(
access_token="stale-keychain-token", expires_at=self._EXPIRED,
)
creds = read_claude_code_credentials()
assert creds is not None
assert creds["accessToken"] == "fresh-file-token"
assert creds["source"] == "claude_code_credentials_file"
def test_both_expired_prefers_later_expiry(self, tmp_path, monkeypatch):
"""When both are expired, return the one with the later ``expiresAt``;
its ``refresh_token`` is the most recently issued and most likely to
succeed at the OAuth refresh endpoint.
"""
self._setup(tmp_path, monkeypatch, file_expires_at=self._EXPIRED + 5, file_token="newer-expired-file")
with patch("agent.anthropic_adapter.subprocess.run") as mock_run:
mock_run.return_value = self._keychain_payload(
access_token="older-expired-keychain", expires_at=self._EXPIRED,
)
creds = read_claude_code_credentials()
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"