fix: google tools need healthcheck

This commit is contained in:
Timothy
2026-02-18 23:07:12 -08:00
parent 5605e24a0d
commit 52a56e4a10
6 changed files with 182 additions and 142 deletions
+5 -6
View File
@@ -584,17 +584,16 @@ def detect_missing_credentials_from_nodes(nodes: list) -> list[MissingCredential
if hasattr(node, "node_type"):
node_types.add(node.node_type)
# Build credential store to check availability
# Build credential store to check availability.
# Env vars take priority over encrypted store (fresh key wins over stale).
env_mapping = {
(spec.credential_id or name): spec.env_var for name, spec in CREDENTIAL_SPECS.items()
}
storages: list = [EnvVarStorage(env_mapping=env_mapping)]
env_storage = EnvVarStorage(env_mapping=env_mapping)
if os.environ.get("HIVE_CREDENTIAL_KEY"):
storages.insert(0, EncryptedFileStorage())
if len(storages) == 1:
storage = storages[0]
storage = CompositeStorage(primary=env_storage, fallbacks=[EncryptedFileStorage()])
else:
storage = CompositeStorage(primary=storages[0], fallbacks=storages[1:])
storage = env_storage
store = CredentialStore(storage=storage)
# Build reverse mappings
+72 -22
View File
@@ -47,18 +47,18 @@ class _CredentialCheck:
help_url: str = ""
def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
"""Check that required credentials are available before running an agent.
def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool = True) -> None:
"""Check that required credentials are available and valid before running an agent.
Uses CredentialStoreAdapter.default() which includes Aden sync support,
correctly resolving OAuth credentials stored under hashed IDs.
Prints a summary of all credentials and their sources (encrypted store, env var).
Raises CredentialError with actionable guidance if any are missing.
Two-phase validation:
1. **Presence** is the credential set (env var, encrypted store, or Aden sync)?
2. **Health check** does the credential actually work? Uses each tool's
registered ``check_credential_health`` endpoint (lightweight HTTP call).
Args:
nodes: List of NodeSpec objects from the agent graph.
quiet: If True, suppress the credential summary output.
verify: If True (default), run health checks on present credentials.
"""
# Collect required tools and node types
required_tools = {tool for node in nodes if node.tools for tool in node.tools}
@@ -72,17 +72,17 @@ def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
from framework.credentials.storage import CompositeStorage, EncryptedFileStorage, EnvVarStorage
from framework.credentials.store import CredentialStore
# Build credential store
# Build credential store.
# Env vars take priority — if a user explicitly exports a fresh key it
# must win over a potentially stale value in the encrypted store.
env_mapping = {
(spec.credential_id or name): spec.env_var for name, spec in CREDENTIAL_SPECS.items()
}
storages: list = [EnvVarStorage(env_mapping=env_mapping)]
env_storage = EnvVarStorage(env_mapping=env_mapping)
if os.environ.get("HIVE_CREDENTIAL_KEY"):
storages.insert(0, EncryptedFileStorage())
if len(storages) == 1:
storage = storages[0]
storage = CompositeStorage(primary=env_storage, fallbacks=[EncryptedFileStorage()])
else:
storage = CompositeStorage(primary=storages[0], fallbacks=storages[1:])
storage = env_storage
store = CredentialStore(storage=storage)
# Build reverse mappings
@@ -95,7 +95,10 @@ def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
node_type_to_cred[nt] = cred_name
missing: list[str] = []
invalid: list[str] = []
checked: set[str] = set()
# Credentials that are present and should be health-checked
to_verify: list[tuple[str, str]] = [] # (cred_name, used_by_label)
# Check tool credentials
for tool_name in sorted(required_tools):
@@ -105,12 +108,17 @@ def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
checked.add(cred_name)
spec = CREDENTIAL_SPECS[cred_name]
cred_id = spec.credential_id or cred_name
if spec.required and not store.is_available(cred_id):
affected = sorted(t for t in required_tools if t in spec.tools)
entry = f" {spec.env_var} for {', '.join(affected)}"
if not spec.required:
continue
affected = sorted(t for t in required_tools if t in spec.tools)
label = ", ".join(affected)
if not store.is_available(cred_id):
entry = f" {spec.env_var} for {label}"
if spec.help_url:
entry += f"\n Get it at: {spec.help_url}"
missing.append(entry)
elif verify and spec.health_check_endpoint:
to_verify.append((cred_name, label))
# Check node type credentials (e.g., ANTHROPIC_API_KEY for LLM nodes)
for nt in sorted(node_types):
@@ -120,18 +128,60 @@ def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
checked.add(cred_name)
spec = CREDENTIAL_SPECS[cred_name]
cred_id = spec.credential_id or cred_name
if spec.required and not store.is_available(cred_id):
affected_types = sorted(t for t in node_types if t in spec.node_types)
entry = f" {spec.env_var} for {', '.join(affected_types)} nodes"
if not spec.required:
continue
affected_types = sorted(t for t in node_types if t in spec.node_types)
label = ", ".join(affected_types) + " nodes"
if not store.is_available(cred_id):
entry = f" {spec.env_var} for {label}"
if spec.help_url:
entry += f"\n Get it at: {spec.help_url}"
missing.append(entry)
elif verify and spec.health_check_endpoint:
to_verify.append((cred_name, label))
if missing:
# Phase 2: health-check present credentials
if to_verify:
try:
from aden_tools.credentials import check_credential_health
except ImportError:
check_credential_health = None # type: ignore[assignment]
if check_credential_health is not None:
for cred_name, label in to_verify:
spec = CREDENTIAL_SPECS[cred_name]
cred_id = spec.credential_id or cred_name
value = store.get(cred_id)
if not value:
continue
try:
result = check_credential_health(
cred_name,
value,
health_check_endpoint=spec.health_check_endpoint,
health_check_method=spec.health_check_method,
)
if not result.valid:
entry = f" {spec.env_var} for {label}{result.message}"
if spec.help_url:
entry += f"\n Get a new key at: {spec.help_url}"
invalid.append(entry)
except Exception as exc:
logger.debug("Health check for %s failed: %s", cred_name, exc)
errors = missing + invalid
if errors:
from framework.credentials.models import CredentialError
lines = ["Missing required credentials:\n"]
lines.extend(missing)
lines: list[str] = []
if missing:
lines.append("Missing credentials:\n")
lines.extend(missing)
if invalid:
if missing:
lines.append("")
lines.append("Invalid or expired credentials:\n")
lines.extend(invalid)
lines.append(
"\nTo fix: run /hive-credentials in Claude Code."
"\nIf you've already set up credentials, restart your terminal to load them."
+6 -88
View File
@@ -500,6 +500,7 @@ def cmd_run(args: argparse.Namespace) -> int:
try:
# Load runner inside the async loop to ensure strict loop affinity
# (only one load — avoids spawning duplicate MCP subprocesses)
# AgentRunner handles credential setup interactively when stdin is a TTY.
try:
runner = AgentRunner.load(
args.agent_path,
@@ -507,36 +508,7 @@ def cmd_run(args: argparse.Namespace) -> int:
)
except CredentialError as e:
print(f"\n{e}", file=sys.stderr)
# Offer interactive credential setup if running in a terminal
if sys.stdin.isatty():
print()
try:
choice = input("Would you like to set up credentials now? [Y/n]: ")
choice = choice.strip()
except (EOFError, KeyboardInterrupt):
print()
return
if choice.lower() != "n":
from framework.credentials.setup import CredentialSetupSession
session = CredentialSetupSession.from_agent_path(args.agent_path)
result = session.run_interactive()
if result.success:
# Retry loading with credentials now configured
try:
runner = AgentRunner.load(args.agent_path, model=args.model)
except CredentialError as retry_e:
print(f"\n{retry_e}", file=sys.stderr)
return
except Exception as retry_e:
print(f"Error loading agent: {retry_e}")
return
else:
return
else:
return
else:
return
return
except Exception as e:
print(f"Error loading agent: {e}")
return
@@ -588,6 +560,7 @@ def cmd_run(args: argparse.Namespace) -> int:
return 0
else:
# Standard execution — load runner here (not shared with TUI path)
# AgentRunner handles credential setup interactively when stdin is a TTY.
try:
runner = AgentRunner.load(
args.agent_path,
@@ -595,35 +568,7 @@ def cmd_run(args: argparse.Namespace) -> int:
)
except CredentialError as e:
print(f"\n{e}", file=sys.stderr)
# Offer interactive credential setup if running in a terminal
if sys.stdin.isatty():
print()
try:
choice = input("Would you like to set up credentials now? [Y/n]: ").strip()
except (EOFError, KeyboardInterrupt):
print()
return 1
if choice.lower() != "n":
from framework.credentials.setup import CredentialSetupSession
session = CredentialSetupSession.from_agent_path(args.agent_path)
result = session.run_interactive()
if result.success:
# Retry loading with credentials now configured
try:
runner = AgentRunner.load(args.agent_path, model=args.model)
except CredentialError as retry_e:
print(f"\n{retry_e}", file=sys.stderr)
return 1
except Exception as retry_e:
print(f"Error loading agent: {retry_e}")
return 1
else:
return 1
else:
return 1
else:
return 1
return 1
except FileNotFoundError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
@@ -1394,6 +1339,7 @@ def _launch_agent_tui(
from framework.tui.app import AdenTUI
async def run_with_tui():
# AgentRunner handles credential setup interactively when stdin is a TTY.
try:
runner = AgentRunner.load(
agent_path,
@@ -1401,35 +1347,7 @@ def _launch_agent_tui(
)
except CredentialError as e:
print(f"\n{e}", file=sys.stderr)
# Offer interactive credential setup if running in a terminal
if sys.stdin.isatty():
print()
try:
choice = input("Would you like to set up credentials now? [Y/n]: ").strip()
except (EOFError, KeyboardInterrupt):
print()
return
if choice.lower() != "n":
from framework.credentials.setup import CredentialSetupSession
session = CredentialSetupSession.from_agent_path(agent_path)
result = session.run_interactive()
if result.success:
# Retry loading with credentials now configured
try:
runner = AgentRunner.load(agent_path, model=model)
except CredentialError as retry_e:
print(f"\n{retry_e}", file=sys.stderr)
return
except Exception as retry_e:
print(f"Error loading agent: {retry_e}")
return
else:
return
else:
return
else:
return
return
except Exception as e:
print(f"Error loading agent: {e}")
return
+38 -2
View File
@@ -414,9 +414,45 @@ class AgentRunner:
def _validate_credentials(self) -> None:
"""Check that required credentials are available before spawning MCP servers.
Raises CredentialError with actionable guidance if any are missing.
If validation fails and stdin is a TTY, automatically launches the
interactive credential setup flow so the user can fix the issue
in-place. Re-validates after setup succeeds.
Raises CredentialError only if setup is skipped/fails or stdin is
not interactive.
"""
validate_agent_credentials(self.graph.nodes)
import sys
from framework.credentials.models import CredentialError
try:
validate_agent_credentials(self.graph.nodes)
return # All good
except CredentialError as e:
if not sys.stdin.isatty():
raise
# Interactive: show the error then enter credential setup
print(f"\n{e}", file=sys.stderr)
from framework.credentials.setup import CredentialSetupSession
session = CredentialSetupSession.from_nodes(self.graph.nodes)
if not session.missing:
# Health-check-only failure (creds present but invalid) —
# nothing for setup to collect. Re-raise so the user
# knows they need new keys.
raise
result = session.run_interactive()
if not result.success:
raise CredentialError(
"Credential setup incomplete. "
"Run again after configuring the required credentials."
) from None
# Re-validate after setup
validate_agent_credentials(self.graph.nodes)
@staticmethod
def _import_agent_module(agent_path: Path):
@@ -162,56 +162,59 @@ class BraveSearchHealthChecker:
)
class GoogleCalendarHealthChecker:
"""Health checker for Google Calendar OAuth tokens."""
class OAuthBearerHealthChecker:
"""Generic health checker for OAuth2 Bearer token credentials.
Validates by making a GET request with ``Authorization: Bearer <token>``
to the given endpoint. Reused for Google Gmail, Google Calendar, and as
the automatic fallback for any credential spec that defines a
``health_check_endpoint`` but has no dedicated checker.
"""
ENDPOINT = "https://www.googleapis.com/calendar/v3/users/me/calendarList"
TIMEOUT = 10.0
def check(self, access_token: str) -> HealthCheckResult:
"""
Validate Google Calendar token by making lightweight API call.
def __init__(self, endpoint: str, service_name: str = "Service"):
self.endpoint = endpoint
self.service_name = service_name
Makes a GET request for 1 calendar to verify the token works.
"""
def check(self, access_token: str) -> HealthCheckResult:
try:
with httpx.Client(timeout=self.TIMEOUT) as client:
response = client.get(
self.ENDPOINT,
self.endpoint,
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
params={"maxResults": "1"},
)
if response.status_code == 200:
return HealthCheckResult(
valid=True,
message="Google Calendar credentials valid",
message=f"{self.service_name} credentials valid",
)
elif response.status_code == 401:
return HealthCheckResult(
valid=False,
message="Google Calendar token is invalid or expired",
message=f"{self.service_name} token is invalid or expired",
details={"status_code": 401},
)
elif response.status_code == 403:
return HealthCheckResult(
valid=False,
message="Google Calendar token lacks required scopes",
details={"status_code": 403, "required": "calendar"},
message=f"{self.service_name} token lacks required scopes",
details={"status_code": 403},
)
else:
return HealthCheckResult(
valid=False,
message=f"Google Calendar API returned status {response.status_code}",
message=f"{self.service_name} API returned status {response.status_code}",
details={"status_code": response.status_code},
)
except httpx.TimeoutException:
return HealthCheckResult(
valid=False,
message="Google Calendar API request timed out",
message=f"{self.service_name} API request timed out",
details={"error": "timeout"},
)
except httpx.RequestError as e:
@@ -220,11 +223,21 @@ class GoogleCalendarHealthChecker:
error_msg = "Request failed (details redacted for security)"
return HealthCheckResult(
valid=False,
message=f"Failed to connect to Google Calendar: {error_msg}",
message=f"Failed to connect to {self.service_name}: {error_msg}",
details={"error": error_msg},
)
class GoogleCalendarHealthChecker(OAuthBearerHealthChecker):
"""Health checker for Google Calendar OAuth tokens."""
def __init__(self):
super().__init__(
endpoint="https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=1",
service_name="Google Calendar",
)
class GoogleSearchHealthChecker:
"""Health checker for Google Custom Search API."""
@@ -679,12 +692,23 @@ class GoogleMapsHealthChecker:
)
class GoogleGmailHealthChecker(OAuthBearerHealthChecker):
"""Health checker for Google Gmail OAuth tokens."""
def __init__(self):
super().__init__(
endpoint="https://gmail.googleapis.com/gmail/v1/users/me/profile",
service_name="Gmail",
)
# Registry of health checkers
HEALTH_CHECKERS: dict[str, CredentialHealthChecker] = {
"discord": DiscordHealthChecker(),
"hubspot": HubSpotHealthChecker(),
"brave_search": BraveSearchHealthChecker(),
"google_calendar_oauth": GoogleCalendarHealthChecker(),
"google": GoogleGmailHealthChecker(),
"slack": SlackHealthChecker(),
"google_search": GoogleSearchHealthChecker(),
"google_maps": GoogleMapsHealthChecker(),
@@ -705,7 +729,12 @@ def check_credential_health(
Args:
credential_name: Name of the credential (e.g., 'hubspot', 'brave_search')
credential_value: The credential value to validate
**kwargs: Additional arguments passed to the checker (e.g., cse_id for Google)
**kwargs: Additional arguments passed to the checker.
- cse_id: CSE ID for Google Custom Search
- health_check_endpoint: Fallback endpoint URL when no dedicated
checker is registered. Used automatically by
``validate_agent_credentials`` from the credential spec.
- health_check_method: HTTP method for fallback (default GET).
Returns:
HealthCheckResult with validation status
@@ -720,12 +749,19 @@ def check_credential_health(
checker = HEALTH_CHECKERS.get(credential_name)
if checker is None:
# No health checker registered - assume valid
return HealthCheckResult(
valid=True,
message=f"No health checker for '{credential_name}', assuming valid",
details={"no_checker": True},
)
# No dedicated checker — try generic fallback using the spec's endpoint
endpoint = kwargs.get("health_check_endpoint")
if endpoint:
checker = OAuthBearerHealthChecker(
endpoint=endpoint,
service_name=credential_name.replace("_", " ").title(),
)
else:
return HealthCheckResult(
valid=True,
message=f"No health checker for '{credential_name}', assuming valid",
details={"no_checker": True},
)
# Special case for Google which needs CSE ID
if credential_name == "google_search" and "cse_id" in kwargs:
+1
View File
@@ -66,6 +66,7 @@ class TestHealthCheckerRegistry:
"github",
"resend",
"google_calendar_oauth",
"google",
"slack",
"discord",
}