fix: tool tests
This commit is contained in:
+107
-17
@@ -39,6 +39,7 @@ logger = logging.getLogger(__name__)
|
||||
CLAUDE_CREDENTIALS_FILE = Path.home() / ".claude" / ".credentials.json"
|
||||
CLAUDE_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
||||
CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"
|
||||
|
||||
# Buffer in seconds before token expiry to trigger a proactive refresh
|
||||
_TOKEN_REFRESH_BUFFER_SECS = 300 # 5 minutes
|
||||
@@ -51,6 +52,96 @@ CODEX_KEYCHAIN_SERVICE = "Codex Auth"
|
||||
_CODEX_TOKEN_LIFETIME_SECS = 3600 # 1 hour (no explicit expiry field)
|
||||
|
||||
|
||||
def _read_claude_keychain() -> dict | None:
|
||||
"""Read Claude Code credentials from macOS Keychain.
|
||||
|
||||
Returns the parsed JSON dict, or None if not on macOS or entry missing.
|
||||
"""
|
||||
import getpass
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
if platform.system() != "Darwin":
|
||||
return None
|
||||
|
||||
try:
|
||||
account = getpass.getuser()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
CLAUDE_KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
account,
|
||||
"-w",
|
||||
],
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
raw = result.stdout.strip()
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError) as exc:
|
||||
logger.debug("Claude keychain read failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _save_claude_keychain(creds: dict) -> bool:
|
||||
"""Write Claude Code credentials to macOS Keychain. Returns True on success."""
|
||||
import getpass
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
if platform.system() != "Darwin":
|
||||
return False
|
||||
|
||||
try:
|
||||
account = getpass.getuser()
|
||||
data = json.dumps(creds)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"security",
|
||||
"add-generic-password",
|
||||
"-U",
|
||||
"-s",
|
||||
CLAUDE_KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
account,
|
||||
"-w",
|
||||
data,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.debug("Claude keychain write failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _read_claude_credentials() -> dict | None:
|
||||
"""Read Claude Code credentials from Keychain (macOS) or file (Linux/Windows)."""
|
||||
# Try macOS Keychain first
|
||||
creds = _read_claude_keychain()
|
||||
if creds:
|
||||
return creds
|
||||
|
||||
# Fall back to file
|
||||
if not CLAUDE_CREDENTIALS_FILE.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(CLAUDE_CREDENTIALS_FILE, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _refresh_claude_code_token(refresh_token: str) -> dict | None:
|
||||
"""Refresh the Claude Code OAuth token using the refresh token.
|
||||
|
||||
@@ -89,16 +180,14 @@ def _refresh_claude_code_token(refresh_token: str) -> dict | None:
|
||||
|
||||
|
||||
def _save_refreshed_credentials(token_data: dict) -> None:
|
||||
"""Write refreshed token data back to ~/.claude/.credentials.json."""
|
||||
"""Write refreshed token data back to Keychain (macOS) or credentials file."""
|
||||
import time
|
||||
|
||||
if not CLAUDE_CREDENTIALS_FILE.exists():
|
||||
creds = _read_claude_credentials()
|
||||
if not creds:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(CLAUDE_CREDENTIALS_FILE, encoding="utf-8") as f:
|
||||
creds = json.load(f)
|
||||
|
||||
oauth = creds.get("claudeAiOauth", {})
|
||||
oauth["accessToken"] = token_data["access_token"]
|
||||
if "refresh_token" in token_data:
|
||||
@@ -107,9 +196,15 @@ def _save_refreshed_credentials(token_data: dict) -> None:
|
||||
oauth["expiresAt"] = int((time.time() + token_data["expires_in"]) * 1000)
|
||||
creds["claudeAiOauth"] = oauth
|
||||
|
||||
with open(CLAUDE_CREDENTIALS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(creds, f, indent=2)
|
||||
logger.debug("Claude Code credentials refreshed successfully")
|
||||
# Try Keychain first (macOS), fall back to file
|
||||
if _save_claude_keychain(creds):
|
||||
logger.debug("Claude Code credentials refreshed in Keychain")
|
||||
return
|
||||
|
||||
if CLAUDE_CREDENTIALS_FILE.exists():
|
||||
with open(CLAUDE_CREDENTIALS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(creds, f, indent=2)
|
||||
logger.debug("Claude Code credentials refreshed in file")
|
||||
except (json.JSONDecodeError, OSError, KeyError) as exc:
|
||||
logger.debug("Failed to save refreshed credentials: %s", exc)
|
||||
|
||||
@@ -117,8 +212,8 @@ def _save_refreshed_credentials(token_data: dict) -> None:
|
||||
def get_claude_code_token() -> str | None:
|
||||
"""Get the OAuth token from Claude Code subscription with auto-refresh.
|
||||
|
||||
Reads from ~/.claude/.credentials.json which is created by the
|
||||
Claude Code CLI when users authenticate with their subscription.
|
||||
Reads from macOS Keychain (on Darwin) or ~/.claude/.credentials.json
|
||||
(on Linux/Windows), as created by the Claude Code CLI.
|
||||
|
||||
If the token is expired or close to expiry, attempts an automatic
|
||||
refresh using the stored refresh token.
|
||||
@@ -128,13 +223,8 @@ def get_claude_code_token() -> str | None:
|
||||
"""
|
||||
import time
|
||||
|
||||
if not CLAUDE_CREDENTIALS_FILE.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(CLAUDE_CREDENTIALS_FILE, encoding="utf-8") as f:
|
||||
creds = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
creds = _read_claude_credentials()
|
||||
if not creds:
|
||||
return None
|
||||
|
||||
oauth = creds.get("claudeAiOauth", {})
|
||||
|
||||
@@ -1260,33 +1260,167 @@ class IntercomHealthChecker(OAuthBearerHealthChecker):
|
||||
)
|
||||
|
||||
|
||||
# --- Simple Bearer-auth checkers ---
|
||||
|
||||
|
||||
class ApifyHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://api.apify.com/v2/users/me"
|
||||
SERVICE_NAME = "Apify"
|
||||
|
||||
|
||||
class AsanaHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://app.asana.com/api/1.0/users/me"
|
||||
SERVICE_NAME = "Asana"
|
||||
|
||||
|
||||
class AttioHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://api.attio.com/v2/workspace_members"
|
||||
SERVICE_NAME = "Attio"
|
||||
|
||||
|
||||
class DockerHubHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://hub.docker.com/v2/user/login"
|
||||
SERVICE_NAME = "Docker Hub"
|
||||
|
||||
|
||||
class GoogleSearchConsoleHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://www.googleapis.com/webmasters/v3/sites"
|
||||
SERVICE_NAME = "Google Search Console"
|
||||
|
||||
|
||||
class HuggingFaceHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://huggingface.co/api/whoami-v2"
|
||||
SERVICE_NAME = "Hugging Face"
|
||||
|
||||
|
||||
class LinearHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://api.linear.app/graphql"
|
||||
SERVICE_NAME = "Linear"
|
||||
|
||||
|
||||
class MicrosoftGraphHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://graph.microsoft.com/v1.0/me"
|
||||
SERVICE_NAME = "Microsoft Graph"
|
||||
|
||||
|
||||
class PineconeHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://api.pinecone.io/indexes"
|
||||
SERVICE_NAME = "Pinecone"
|
||||
|
||||
|
||||
class VercelHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://api.vercel.com/v2/user"
|
||||
SERVICE_NAME = "Vercel"
|
||||
|
||||
|
||||
# --- Custom-header auth checkers ---
|
||||
|
||||
|
||||
class GitLabHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://gitlab.com/api/v4/user"
|
||||
SERVICE_NAME = "GitLab"
|
||||
AUTH_TYPE = BaseHttpHealthChecker.AUTH_HEADER
|
||||
AUTH_HEADER_NAME = "PRIVATE-TOKEN"
|
||||
AUTH_HEADER_TEMPLATE = "{token}"
|
||||
|
||||
|
||||
class NotionHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://api.notion.com/v1/users/me"
|
||||
SERVICE_NAME = "Notion"
|
||||
|
||||
def _build_headers(self, credential_value: str) -> dict[str, str]:
|
||||
headers = super()._build_headers(credential_value)
|
||||
headers["Notion-Version"] = "2022-06-28"
|
||||
return headers
|
||||
|
||||
|
||||
# --- Basic-auth checkers ---
|
||||
|
||||
|
||||
class GreenhouseHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://harvest.greenhouse.io/v1/jobs?per_page=1"
|
||||
SERVICE_NAME = "Greenhouse"
|
||||
AUTH_TYPE = BaseHttpHealthChecker.AUTH_BASIC
|
||||
|
||||
|
||||
# --- Query-param auth checkers ---
|
||||
|
||||
|
||||
class PipedriveHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://api.pipedrive.com/v1/users/me"
|
||||
SERVICE_NAME = "Pipedrive"
|
||||
AUTH_TYPE = BaseHttpHealthChecker.AUTH_QUERY
|
||||
AUTH_QUERY_PARAM_NAME = "api_token"
|
||||
|
||||
|
||||
class TrelloKeyHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://api.trello.com/1/members/me"
|
||||
SERVICE_NAME = "Trello"
|
||||
AUTH_TYPE = BaseHttpHealthChecker.AUTH_QUERY
|
||||
AUTH_QUERY_PARAM_NAME = "key"
|
||||
|
||||
|
||||
class TrelloTokenHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = "https://api.trello.com/1/members/me"
|
||||
SERVICE_NAME = "Trello"
|
||||
AUTH_TYPE = BaseHttpHealthChecker.AUTH_QUERY
|
||||
AUTH_QUERY_PARAM_NAME = "token"
|
||||
|
||||
|
||||
class YouTubeHealthChecker(BaseHttpHealthChecker):
|
||||
ENDPOINT = (
|
||||
"https://www.googleapis.com/youtube/v3/videoCategories"
|
||||
"?part=snippet®ionCode=US"
|
||||
)
|
||||
SERVICE_NAME = "YouTube"
|
||||
AUTH_TYPE = BaseHttpHealthChecker.AUTH_QUERY
|
||||
AUTH_QUERY_PARAM_NAME = "key"
|
||||
|
||||
|
||||
# Registry of health checkers
|
||||
HEALTH_CHECKERS: dict[str, CredentialHealthChecker] = {
|
||||
"discord": DiscordHealthChecker(),
|
||||
"hubspot": HubSpotHealthChecker(),
|
||||
"zoho_crm": ZohoCRMHealthChecker(),
|
||||
"brave_search": BraveSearchHealthChecker(),
|
||||
"google_calendar_oauth": GoogleCalendarHealthChecker(),
|
||||
"google": GoogleGmailHealthChecker(),
|
||||
"slack": SlackHealthChecker(),
|
||||
"calendly_pat": CalendlyHealthChecker(),
|
||||
"google_search": GoogleSearchHealthChecker(),
|
||||
"google_maps": GoogleMapsHealthChecker(),
|
||||
"anthropic": AnthropicHealthChecker(),
|
||||
"github": GitHubHealthChecker(),
|
||||
"intercom": IntercomHealthChecker(),
|
||||
"resend": ResendHealthChecker(),
|
||||
"lusha_api_key": LushaHealthChecker(),
|
||||
"stripe": StripeHealthChecker(),
|
||||
"exa_search": ExaSearchHealthChecker(),
|
||||
"google_docs": GoogleDocsHealthChecker(),
|
||||
"calcom": CalcomHealthChecker(),
|
||||
"serpapi": SerpApiHealthChecker(),
|
||||
"apify": ApifyHealthChecker(),
|
||||
"apollo": ApolloHealthChecker(),
|
||||
"telegram": TelegramHealthChecker(),
|
||||
"newsdata": NewsdataHealthChecker(),
|
||||
"finlight": FinlightHealthChecker(),
|
||||
"asana": AsanaHealthChecker(),
|
||||
"attio": AttioHealthChecker(),
|
||||
"brave_search": BraveSearchHealthChecker(),
|
||||
"brevo": BrevoHealthChecker(),
|
||||
"calcom": CalcomHealthChecker(),
|
||||
"calendly_pat": CalendlyHealthChecker(),
|
||||
"discord": DiscordHealthChecker(),
|
||||
"docker_hub": DockerHubHealthChecker(),
|
||||
"exa_search": ExaSearchHealthChecker(),
|
||||
"finlight": FinlightHealthChecker(),
|
||||
"github": GitHubHealthChecker(),
|
||||
"gitlab_token": GitLabHealthChecker(),
|
||||
"google": GoogleGmailHealthChecker(),
|
||||
"google_calendar_oauth": GoogleCalendarHealthChecker(),
|
||||
"google_docs": GoogleDocsHealthChecker(),
|
||||
"google_maps": GoogleMapsHealthChecker(),
|
||||
"google_search": GoogleSearchHealthChecker(),
|
||||
"google_search_console": GoogleSearchConsoleHealthChecker(),
|
||||
"greenhouse_token": GreenhouseHealthChecker(),
|
||||
"hubspot": HubSpotHealthChecker(),
|
||||
"huggingface": HuggingFaceHealthChecker(),
|
||||
"intercom": IntercomHealthChecker(),
|
||||
"linear": LinearHealthChecker(),
|
||||
"lusha_api_key": LushaHealthChecker(),
|
||||
"microsoft_graph": MicrosoftGraphHealthChecker(),
|
||||
"newsdata": NewsdataHealthChecker(),
|
||||
"notion_token": NotionHealthChecker(),
|
||||
"pinecone": PineconeHealthChecker(),
|
||||
"pipedrive": PipedriveHealthChecker(),
|
||||
"resend": ResendHealthChecker(),
|
||||
"serpapi": SerpApiHealthChecker(),
|
||||
"slack": SlackHealthChecker(),
|
||||
"stripe": StripeHealthChecker(),
|
||||
"telegram": TelegramHealthChecker(),
|
||||
"trello_key": TrelloKeyHealthChecker(),
|
||||
"trello_token": TrelloTokenHealthChecker(),
|
||||
"vercel": VercelHealthChecker(),
|
||||
"youtube": YouTubeHealthChecker(),
|
||||
"zoho_crm": ZohoCRMHealthChecker(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,15 @@ class TestRegistryCompleteness:
|
||||
# - google_cse: shares google_search checker (same credential_group)
|
||||
# - razorpay/razorpay_secret: requires HTTP Basic auth with TWO credentials,
|
||||
# which the single-value health check dispatcher can't support
|
||||
KNOWN_EXCEPTIONS = {"google_cse", "razorpay", "razorpay_secret"}
|
||||
# - plaid_client_id/plaid_secret: requires POST with both client_id and
|
||||
# secret in JSON body, can't validate with a single credential value
|
||||
KNOWN_EXCEPTIONS = {
|
||||
"google_cse",
|
||||
"razorpay",
|
||||
"razorpay_secret",
|
||||
"plaid_client_id",
|
||||
"plaid_secret",
|
||||
}
|
||||
|
||||
def test_specs_with_endpoint_have_checkers(self):
|
||||
"""Every CredentialSpec with health_check_endpoint has a HEALTH_CHECKERS entry."""
|
||||
|
||||
@@ -74,30 +74,47 @@ class TestHealthCheckerRegistry:
|
||||
def test_all_expected_checkers_registered(self):
|
||||
"""All expected health checkers are in the registry."""
|
||||
expected = {
|
||||
"hubspot",
|
||||
"brave_search",
|
||||
"google_search",
|
||||
"google_maps",
|
||||
"anthropic",
|
||||
"github",
|
||||
"intercom",
|
||||
"resend",
|
||||
"google_calendar_oauth",
|
||||
"google",
|
||||
"slack",
|
||||
"lusha_api_key",
|
||||
"discord",
|
||||
"stripe",
|
||||
"exa_search",
|
||||
"google_docs",
|
||||
"calcom",
|
||||
"serpapi",
|
||||
"apify",
|
||||
"apollo",
|
||||
"telegram",
|
||||
"newsdata",
|
||||
"finlight",
|
||||
"asana",
|
||||
"attio",
|
||||
"brave_search",
|
||||
"brevo",
|
||||
"calcom",
|
||||
"calendly_pat",
|
||||
"discord",
|
||||
"docker_hub",
|
||||
"exa_search",
|
||||
"finlight",
|
||||
"github",
|
||||
"gitlab_token",
|
||||
"google",
|
||||
"google_calendar_oauth",
|
||||
"google_docs",
|
||||
"google_maps",
|
||||
"google_search",
|
||||
"google_search_console",
|
||||
"greenhouse_token",
|
||||
"hubspot",
|
||||
"huggingface",
|
||||
"intercom",
|
||||
"linear",
|
||||
"lusha_api_key",
|
||||
"microsoft_graph",
|
||||
"newsdata",
|
||||
"notion_token",
|
||||
"pinecone",
|
||||
"pipedrive",
|
||||
"resend",
|
||||
"serpapi",
|
||||
"slack",
|
||||
"stripe",
|
||||
"telegram",
|
||||
"trello_key",
|
||||
"trello_token",
|
||||
"vercel",
|
||||
"youtube",
|
||||
"zoho_crm",
|
||||
}
|
||||
assert set(HEALTH_CHECKERS.keys()) == expected
|
||||
|
||||
Reference in New Issue
Block a user