mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Bug 3 — Stale OAuth token not detected in 'hermes model': - _model_flow_anthropic used 'has_creds = bool(existing_key)' which treats any non-empty token (including expired OAuth tokens) as valid. - Added existing_is_stale_oauth check: if the only credential is an OAuth token (sk-ant- prefix) with no valid cc_creds fallback, mark it stale and force the re-auth menu instead of silently accepting a broken token. Bug 4 — macOS Keychain credentials never read: - Claude Code >=2.1.114 migrated from ~/.claude/.credentials.json to the macOS Keychain under service 'Claude Code-credentials'. - Added _read_claude_code_credentials_from_keychain() using the 'security' CLI tool; read_claude_code_credentials() now tries Keychain first then falls back to JSON file. - Non-Darwin platforms return None from Keychain read immediately. Tests: - tests/agent/test_anthropic_keychain.py: 11 cases covering Darwin-only guard, security command failures, JSON parsing, fallback priority. - tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py: 8 cases covering stale OAuth detection, API key passthrough, cc_creds fallback. Refs: #12905
165 lines
7.7 KiB
Python
165 lines
7.7 KiB
Python
"""Tests for Bug #12905 fixes in agent/anthropic_adapter.py — macOS Keychain support."""
|
|
|
|
import json
|
|
import platform
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
|
|
from agent.anthropic_adapter import (
|
|
_read_claude_code_credentials_from_keychain,
|
|
read_claude_code_credentials,
|
|
)
|
|
|
|
|
|
class TestReadClaudeCodeCredentialsFromKeychain:
|
|
"""Bug 4: macOS Keychain support for Claude Code >=2.1.114."""
|
|
|
|
def test_returns_none_on_linux(self):
|
|
"""Keychain reading is Darwin-only; must return None on other platforms."""
|
|
with patch("agent.anthropic_adapter.platform.system", return_value="Linux"):
|
|
assert _read_claude_code_credentials_from_keychain() is None
|
|
|
|
def test_returns_none_on_windows(self):
|
|
with patch("agent.anthropic_adapter.platform.system", return_value="Windows"):
|
|
assert _read_claude_code_credentials_from_keychain() is None
|
|
|
|
def test_returns_none_when_security_command_not_found(self):
|
|
"""OSError from missing security binary must be handled gracefully."""
|
|
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
|
|
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.platform.system", return_value="Darwin"), \
|
|
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
|
|
|
|
def test_returns_none_for_empty_stdout(self):
|
|
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
|
|
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
assert _read_claude_code_credentials_from_keychain() is None
|
|
|
|
def test_returns_none_for_non_json_payload(self):
|
|
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
|
|
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="not valid json", stderr="")
|
|
assert _read_claude_code_credentials_from_keychain() is None
|
|
|
|
def test_returns_none_when_password_field_is_missing_claude_ai_oauth(self):
|
|
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
|
|
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(
|
|
returncode=0,
|
|
stdout=json.dumps({"someOtherService": {"accessToken": "tok"}}),
|
|
stderr="",
|
|
)
|
|
assert _read_claude_code_credentials_from_keychain() is None
|
|
|
|
def test_returns_none_when_access_token_is_empty(self):
|
|
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
|
|
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(
|
|
returncode=0,
|
|
stdout=json.dumps({"claudeAiOauth": {"accessToken": "", "refreshToken": "x"}}),
|
|
stderr="",
|
|
)
|
|
assert _read_claude_code_credentials_from_keychain() is None
|
|
|
|
def test_parses_valid_keychain_entry(self):
|
|
with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \
|
|
patch("agent.anthropic_adapter.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(
|
|
returncode=0,
|
|
stdout=json.dumps({
|
|
"claudeAiOauth": {
|
|
"accessToken": "kc-access-token-abc",
|
|
"refreshToken": "kc-refresh-token-xyz",
|
|
"expiresAt": 9999999999999,
|
|
}
|
|
}),
|
|
stderr="",
|
|
)
|
|
creds = _read_claude_code_credentials_from_keychain()
|
|
assert creds is not None
|
|
assert creds["accessToken"] == "kc-access-token-abc"
|
|
assert creds["refreshToken"] == "kc-refresh-token-xyz"
|
|
assert creds["expiresAt"] == 9999999999999
|
|
assert creds["source"] == "macos_keychain"
|
|
|
|
|
|
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.platform.system", return_value="Darwin"), \
|
|
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.platform.system", return_value="Darwin"), \
|
|
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.platform.system", return_value="Darwin"), \
|
|
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
|