update credentials store to work with non-oauth keys

This commit is contained in:
bryan
2026-02-03 14:49:12 -08:00
parent bcc6848275
commit f50630c551
11 changed files with 757 additions and 91 deletions
+175 -84
View File
@@ -1,10 +1,10 @@
---
name: setup-credentials
description: Set up and install credentials for an agent. Detects missing credentials from agent config, collects them from the user, and stores them securely in the encrypted credential store at ~/.hive/credentials.
description: Set up and install credentials for an agent. Detects missing credentials from agent config, collects them from the user, and stores them securely in the local encrypted store at ~/.hive/credentials.
license: Apache-2.0
metadata:
author: hive
version: "2.1"
version: "2.2"
type: utility
---
@@ -31,48 +31,110 @@ Determine which agent needs credentials. The user will either:
Locate the agent's directory under `exports/{agent_name}/`.
### Step 2: Detect Required Credentials
### Step 2: Detect Required Credentials (Bash-First)
Read the agent's configuration to determine which tools and node types it uses:
Use bash commands to determine what the agent needs and what's already configured. This avoids Python import issues and works even when `HIVE_CREDENTIAL_KEY` is not set.
```python
from core.framework.runner import AgentRunner
#### Step 2a: Read Agent Requirements
runner = AgentRunner.load("exports/{agent_name}")
validation = runner.validate()
Extract `required_tools` and node types from the agent config:
# validation.missing_credentials contains env var names
# validation.warnings contains detailed messages with help URLs
```bash
# Get required tools
jq -r '.required_tools[]?' exports/{agent_name}/agent.json 2>/dev/null
# Get node types from graph nodes
jq -r '.graph.nodes[]?.node_type' exports/{agent_name}/agent.json 2>/dev/null | sort -u
```
Alternatively, check the credential store directly:
Use this lookup table to map tools and node types to credential IDs and env vars:
```python
from core.framework.credentials import CredentialStore
```
Tool → Credential mapping:
web_search → brave_search (BRAVE_SEARCH_API_KEY)
google_search → google_search (GOOGLE_API_KEY) + google_cse (GOOGLE_CSE_ID)
send_email, send_budget_alert_email → resend (RESEND_API_KEY)
github_* (any github_ prefixed) → github (GITHUB_TOKEN)
hubspot_* (any hubspot_ prefixed) → hubspot (HUBSPOT_ACCESS_TOKEN)
# Use encrypted storage (default: ~/.hive/credentials)
store = CredentialStore.with_encrypted_storage()
# Check what's available
available = store.list_credentials()
print(f"Available credentials: {available}")
# Check if specific credential exists
if store.is_available("hubspot"):
print("HubSpot credential found")
else:
print("HubSpot credential missing")
Node type → Credential mapping:
llm_generate, llm_tool_use → anthropic (ANTHROPIC_API_KEY)
```
To see all known credential specs (for help URLs and setup instructions):
Read the tool list and node types, apply the table, and build a list of needed credential IDs.
```python
from aden_tools.credentials import CREDENTIAL_SPECS
#### Step 2b: Check Existing Credential Sources
for name, spec in CREDENTIAL_SPECS.items():
print(f"{name}: env_var={spec.env_var}, aden={spec.aden_supported}")
For each needed credential, check three sources. A credential is "found" if it exists in ANY of them:
**1. Encrypted store metadata index** (unencrypted JSON — no decryption key needed):
```bash
cat ~/.hive/credentials/metadata/index.json 2>/dev/null | jq -r '.credentials | keys[]'
```
If a credential ID appears in this list, it is stored in the encrypted store.
**2. Environment variables:**
```bash
# Check each needed env var, e.g.:
printenv ANTHROPIC_API_KEY > /dev/null 2>&1 && echo "ANTHROPIC_API_KEY: set" || echo "ANTHROPIC_API_KEY: not set"
printenv BRAVE_SEARCH_API_KEY > /dev/null 2>&1 && echo "BRAVE_SEARCH_API_KEY: set" || echo "BRAVE_SEARCH_API_KEY: not set"
```
**3. Project `.env` file:**
```bash
# Check each needed env var, e.g.:
grep -q '^ANTHROPIC_API_KEY=' .env 2>/dev/null && echo "ANTHROPIC_API_KEY: in .env" || echo "ANTHROPIC_API_KEY: not in .env"
grep -q '^BRAVE_SEARCH_API_KEY=' .env 2>/dev/null && echo "BRAVE_SEARCH_API_KEY: in .env" || echo "BRAVE_SEARCH_API_KEY: not in .env"
```
#### Step 2c: HIVE_CREDENTIAL_KEY Check
If any credentials were found in the encrypted store metadata index, verify the encryption key is available. The key is typically persisted to shell config by a previous setup-credentials run.
Check both the current session AND shell config files:
```bash
# Check 1: Current session
printenv HIVE_CREDENTIAL_KEY > /dev/null 2>&1 && echo "session: set" || echo "session: not set"
# Check 2: Shell config files (where setup-credentials persists it)
# Note: check each file individually to avoid non-zero exit when one doesn't exist
for f in ~/.zshrc ~/.bashrc ~/.profile; do [ -f "$f" ] && grep -q 'HIVE_CREDENTIAL_KEY' "$f" && echo "$f"; done
```
Decision logic:
- **In current session** — no action needed, credentials in the store are usable
- **In shell config but NOT in current session** — the key is persisted but this shell hasn't sourced it. Run `source ~/.zshrc` (or `~/.bashrc`), then re-check. Credentials in the store are usable after sourcing.
- **Not in session AND not in shell config** — the key was never persisted. Warn the user that credentials in the store cannot be decrypted. Help fix the key situation (recover/re-persist), do NOT re-collect credential values that are already stored.
#### Step 2d: Compute Missing & Group
Diff the "needed" credentials against the "found" credentials to get the truly missing list.
Group related credentials using this table:
```
Groups:
google_custom_search: google_search + google_cse
```
When a group is detected, present all credentials in the group as a single setup step rather than asking for each one individually.
**If nothing is missing and there's no HIVE_CREDENTIAL_KEY issue:** Report all credentials as configured and skip Steps 3-5. Example:
```
All required credentials are already configured:
✓ anthropic (ANTHROPIC_API_KEY) — found in encrypted store
✓ brave_search (BRAVE_SEARCH_API_KEY) — found in environment
Your agent is ready to run!
```
**If credentials are missing:** Continue to Step 3 with only the missing ones.
### Step 3: Present Auth Options for Each Missing Credential
For each missing credential, check what authentication methods are available:
@@ -104,7 +166,7 @@ Present the available options using AskUserQuestion:
```
Choose how to configure HUBSPOT_ACCESS_TOKEN:
1) Aden Authorization Server (Recommended)
1) Aden Platform (OAuth) (Recommended)
Secure OAuth2 flow via integration.adenhq.com
- Quick setup with automatic token refresh
- No need to manage API keys manually
@@ -114,7 +176,7 @@ Choose how to configure HUBSPOT_ACCESS_TOKEN:
- Requires creating a HubSpot Private App
- Full control over scopes and permissions
3) Custom Credential Store (Advanced)
3) Local Credential Setup (Advanced)
Programmatic configuration for CI/CD
- For automated deployments
- Requires manual API calls
@@ -122,7 +184,7 @@ Choose how to configure HUBSPOT_ACCESS_TOKEN:
### Step 4: Execute Auth Flow Based on User Choice
#### Option 1: Aden Authorization Server
#### Option 1: Aden Platform (OAuth)
This is the recommended flow for supported integrations (HubSpot, etc.).
@@ -174,7 +236,7 @@ shell_type = detect_shell() # 'bash', 'zsh', or 'unknown'
success, config_path = add_env_var_to_shell_config(
"ADEN_API_KEY",
user_provided_key,
comment="Aden authorization server API key"
comment="Aden Platform (OAuth) API key"
)
if success:
@@ -313,7 +375,7 @@ if not result.valid:
# 2. Continue anyway (not recommended)
```
**4.2d. Store in Encrypted Credential Store**
**4.2d. Store in Local Encrypted Store**
```python
from core.framework.credentials import CredentialStore, CredentialObject, CredentialKey
@@ -340,7 +402,7 @@ store.save_credential(cred)
export HUBSPOT_ACCESS_TOKEN="the-value"
```
#### Option 3: Custom Credential Store (Advanced)
#### Option 3: Local Credential Setup (Advanced)
For programmatic/CI/CD setups.
@@ -408,10 +470,14 @@ Report the result to the user.
Health checks validate credentials by making lightweight API calls:
| Credential | Endpoint | What It Checks |
| -------------- | --------------------------------------- | --------------------------------- |
| `hubspot` | `GET /crm/v3/objects/contacts?limit=1` | Bearer token validity, CRM scopes |
| `brave_search` | `GET /res/v1/web/search?q=test&count=1` | API key validity |
| Credential | Endpoint | What It Checks |
| --------------- | --------------------------------------- | ---------------------------------- |
| `anthropic` | `POST /v1/messages` | API key validity |
| `brave_search` | `GET /res/v1/web/search?q=test&count=1` | API key validity |
| `google_search` | `GET /customsearch/v1?q=test&num=1` | API key + CSE ID validity |
| `github` | `GET /user` | Token validity, user identity |
| `hubspot` | `GET /crm/v3/objects/contacts?limit=1` | Bearer token validity, CRM scopes |
| `resend` | `GET /domains` | API key validity |
```python
from aden_tools.credentials import check_credential_health, HealthCheckResult
@@ -424,7 +490,7 @@ result: HealthCheckResult = check_credential_health("hubspot", token_value)
## Encryption Key (HIVE_CREDENTIAL_KEY)
The encrypted credential store requires `HIVE_CREDENTIAL_KEY` to encrypt/decrypt credentials.
The local encrypted store requires `HIVE_CREDENTIAL_KEY` to encrypt/decrypt credentials.
- If the user doesn't have one, `EncryptedFileStorage` will auto-generate one and log it
- The user MUST persist this key (e.g., in `~/.bashrc` or a secrets manager)
@@ -443,7 +509,7 @@ If `HIVE_CREDENTIAL_KEY` is not set:
- **NEVER** store credentials in plaintext files, git-tracked files, or agent configs
- **NEVER** hardcode credentials in source code
- **ALWAYS** use `SecretStr` from Pydantic when handling credential values in Python
- **ALWAYS** use the encrypted credential store (`~/.hive/credentials`) for persistence
- **ALWAYS** use the local encrypted store (`~/.hive/credentials`) for persistence
- **ALWAYS** run health checks before storing credentials (when possible)
- **ALWAYS** verify credentials were stored by re-running validation, not by reading them back
- When modifying `~/.bashrc` or `~/.zshrc`, confirm with the user first
@@ -456,7 +522,8 @@ All credential specs are defined in `tools/src/aden_tools/credentials/`:
| ----------------- | ------------- | --------------------------------------------- | -------------- |
| `llm.py` | LLM Providers | `anthropic` | No |
| `search.py` | Search Tools | `brave_search`, `google_search`, `google_cse` | No |
| `integrations.py` | Integrations | `hubspot` | Yes |
| `email.py` | Email | `resend` | No |
| `integrations.py` | Integrations | `github`, `hubspot` | No / Yes |
**Note:** Additional LLM providers (Cerebras, Groq, OpenAI) are handled by LiteLLM via environment
variables (`CEREBRAS_API_KEY`, `GROQ_API_KEY`, `OPENAI_API_KEY`) but are not yet in CREDENTIAL_SPECS.
@@ -507,66 +574,90 @@ token = store.get_key("hubspot", "access_token")
## Example Session
```
User: /setup-credentials for my hubspot-agent
User: /setup-credentials for my research-agent
Agent: Let me check what credentials your hubspot-agent needs.
Agent: Let me check what credentials your research-agent needs.
[Runs validation, finds ANTHROPIC_API_KEY and HUBSPOT_ACCESS_TOKEN missing]
[Reads agent config]
$ jq -r '.required_tools[]?' exports/research-agent/agent.json
web_search
google_search
Agent: Your hubspot-agent requires 2 credentials:
$ jq -r '.graph.nodes[]?.node_type' exports/research-agent/agent.json | sort -u
llm_tool_use
1. ANTHROPIC_API_KEY - Anthropic API key for LLM calls
2. HUBSPOT_ACCESS_TOKEN - HubSpot access token
[Maps tools/nodes to credentials using lookup table]
Needed: anthropic, brave_search, google_search, google_cse
Let's set these up one at a time.
[Checks encrypted store metadata index]
$ cat ~/.hive/credentials/metadata/index.json | jq -r '.credentials | keys[]'
anthropic
brave_search
--- Setting up ANTHROPIC_API_KEY ---
[Checks environment variables]
$ printenv ANTHROPIC_API_KEY > /dev/null 2>&1 && echo "set" || echo "not set"
not set
$ printenv BRAVE_SEARCH_API_KEY > /dev/null 2>&1 && echo "set" || echo "not set"
not set
$ printenv GOOGLE_API_KEY > /dev/null 2>&1 && echo "set" || echo "not set"
not set
$ printenv GOOGLE_CSE_ID > /dev/null 2>&1 && echo "set" || echo "not set"
not set
This credential only supports direct API key entry.
[Checks HIVE_CREDENTIAL_KEY since credentials found in store]
$ printenv HIVE_CREDENTIAL_KEY > /dev/null 2>&1 && echo "session: set" || echo "session: not set"
session: not set
$ for f in ~/.zshrc ~/.bashrc ~/.profile; do [ -f "$f" ] && grep -q 'HIVE_CREDENTIAL_KEY' "$f" && echo "$f"; done
/Users/user/.zshrc
[Shows instructions for getting Anthropic API key]
[AskUserQuestion: "Please provide your Anthropic API key:"]
[User provides key]
[Stores in credential store, exports to env]
[Key is in shell config but not current session — sourcing it]
$ source ~/.zshrc
--- Setting up HUBSPOT_ACCESS_TOKEN ---
[Computes missing credentials]
Found:
✓ anthropic (ANTHROPIC_API_KEY) — in encrypted store
✓ brave_search (BRAVE_SEARCH_API_KEY) — in encrypted store
Missing:
✗ google_search (GOOGLE_API_KEY)
✗ google_cse (GOOGLE_CSE_ID)
[AskUserQuestion with 3 options]
Agent: 2 of 4 required credentials are already configured. Only Google Custom
Search needs setup (2 values as a single group).
Choose how to configure HUBSPOT_ACCESS_TOKEN:
1) Aden Authorization Server (Recommended)
2) Direct API Key
3) Custom Credential Store (Advanced)
--- Setting up Google Custom Search (google_search + google_cse) ---
User: 1 (Aden)
This requires two values that work together.
Agent: Great! Let me check if you have an Aden API key configured...
First, the Google API Key:
1. Go to https://console.cloud.google.com/apis/credentials
2. Create a new project (or select an existing one)
3. Enable the "Custom Search API" from the API Library
4. Go to Credentials > Create Credentials > API Key
5. Copy the generated API key
[Checks for ADEN_API_KEY, not found]
[Opens browser to integration.adenhq.com/setup]
Agent: I've opened Aden's setup page. Please:
1. Sign in or create an account
2. Connect your HubSpot account (OAuth happens on Aden's site)
3. Copy the API key shown after setup
[AskUserQuestion: "Please provide your Aden API key:"]
[AskUserQuestion: "Please provide your Google API key:"]
[User provides key]
Agent: Would you like me to save this to your shell config (~/.zshrc) for future sessions?
Now, the Custom Search Engine ID:
1. Go to https://programmablesearchengine.google.com/controlpanel/all
2. Click "Add" to create a new search engine
3. Under "What to search", select "Search the entire web"
4. Give your search engine a name
5. Click "Create"
6. Copy the Search Engine ID (cx value)
[User approves]
[Saves to ~/.zshrc and ~/.hive/configuration.json]
[AskUserQuestion: "Please provide your Google CSE ID:"]
[User provides ID]
Agent: Let me sync your credentials from Aden...
[Runs health check with both values - GET /customsearch/v1?q=test&num=1 → 200 OK]
[Stores both in local encrypted store, exports to env]
[Syncs credentials from Aden server - OAuth already done on Aden's side]
[Runs health check]
Agent: HubSpot credentials validated successfully!
✓ Google Custom Search credentials valid
All credentials are now configured:
- ANTHROPIC_API_KEY: Stored in encrypted credential store
- HUBSPOT_ACCESS_TOKEN: Synced from Aden (OAuth completed on Aden)
- Validation passed - your agent is ready to run!
✓ anthropic (ANTHROPIC_API_KEY) — already in encrypted store
✓ brave_search (BRAVE_SEARCH_API_KEY) — already in encrypted store
✓ google_search (GOOGLE_API_KEY) — stored in encrypted store
✓ google_cse (GOOGLE_CSE_ID) — stored in encrypted store
Your agent is ready to run!
```
+3 -3
View File
@@ -3417,7 +3417,7 @@ def store_credential(
display_name: Annotated[str, "Human-readable name (e.g., 'HubSpot Access Token')"] = "",
) -> str:
"""
Store a credential securely in the encrypted credential store at ~/.hive/credentials.
Store a credential securely in the local encrypted store at ~/.hive/credentials.
Uses Fernet encryption (AES-128-CBC + HMAC). Requires HIVE_CREDENTIAL_KEY env var.
"""
@@ -3459,7 +3459,7 @@ def store_credential(
@mcp.tool()
def list_stored_credentials() -> str:
"""
List all credentials currently stored in the encrypted credential store.
List all credentials currently stored in the local encrypted store.
Returns credential IDs and metadata (never returns secret values).
"""
@@ -3499,7 +3499,7 @@ def delete_stored_credential(
credential_name: Annotated[str, "Logical credential name to delete (e.g., 'hubspot')"],
) -> str:
"""
Delete a credential from the encrypted credential store.
Delete a credential from the local encrypted store.
"""
try:
store = _get_credential_store()
+1 -1
View File
@@ -1757,7 +1757,7 @@ tools/src/aden_tools/credentials/
### Manual Testing
- [ ] Create encrypted credential store
- [ ] Create local encrypted store
- [ ] Save and load multi-key credentials
- [ ] Verify template resolution in tool headers
- [ ] Test OAuth2 token refresh
+3 -1
View File
@@ -17,7 +17,9 @@ class AgentMetadata:
version: str = "0.1.0"
description: str = "Multi-channel marketing content generator"
author: str = ""
tags: list[str] = field(default_factory=lambda: ["marketing", "content", "template"])
tags: list[str] = field(
default_factory=lambda: ["marketing", "content", "template"]
)
default_config = RuntimeConfig()
+3
View File
@@ -70,6 +70,9 @@ class CredentialSpec:
credential_key: str = "access_token"
"""Key name within the credential (e.g., 'access_token', 'api_key')"""
credential_group: str = ""
"""Group name for credentials that must be configured together (e.g., 'google_custom_search')"""
class CredentialError(Exception):
"""Raised when required credentials are missing."""
+17
View File
@@ -15,5 +15,22 @@ EMAIL_CREDENTIALS = {
startup_required=False,
help_url="https://resend.com/api-keys",
description="API key for Resend email service",
# Auth method support
direct_api_key_supported=True,
api_key_instructions="""To get a Resend API key:
1. Go to https://resend.com and create an account (or sign in)
2. Navigate to API Keys in the dashboard
3. Click "Create API Key"
4. Give it a name (e.g., "Hive Agent") and choose permissions:
- "Sending access" is sufficient for most use cases
- "Full access" if you also need to manage domains
5. Copy the API key (starts with re_)
6. Store it securely - you won't be able to see it again!
7. Note: You'll also need to verify a domain to send emails from custom addresses""",
# Health check configuration
health_check_endpoint="https://api.resend.com/domains",
# Credential store mapping
credential_id="resend",
credential_key="api_key",
),
}
@@ -231,10 +231,211 @@ class GoogleSearchHealthChecker:
)
class AnthropicHealthChecker:
"""Health checker for Anthropic API credentials."""
ENDPOINT = "https://api.anthropic.com/v1/messages"
TIMEOUT = 10.0
def check(self, api_key: str) -> HealthCheckResult:
"""
Validate Anthropic API key.
Sends a minimal request to the messages endpoint.
A 401 means invalid key; 400 (bad request) or 200 means the key is valid.
429 (rate limited) also indicates a valid key.
"""
try:
with httpx.Client(timeout=self.TIMEOUT) as client:
response = client.post(
self.ENDPOINT,
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
json={
"model": "claude-sonnet-4-20250514",
"max_tokens": 1,
"messages": [{"role": "user", "content": "hi"}],
},
)
if response.status_code == 200:
return HealthCheckResult(
valid=True,
message="Anthropic API key valid",
)
elif response.status_code == 401:
return HealthCheckResult(
valid=False,
message="Anthropic API key is invalid",
details={"status_code": 401},
)
elif response.status_code == 429:
# Rate limited but key is valid
return HealthCheckResult(
valid=True,
message="Anthropic API key valid (rate limited)",
details={"status_code": 429, "rate_limited": True},
)
elif response.status_code == 400:
# Bad request but key authenticated - key is valid
return HealthCheckResult(
valid=True,
message="Anthropic API key valid",
details={"status_code": 400},
)
else:
return HealthCheckResult(
valid=False,
message=f"Anthropic API returned status {response.status_code}",
details={"status_code": response.status_code},
)
except httpx.TimeoutException:
return HealthCheckResult(
valid=False,
message="Anthropic API request timed out",
details={"error": "timeout"},
)
except httpx.RequestError as e:
return HealthCheckResult(
valid=False,
message=f"Failed to connect to Anthropic API: {e}",
details={"error": str(e)},
)
class GitHubHealthChecker:
"""Health checker for GitHub Personal Access Token."""
ENDPOINT = "https://api.github.com/user"
TIMEOUT = 10.0
def check(self, access_token: str) -> HealthCheckResult:
"""
Validate GitHub token by fetching the authenticated user.
Returns the authenticated username on success.
"""
try:
with httpx.Client(timeout=self.TIMEOUT) as client:
response = client.get(
self.ENDPOINT,
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
if response.status_code == 200:
data = response.json()
username = data.get("login", "unknown")
return HealthCheckResult(
valid=True,
message=f"GitHub token valid (authenticated as {username})",
details={"username": username},
)
elif response.status_code == 401:
return HealthCheckResult(
valid=False,
message="GitHub token is invalid or expired",
details={"status_code": 401},
)
elif response.status_code == 403:
return HealthCheckResult(
valid=False,
message="GitHub token lacks required permissions",
details={"status_code": 403},
)
else:
return HealthCheckResult(
valid=False,
message=f"GitHub API returned status {response.status_code}",
details={"status_code": response.status_code},
)
except httpx.TimeoutException:
return HealthCheckResult(
valid=False,
message="GitHub API request timed out",
details={"error": "timeout"},
)
except httpx.RequestError as e:
return HealthCheckResult(
valid=False,
message=f"Failed to connect to GitHub API: {e}",
details={"error": str(e)},
)
class ResendHealthChecker:
"""Health checker for Resend API credentials."""
ENDPOINT = "https://api.resend.com/domains"
TIMEOUT = 10.0
def check(self, api_key: str) -> HealthCheckResult:
"""
Validate Resend API key by listing domains.
A successful response confirms the key is valid.
"""
try:
with httpx.Client(timeout=self.TIMEOUT) as client:
response = client.get(
self.ENDPOINT,
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
},
)
if response.status_code == 200:
return HealthCheckResult(
valid=True,
message="Resend API key valid",
)
elif response.status_code == 401:
return HealthCheckResult(
valid=False,
message="Resend API key is invalid",
details={"status_code": 401},
)
elif response.status_code == 403:
return HealthCheckResult(
valid=False,
message="Resend API key lacks required permissions",
details={"status_code": 403},
)
else:
return HealthCheckResult(
valid=False,
message=f"Resend API returned status {response.status_code}",
details={"status_code": response.status_code},
)
except httpx.TimeoutException:
return HealthCheckResult(
valid=False,
message="Resend API request timed out",
details={"error": "timeout"},
)
except httpx.RequestError as e:
return HealthCheckResult(
valid=False,
message=f"Failed to connect to Resend API: {e}",
details={"error": str(e)},
)
# Registry of health checkers
HEALTH_CHECKERS: dict[str, CredentialHealthChecker] = {
"hubspot": HubSpotHealthChecker(),
"brave_search": BraveSearchHealthChecker(),
"google_search": GoogleSearchHealthChecker(),
"anthropic": AnthropicHealthChecker(),
"github": GitHubHealthChecker(),
"resend": ResendHealthChecker(),
}
+15
View File
@@ -15,6 +15,21 @@ LLM_CREDENTIALS = {
startup_required=False, # MCP server doesn't need LLM credentials
help_url="https://console.anthropic.com/settings/keys",
description="API key for Anthropic Claude models",
# Auth method support
direct_api_key_supported=True,
api_key_instructions="""To get an Anthropic API key:
1. Go to https://console.anthropic.com/settings/keys
2. Sign in or create an Anthropic account
3. Click "Create Key"
4. Give your key a descriptive name (e.g., "Hive Agent")
5. Copy the API key (starts with sk-ant-)
6. Store it securely - you won't be able to see the full key again!""",
# Health check configuration
health_check_endpoint="https://api.anthropic.com/v1/messages",
health_check_method="POST",
# Credential store mapping
credential_id="anthropic",
credential_key="api_key",
),
# Future LLM providers:
# "openai": CredentialSpec(
+47 -2
View File
@@ -15,6 +15,20 @@ SEARCH_CREDENTIALS = {
startup_required=False,
help_url="https://brave.com/search/api/",
description="API key for Brave Search",
# Auth method support
direct_api_key_supported=True,
api_key_instructions="""To get a Brave Search API key:
1. Go to https://brave.com/search/api/
2. Create a Brave Search API account (or sign in)
3. Choose a plan (Free tier includes 2,000 queries/month)
4. Navigate to the API Keys section in your dashboard
5. Click "Create API Key" and give it a name
6. Copy the API key and store it securely""",
# Health check configuration
health_check_endpoint="https://api.search.brave.com/res/v1/web/search",
# Credential store mapping
credential_id="brave_search",
credential_key="api_key",
),
"google_search": CredentialSpec(
env_var="GOOGLE_API_KEY",
@@ -22,8 +36,24 @@ SEARCH_CREDENTIALS = {
node_types=[],
required=True,
startup_required=False,
help_url="https://console.cloud.google.com/",
help_url="https://console.cloud.google.com/apis/credentials",
description="API key for Google Custom Search",
# Auth method support
direct_api_key_supported=True,
api_key_instructions="""To get a Google Custom Search API key:
1. Go to https://console.cloud.google.com/apis/credentials
2. Create a new project (or select an existing one)
3. Enable the "Custom Search API" from the API Library
4. Go to Credentials > Create Credentials > API Key
5. Copy the generated API key
6. (Recommended) Click "Restrict Key" and limit it to the Custom Search API
7. Store the key securely""",
# Health check configuration
health_check_endpoint="https://www.googleapis.com/customsearch/v1",
# Credential store mapping
credential_id="google_search",
credential_key="api_key",
credential_group="google_custom_search",
),
"google_cse": CredentialSpec(
env_var="GOOGLE_CSE_ID",
@@ -31,7 +61,22 @@ SEARCH_CREDENTIALS = {
node_types=[],
required=True,
startup_required=False,
help_url="https://programmablesearchengine.google.com/",
help_url="https://programmablesearchengine.google.com/controlpanel/all",
description="Google Custom Search Engine ID",
# Auth method support
direct_api_key_supported=True,
api_key_instructions="""To get a Google Custom Search Engine (CSE) ID:
1. Go to https://programmablesearchengine.google.com/controlpanel/all
2. Click "Add" to create a new search engine
3. Under "What to search", select "Search the entire web"
4. Give your search engine a name (e.g., "Hive Agent Search")
5. Click "Create"
6. Copy the Search Engine ID (cx value) from the overview page""",
# Health check configuration
health_check_endpoint="https://www.googleapis.com/customsearch/v1",
# Credential store mapping
credential_id="google_cse",
credential_key="api_key",
credential_group="google_custom_search",
),
}
+35
View File
@@ -438,3 +438,38 @@ class TestStartupValidation:
# Should not raise
creds.validate_startup()
class TestSpecCompleteness:
"""Tests that all credential specs have required fields populated."""
def test_direct_api_key_specs_have_instructions(self):
"""All specs with direct_api_key_supported=True have non-empty api_key_instructions."""
for name, spec in CREDENTIAL_SPECS.items():
if spec.direct_api_key_supported:
assert spec.api_key_instructions.strip(), (
f"Credential '{name}' has direct_api_key_supported=True "
f"but empty api_key_instructions"
)
def test_all_specs_have_credential_id(self):
"""All credential specs have a non-empty credential_id."""
for name, spec in CREDENTIAL_SPECS.items():
assert spec.credential_id, f"Credential '{name}' is missing credential_id"
def test_google_search_and_cse_share_credential_group(self):
"""google_search and google_cse share the same credential_group."""
google_search = CREDENTIAL_SPECS["google_search"]
google_cse = CREDENTIAL_SPECS["google_cse"]
assert google_search.credential_group == "google_custom_search"
assert google_cse.credential_group == "google_custom_search"
assert google_search.credential_group == google_cse.credential_group
def test_credential_group_default_empty(self):
"""Specs without a group have empty credential_group."""
for name, spec in CREDENTIAL_SPECS.items():
if name not in ("google_search", "google_cse"):
assert (
spec.credential_group == ""
), f"Credential '{name}' has unexpected credential_group='{spec.credential_group}'"
+257
View File
@@ -0,0 +1,257 @@
"""Tests for credential health checkers."""
from unittest.mock import MagicMock, patch
import httpx
from aden_tools.credentials.health_check import (
HEALTH_CHECKERS,
AnthropicHealthChecker,
GitHubHealthChecker,
GoogleSearchHealthChecker,
ResendHealthChecker,
)
class TestHealthCheckerRegistry:
"""Tests for the HEALTH_CHECKERS registry."""
def test_google_search_registered(self):
"""GoogleSearchHealthChecker is registered in HEALTH_CHECKERS."""
assert "google_search" in HEALTH_CHECKERS
assert isinstance(HEALTH_CHECKERS["google_search"], GoogleSearchHealthChecker)
def test_anthropic_registered(self):
"""AnthropicHealthChecker is registered in HEALTH_CHECKERS."""
assert "anthropic" in HEALTH_CHECKERS
assert isinstance(HEALTH_CHECKERS["anthropic"], AnthropicHealthChecker)
def test_github_registered(self):
"""GitHubHealthChecker is registered in HEALTH_CHECKERS."""
assert "github" in HEALTH_CHECKERS
assert isinstance(HEALTH_CHECKERS["github"], GitHubHealthChecker)
def test_resend_registered(self):
"""ResendHealthChecker is registered in HEALTH_CHECKERS."""
assert "resend" in HEALTH_CHECKERS
assert isinstance(HEALTH_CHECKERS["resend"], ResendHealthChecker)
def test_all_expected_checkers_registered(self):
"""All expected health checkers are in the registry."""
expected = {"hubspot", "brave_search", "google_search", "anthropic", "github", "resend"}
assert set(HEALTH_CHECKERS.keys()) == expected
class TestAnthropicHealthChecker:
"""Tests for AnthropicHealthChecker."""
def _mock_response(self, status_code, json_data=None):
response = MagicMock(spec=httpx.Response)
response.status_code = status_code
if json_data:
response.json.return_value = json_data
return response
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_valid_key_200(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = self._mock_response(200)
checker = AnthropicHealthChecker()
result = checker.check("sk-ant-test-key")
assert result.valid is True
assert "valid" in result.message.lower()
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_invalid_key_401(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = self._mock_response(401)
checker = AnthropicHealthChecker()
result = checker.check("invalid-key")
assert result.valid is False
assert result.details["status_code"] == 401
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_rate_limited_429(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = self._mock_response(429)
checker = AnthropicHealthChecker()
result = checker.check("sk-ant-test-key")
assert result.valid is True
assert result.details.get("rate_limited") is True
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_bad_request_400_still_valid(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = self._mock_response(400)
checker = AnthropicHealthChecker()
result = checker.check("sk-ant-test-key")
assert result.valid is True
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_timeout(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.post.side_effect = httpx.TimeoutException("timed out")
checker = AnthropicHealthChecker()
result = checker.check("sk-ant-test-key")
assert result.valid is False
assert result.details["error"] == "timeout"
class TestGitHubHealthChecker:
"""Tests for GitHubHealthChecker."""
def _mock_response(self, status_code, json_data=None):
response = MagicMock(spec=httpx.Response)
response.status_code = status_code
if json_data:
response.json.return_value = json_data
return response
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_valid_token_200(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = self._mock_response(200, {"login": "testuser"})
checker = GitHubHealthChecker()
result = checker.check("ghp_test-token")
assert result.valid is True
assert "testuser" in result.message
assert result.details["username"] == "testuser"
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_invalid_token_401(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = self._mock_response(401)
checker = GitHubHealthChecker()
result = checker.check("invalid-token")
assert result.valid is False
assert result.details["status_code"] == 401
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_forbidden_403(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = self._mock_response(403)
checker = GitHubHealthChecker()
result = checker.check("ghp_test-token")
assert result.valid is False
assert result.details["status_code"] == 403
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_timeout(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.get.side_effect = httpx.TimeoutException("timed out")
checker = GitHubHealthChecker()
result = checker.check("ghp_test-token")
assert result.valid is False
assert result.details["error"] == "timeout"
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_request_error(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.get.side_effect = httpx.RequestError("connection failed")
checker = GitHubHealthChecker()
result = checker.check("ghp_test-token")
assert result.valid is False
assert "connection failed" in result.details["error"]
class TestResendHealthChecker:
"""Tests for ResendHealthChecker."""
def _mock_response(self, status_code, json_data=None):
response = MagicMock(spec=httpx.Response)
response.status_code = status_code
if json_data:
response.json.return_value = json_data
return response
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_valid_key_200(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = self._mock_response(200)
checker = ResendHealthChecker()
result = checker.check("re_test-key")
assert result.valid is True
assert "valid" in result.message.lower()
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_invalid_key_401(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = self._mock_response(401)
checker = ResendHealthChecker()
result = checker.check("invalid-key")
assert result.valid is False
assert result.details["status_code"] == 401
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_forbidden_403(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = self._mock_response(403)
checker = ResendHealthChecker()
result = checker.check("re_test-key")
assert result.valid is False
assert result.details["status_code"] == 403
@patch("aden_tools.credentials.health_check.httpx.Client")
def test_timeout(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_client.get.side_effect = httpx.TimeoutException("timed out")
checker = ResendHealthChecker()
result = checker.check("re_test-key")
assert result.valid is False
assert result.details["error"] == "timeout"