diff --git a/core/framework/runner/runner.py b/core/framework/runner/runner.py index 049ca3fb..8df1da38 100644 --- a/core/framework/runner/runner.py +++ b/core/framework/runner/runner.py @@ -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", {}) diff --git a/tools/src/aden_tools/credentials/health_check.py b/tools/src/aden_tools/credentials/health_check.py index 26389e29..7a2c4a52 100644 --- a/tools/src/aden_tools/credentials/health_check.py +++ b/tools/src/aden_tools/credentials/health_check.py @@ -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(), } diff --git a/tools/tests/test_credential_registry.py b/tools/tests/test_credential_registry.py index 98c97e71..9443b67e 100644 --- a/tools/tests/test_credential_registry.py +++ b/tools/tests/test_credential_registry.py @@ -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.""" diff --git a/tools/tests/test_health_checks.py b/tools/tests/test_health_checks.py index 8e39aa95..e6cca430 100644 --- a/tools/tests/test_health_checks.py +++ b/tools/tests/test_health_checks.py @@ -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