Merge branch 'fix/google-tool-healthcheck' into feature/tui-credential-setup
This commit is contained in:
+9
-8
@@ -24,6 +24,7 @@ Ctrl+E # or /coder in chat
|
||||
```
|
||||
|
||||
The Coder ships with:
|
||||
|
||||
- **Reference documentation** -- anti-patterns, construction guide, and design patterns baked into its system prompt
|
||||
- **Guardian watchdog** -- an event-driven monitor that catches agent failures and triggers automatic remediation
|
||||
- **Coder Tools MCP server** -- file I/O, fuzzy-match editing, git snapshots, and sandboxed shell execution (`tools/coder_tools_server.py`)
|
||||
@@ -90,13 +91,13 @@ The quickstart script auto-detects Claude Code subscriptions and ZAI Code instal
|
||||
|
||||
### New Tool Integrations
|
||||
|
||||
| Tool | Description | Contributor |
|
||||
|------|-------------|-------------|
|
||||
| **Discord** | 4 MCP tools (`discord_list_guilds`, `discord_list_channels`, `discord_send_message`, `discord_get_messages`) with rate-limit retry and channel filtering | @mishrapravin114 |
|
||||
| **Exa Search API** | 4 AI-powered search tools (`exa_search`, `exa_find_similar`, `exa_get_contents`, `exa_answer`) with neural/keyword search, domain filters, and citation-backed answers | @JeetKaria06 |
|
||||
| **Razorpay** | 6 payment processing tools for payments, invoices, payment links, and refunds with HTTP Basic Auth | @shivamshahi07 |
|
||||
| **Google Docs** | Document creation, reading, and editing with OAuth credential support | @haliaeetusvocifer |
|
||||
| **Gmail enhancements** | Expanded mail operations for inbox management | @bryanadenhq |
|
||||
| Tool | Description | Contributor |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
|
||||
| **Discord** | 4 MCP tools (`discord_list_guilds`, `discord_list_channels`, `discord_send_message`, `discord_get_messages`) with rate-limit retry and channel filtering | @mishrapravin114 |
|
||||
| **Exa Search API** | 4 AI-powered search tools (`exa_search`, `exa_find_similar`, `exa_get_contents`, `exa_answer`) with neural/keyword search, domain filters, and citation-backed answers | @JeetKaria06 |
|
||||
| **Razorpay** | 6 payment processing tools for payments, invoices, payment links, and refunds with HTTP Basic Auth | @shivamshahi07 |
|
||||
| **Google Docs** | Document creation, reading, and editing with OAuth credential support | @haliaeetusvocifer |
|
||||
| **Gmail enhancements** | Expanded mail operations for inbox management | @bryanadenhq |
|
||||
|
||||
### Infrastructure
|
||||
|
||||
@@ -184,7 +185,7 @@ NodeSpec(node_type="function", function=my_func, ...)
|
||||
NodeSpec(node_type="event_loop", ...) # or just omit node_type (it's the default now)
|
||||
```
|
||||
|
||||
If your agents set `max_node_visits=1` explicitly, they'll still work. The only change is the *default* -- new agents without an explicit value now get unlimited visits.
|
||||
If your agents set `max_node_visits=1` explicitly, they'll still work. The only change is the _default_ -- new agents without an explicit value now get unlimited visits.
|
||||
|
||||
To try the new Hive Coder:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,18 +47,50 @@ 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 _presync_aden_tokens(credential_specs: dict) -> None:
|
||||
"""Sync Aden-backed OAuth tokens into env vars for validation.
|
||||
|
||||
Uses CredentialStoreAdapter.default() which includes Aden sync support,
|
||||
correctly resolving OAuth credentials stored under hashed IDs.
|
||||
When ADEN_API_KEY is available, fetches fresh OAuth tokens from the Aden
|
||||
server and exports them to env vars. This ensures validation sees real
|
||||
tokens instead of stale or mis-stored values in the encrypted store.
|
||||
Only touches credentials that are ``aden_supported`` AND whose env var
|
||||
is not already set (so explicit user exports always win).
|
||||
"""
|
||||
from framework.credentials.store import CredentialStore
|
||||
|
||||
Prints a summary of all credentials and their sources (encrypted store, env var).
|
||||
Raises CredentialError with actionable guidance if any are missing.
|
||||
try:
|
||||
aden_store = CredentialStore.with_aden_sync(auto_sync=True)
|
||||
except Exception:
|
||||
logger.debug("Aden pre-sync unavailable, skipping")
|
||||
return
|
||||
|
||||
for name, spec in credential_specs.items():
|
||||
if not spec.aden_supported:
|
||||
continue
|
||||
if os.environ.get(spec.env_var):
|
||||
continue # Already set — don't overwrite
|
||||
cred_id = spec.credential_id or name
|
||||
try:
|
||||
value = aden_store.get_key(cred_id, spec.credential_key)
|
||||
if value:
|
||||
os.environ[spec.env_var] = value
|
||||
logger.debug("Pre-synced %s from Aden", spec.env_var)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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 +104,24 @@ 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.
|
||||
#
|
||||
# Pre-sync: when ADEN_API_KEY is available, sync OAuth tokens from Aden
|
||||
# into env vars so validation sees fresh tokens instead of stale values
|
||||
# in the encrypted store (e.g., a previously mis-stored google.enc).
|
||||
if os.environ.get("ADEN_API_KEY"):
|
||||
_presync_aden_tokens(CREDENTIAL_SPECS)
|
||||
|
||||
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 +134,11 @@ def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
|
||||
node_type_to_cred[nt] = cred_name
|
||||
|
||||
missing: list[str] = []
|
||||
invalid: list[str] = []
|
||||
failed_cred_names: list[str] = [] # all cred names that need (re-)collection
|
||||
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 +148,18 @@ 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)
|
||||
failed_cred_names.append(cred_name)
|
||||
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,20 +169,128 @@ 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)
|
||||
failed_cred_names.append(cred_name)
|
||||
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)
|
||||
failed_cred_names.append(cred_name)
|
||||
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."
|
||||
)
|
||||
raise CredentialError("\n".join(lines))
|
||||
exc = CredentialError("\n".join(lines))
|
||||
exc.failed_cred_names = failed_cred_names # type: ignore[attr-defined]
|
||||
raise exc
|
||||
|
||||
|
||||
def build_setup_session_from_error(
|
||||
credential_error: Exception,
|
||||
nodes: list | None = None,
|
||||
agent_path: str | None = None,
|
||||
):
|
||||
"""Build a ``CredentialSetupSession`` that covers all failed credentials.
|
||||
|
||||
``validate_agent_credentials`` attaches ``failed_cred_names`` (both missing
|
||||
and invalid) to the ``CredentialError``. This helper converts those names
|
||||
into ``MissingCredential`` entries so the setup screen can re-collect them.
|
||||
|
||||
Falls back to the normal ``from_nodes`` / ``from_agent_path`` detection
|
||||
when the attribute is absent.
|
||||
|
||||
Args:
|
||||
credential_error: The ``CredentialError`` raised by validation.
|
||||
nodes: Graph nodes (preferred — avoids re-loading from disk).
|
||||
agent_path: Agent directory path (used when nodes aren't available).
|
||||
"""
|
||||
from framework.credentials.setup import CredentialSetupSession, MissingCredential
|
||||
|
||||
# Start with normal detection (picks up truly missing creds)
|
||||
if nodes is not None:
|
||||
session = CredentialSetupSession.from_nodes(nodes)
|
||||
elif agent_path is not None:
|
||||
session = CredentialSetupSession.from_agent_path(agent_path)
|
||||
else:
|
||||
session = CredentialSetupSession(missing=[])
|
||||
|
||||
# Add credentials that are present but failed health checks
|
||||
already = {m.credential_name for m in session.missing}
|
||||
failed_names: list[str] = getattr(credential_error, "failed_cred_names", [])
|
||||
if failed_names:
|
||||
try:
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
|
||||
for name in failed_names:
|
||||
if name in already:
|
||||
continue
|
||||
spec = CREDENTIAL_SPECS.get(name)
|
||||
if spec is None:
|
||||
continue
|
||||
session.missing.append(
|
||||
MissingCredential(
|
||||
credential_name=name,
|
||||
env_var=spec.env_var,
|
||||
description=spec.description,
|
||||
help_url=spec.help_url,
|
||||
api_key_instructions=spec.api_key_instructions,
|
||||
tools=list(spec.tools),
|
||||
aden_supported=spec.aden_supported,
|
||||
direct_api_key_supported=spec.direct_api_key_supported,
|
||||
credential_id=spec.credential_id,
|
||||
credential_key=spec.credential_key,
|
||||
)
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return session
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -350,6 +350,7 @@ class AgentRunner:
|
||||
model: str | None = None,
|
||||
intro_message: str = "",
|
||||
runtime_config: "AgentRuntimeConfig | None" = None,
|
||||
interactive: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize the runner (use AgentRunner.load() instead).
|
||||
@@ -363,6 +364,8 @@ class AgentRunner:
|
||||
model: Model to use (reads from agent config or ~/.hive/configuration.json if None)
|
||||
intro_message: Optional greeting shown to user on TUI load
|
||||
runtime_config: Optional AgentRuntimeConfig (webhook settings, etc.)
|
||||
interactive: If True (default), offer interactive credential setup on failure.
|
||||
Set to False when called from the TUI (which handles setup via its own screen).
|
||||
"""
|
||||
self.agent_path = agent_path
|
||||
self.graph = graph
|
||||
@@ -371,6 +374,7 @@ class AgentRunner:
|
||||
self.model = model or self._resolve_default_model()
|
||||
self.intro_message = intro_message
|
||||
self.runtime_config = runtime_config
|
||||
self._interactive = interactive
|
||||
|
||||
# Set up storage
|
||||
if storage_path:
|
||||
@@ -414,9 +418,47 @@ 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 ``interactive`` is True 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.
|
||||
|
||||
When ``interactive`` is False (e.g. TUI callers), the CredentialError
|
||||
propagates immediately so the caller can handle it with its own UI.
|
||||
"""
|
||||
validate_agent_credentials(self.graph.nodes)
|
||||
if not self._interactive:
|
||||
# Let the CredentialError propagate — caller handles UI.
|
||||
validate_agent_credentials(self.graph.nodes)
|
||||
return
|
||||
|
||||
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.validation import build_setup_session_from_error
|
||||
|
||||
session = build_setup_session_from_error(e, nodes=self.graph.nodes)
|
||||
if not session.missing:
|
||||
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):
|
||||
@@ -461,6 +503,7 @@ class AgentRunner:
|
||||
mock_mode: bool = False,
|
||||
storage_path: Path | None = None,
|
||||
model: str | None = None,
|
||||
interactive: bool = True,
|
||||
) -> "AgentRunner":
|
||||
"""
|
||||
Load an agent from an export folder.
|
||||
@@ -474,6 +517,8 @@ class AgentRunner:
|
||||
mock_mode: If True, use mock LLM responses
|
||||
storage_path: Path for runtime storage (defaults to ~/.hive/agents/{name})
|
||||
model: LLM model to use (reads from agent's default_config if None)
|
||||
interactive: If True (default), offer interactive credential setup.
|
||||
Set to False from TUI callers that handle setup via their own UI.
|
||||
|
||||
Returns:
|
||||
AgentRunner instance ready to run
|
||||
@@ -550,6 +595,7 @@ class AgentRunner:
|
||||
model=model,
|
||||
intro_message=intro_message,
|
||||
runtime_config=agent_runtime_config,
|
||||
interactive=interactive,
|
||||
)
|
||||
|
||||
# Fallback: load from agent.json (legacy JSON-based agents)
|
||||
@@ -567,6 +613,7 @@ class AgentRunner:
|
||||
mock_mode=mock_mode,
|
||||
storage_path=storage_path,
|
||||
model=model,
|
||||
interactive=interactive,
|
||||
)
|
||||
|
||||
def register_tool(
|
||||
|
||||
+47
-20
@@ -353,11 +353,21 @@ class AdenTUI(App):
|
||||
self.status_bar.set_graph_id(f"Loading {agent_name}...")
|
||||
self.notify(f"Loading agent: {agent_name}...", timeout=3)
|
||||
|
||||
# 3. Load new agent
|
||||
# 3. Load new agent (run blocking I/O in thread to avoid freezing the TUI)
|
||||
import asyncio
|
||||
import functools
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
runner = AgentRunner.load(agent_path, model=self._model)
|
||||
load_fn = functools.partial(
|
||||
AgentRunner.load,
|
||||
agent_path,
|
||||
model=self._model,
|
||||
interactive=False,
|
||||
)
|
||||
runner = await loop.run_in_executor(None, load_fn)
|
||||
if runner._agent_runtime is None:
|
||||
runner._setup()
|
||||
await loop.run_in_executor(None, runner._setup)
|
||||
|
||||
if not self._no_guardian and runner._agent_runtime:
|
||||
from framework.agents.hive_coder.guardian import attach_guardian
|
||||
@@ -369,9 +379,12 @@ class AdenTUI(App):
|
||||
|
||||
self._runner = runner
|
||||
self.runtime = runner._agent_runtime
|
||||
except CredentialError:
|
||||
except CredentialError as e:
|
||||
self.status_bar.set_graph_id("")
|
||||
self._show_credential_setup(str(agent_path))
|
||||
self._show_credential_setup(
|
||||
str(agent_path),
|
||||
credential_error=e,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
self.status_bar.set_graph_id("")
|
||||
@@ -392,21 +405,29 @@ class AdenTUI(App):
|
||||
self,
|
||||
agent_path: str,
|
||||
on_cancel: object | None = None,
|
||||
credential_error: Exception | None = None,
|
||||
) -> None:
|
||||
"""Show the credential setup screen for an agent with missing credentials.
|
||||
|
||||
Args:
|
||||
agent_path: Path to the agent that needs credentials.
|
||||
on_cancel: Callable to invoke if the user skips/cancels setup.
|
||||
credential_error: The CredentialError from validation (carries
|
||||
``failed_cred_names`` for both missing and invalid creds).
|
||||
"""
|
||||
from framework.credentials.setup import CredentialSetupSession
|
||||
from framework.credentials.validation import build_setup_session_from_error
|
||||
from framework.tui.screens.credential_setup import CredentialSetupScreen
|
||||
|
||||
session = CredentialSetupSession.from_agent_path(agent_path)
|
||||
session = build_setup_session_from_error(
|
||||
credential_error or Exception("unknown"),
|
||||
agent_path=agent_path,
|
||||
)
|
||||
|
||||
if not session.missing:
|
||||
self.status_bar.set_graph_id("")
|
||||
self.notify(
|
||||
"Credential error but no missing credentials detected",
|
||||
"Credential error but no missing credentials detected. "
|
||||
"Run 'hive setup-credentials' from the terminal.",
|
||||
severity="error",
|
||||
timeout=10,
|
||||
)
|
||||
@@ -419,6 +440,7 @@ class AdenTUI(App):
|
||||
# Credentials saved — retry loading the agent
|
||||
self._do_load_agent(agent_path)
|
||||
else:
|
||||
self.status_bar.set_graph_id("")
|
||||
self.notify(
|
||||
"Credential setup skipped. Agent not loaded.",
|
||||
severity="warning",
|
||||
@@ -525,10 +547,20 @@ class AdenTUI(App):
|
||||
framework_agents_dir = Path(__file__).resolve().parent.parent / "agents"
|
||||
hive_coder_path = framework_agents_dir / "hive_coder"
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
runner = AgentRunner.load(hive_coder_path, model=self._model)
|
||||
load_fn = functools.partial(
|
||||
AgentRunner.load,
|
||||
str(hive_coder_path),
|
||||
model=self._model,
|
||||
interactive=False,
|
||||
)
|
||||
runner = await loop.run_in_executor(None, load_fn)
|
||||
if runner._agent_runtime is None:
|
||||
runner._setup()
|
||||
await loop.run_in_executor(None, runner._setup)
|
||||
|
||||
coder_runtime = runner._agent_runtime
|
||||
coder_runtime._graph_id = "hive_coder"
|
||||
@@ -544,13 +576,12 @@ class AdenTUI(App):
|
||||
|
||||
self._runner = runner
|
||||
self.runtime = coder_runtime
|
||||
except CredentialError:
|
||||
except CredentialError as e:
|
||||
self.status_bar.set_graph_id("")
|
||||
coder_path = str(hive_coder_path)
|
||||
self.call_from_thread(
|
||||
self._show_credential_setup,
|
||||
coder_path,
|
||||
self._restore_from_escalation_stack,
|
||||
self._show_credential_setup(
|
||||
str(hive_coder_path),
|
||||
on_cancel=self._restore_from_escalation_stack,
|
||||
credential_error=e,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
@@ -864,10 +895,6 @@ class AdenTUI(App):
|
||||
self.chat_repl.handle_node_started(event.node_id or "")
|
||||
elif et == EventType.NODE_LOOP_ITERATION:
|
||||
self.chat_repl.handle_loop_iteration(event.data.get("iteration", 0))
|
||||
|
||||
# Track active node in chat_repl for mid-execution input
|
||||
if et == EventType.NODE_LOOP_STARTED:
|
||||
self.chat_repl.handle_node_started(event.node_id or "")
|
||||
elif et == EventType.NODE_LOOP_COMPLETED:
|
||||
self.chat_repl.handle_node_completed(event.node_id or "")
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Vertical, VerticalScroll
|
||||
@@ -15,6 +17,10 @@ class CredentialSetupScreen(ModalScreen[bool | None]):
|
||||
"""Modal screen for configuring missing agent credentials.
|
||||
|
||||
Shows a form with one password Input per missing credential.
|
||||
For Aden-backed credentials (``aden_supported=True``), prompts for
|
||||
``ADEN_API_KEY`` and runs the Aden sync flow instead of storing a
|
||||
raw value.
|
||||
|
||||
Returns True on successful save, or None on cancel/skip.
|
||||
"""
|
||||
|
||||
@@ -76,6 +82,13 @@ class CredentialSetupScreen(ModalScreen[bool | None]):
|
||||
super().__init__()
|
||||
self._session = session
|
||||
self._missing: list[MissingCredential] = session.missing
|
||||
# Track which credentials need Aden sync vs direct API key
|
||||
self._aden_creds: set[int] = set()
|
||||
self._needs_aden_key = False
|
||||
for i, cred in enumerate(self._missing):
|
||||
if cred.aden_supported and not cred.direct_api_key_supported:
|
||||
self._aden_creds.add(i)
|
||||
self._needs_aden_key = True
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
n = len(self._missing)
|
||||
@@ -86,7 +99,28 @@ class CredentialSetupScreen(ModalScreen[bool | None]):
|
||||
id="cred-subtitle",
|
||||
)
|
||||
with VerticalScroll(id="cred-scroll"):
|
||||
# If any credential needs Aden, show ADEN_API_KEY input first
|
||||
if self._needs_aden_key:
|
||||
aden_key = os.environ.get("ADEN_API_KEY", "")
|
||||
with Vertical(classes="cred-entry"):
|
||||
yield Label("[bold]ADEN_API_KEY[/bold]")
|
||||
aden_names = [
|
||||
self._missing[i].credential_name for i in sorted(self._aden_creds)
|
||||
]
|
||||
yield Label(f"[dim]Required for OAuth sync: {', '.join(aden_names)}[/dim]")
|
||||
yield Label("[cyan]Get key:[/cyan] https://hive.adenhq.com")
|
||||
yield Input(
|
||||
placeholder="Paste ADEN_API_KEY..."
|
||||
if not aden_key
|
||||
else "Already set (leave blank to keep)",
|
||||
password=True,
|
||||
id="key-aden",
|
||||
)
|
||||
|
||||
# Show direct API key inputs for non-Aden credentials
|
||||
for i, cred in enumerate(self._missing):
|
||||
if i in self._aden_creds:
|
||||
continue # Handled via Aden sync above
|
||||
with Vertical(classes="cred-entry"):
|
||||
yield Label(f"[bold]{cred.env_var}[/bold]")
|
||||
affected = cred.tools or cred.node_types
|
||||
@@ -117,11 +151,39 @@ class CredentialSetupScreen(ModalScreen[bool | None]):
|
||||
|
||||
def _save_credentials(self) -> None:
|
||||
"""Collect inputs, store credentials, and dismiss."""
|
||||
# Init encryption key (generates one if missing)
|
||||
self._session._ensure_credential_key()
|
||||
|
||||
configured = 0
|
||||
|
||||
# Handle Aden-backed credentials
|
||||
if self._needs_aden_key:
|
||||
aden_input = self.query_one("#key-aden", Input)
|
||||
aden_key = aden_input.value.strip()
|
||||
if aden_key:
|
||||
os.environ["ADEN_API_KEY"] = aden_key
|
||||
# Persist to shell config
|
||||
try:
|
||||
from aden_tools.credentials.shell_config import (
|
||||
add_env_var_to_shell_config,
|
||||
)
|
||||
|
||||
add_env_var_to_shell_config(
|
||||
"ADEN_API_KEY",
|
||||
aden_key,
|
||||
comment="Aden Platform API key",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Run Aden sync for all Aden-backed creds
|
||||
if aden_key or os.environ.get("ADEN_API_KEY"):
|
||||
synced = self._sync_aden_credentials()
|
||||
configured += synced
|
||||
|
||||
# Handle direct API key credentials
|
||||
for i, cred in enumerate(self._missing):
|
||||
if i in self._aden_creds:
|
||||
continue
|
||||
input_widget = self.query_one(f"#key-{i}", Input)
|
||||
value = input_widget.value.strip()
|
||||
if not value:
|
||||
@@ -135,7 +197,70 @@ class CredentialSetupScreen(ModalScreen[bool | None]):
|
||||
if configured > 0:
|
||||
self.dismiss(True)
|
||||
else:
|
||||
self.notify("No credentials entered", severity="warning", timeout=3)
|
||||
self.notify("No credentials configured", severity="warning", timeout=3)
|
||||
|
||||
def _sync_aden_credentials(self) -> int:
|
||||
"""Sync Aden-backed credentials and return count of successfully synced."""
|
||||
try:
|
||||
from framework.credentials import CredentialStore
|
||||
|
||||
store = CredentialStore.with_aden_sync(
|
||||
base_url="https://api.adenhq.com",
|
||||
auto_sync=True,
|
||||
)
|
||||
except Exception as e:
|
||||
self.notify(f"Aden sync failed: {e}", severity="error", timeout=5)
|
||||
return 0
|
||||
|
||||
synced = 0
|
||||
for i in sorted(self._aden_creds):
|
||||
cred = self._missing[i]
|
||||
cred_id = cred.credential_id or cred.credential_name
|
||||
if store.is_available(cred_id):
|
||||
# Export the synced token to the current process
|
||||
try:
|
||||
value = store.get_key(cred_id, cred.credential_key)
|
||||
if value:
|
||||
os.environ[cred.env_var] = value
|
||||
# Also persist under the canonical ID so the local
|
||||
# encrypted store has the real token (overwrites any
|
||||
# stale entry like a previously mis-stored google.enc).
|
||||
self._persist_to_local_store(cred_id, cred.credential_key, value)
|
||||
synced += 1
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
self.notify(
|
||||
f"{cred.credential_name} not found in Aden. "
|
||||
"Connect this integration at hive.adenhq.com first.",
|
||||
severity="warning",
|
||||
timeout=8,
|
||||
)
|
||||
return synced
|
||||
|
||||
@staticmethod
|
||||
def _persist_to_local_store(cred_id: str, key_name: str, value: str) -> None:
|
||||
"""Save a synced token to the local encrypted store under the canonical ID."""
|
||||
try:
|
||||
from pydantic import SecretStr
|
||||
|
||||
from framework.credentials.models import CredentialKey, CredentialObject, CredentialType
|
||||
from framework.credentials.storage import EncryptedFileStorage
|
||||
|
||||
cred_obj = CredentialObject(
|
||||
id=cred_id,
|
||||
credential_type=CredentialType.OAUTH2,
|
||||
keys={
|
||||
key_name: CredentialKey(
|
||||
name=key_name,
|
||||
value=SecretStr(value),
|
||||
),
|
||||
},
|
||||
auto_refresh=True,
|
||||
)
|
||||
EncryptedFileStorage().save(cred_obj)
|
||||
except Exception:
|
||||
pass # Best-effort; env var is the primary delivery mechanism
|
||||
|
||||
def action_dismiss_setup(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
@@ -85,17 +85,19 @@ class ChatRepl(Vertical):
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: 50%;
|
||||
background: $surface;
|
||||
border: none;
|
||||
max-height: 20%;
|
||||
background: $panel;
|
||||
border-top: solid $primary 40%;
|
||||
display: none;
|
||||
scrollbar-background: $panel;
|
||||
scrollbar-color: $primary;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
ChatRepl > #processing-indicator {
|
||||
width: 100%;
|
||||
height: 1;
|
||||
height: auto;
|
||||
max-height: 4;
|
||||
background: $primary 20%;
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
@@ -185,12 +187,10 @@ class ChatRepl(Vertical):
|
||||
return self._FILE_URI_RE.sub(_shorten, text)
|
||||
|
||||
def _write_history(self, content: str) -> None:
|
||||
"""Write to chat history, only auto-scrolling if user is at the bottom."""
|
||||
"""Write to chat history and scroll to bottom."""
|
||||
history = self.query_one("#chat-history", RichLog)
|
||||
was_at_bottom = history.is_vertical_scroll_end
|
||||
history.write(self._linkify(content))
|
||||
if was_at_bottom:
|
||||
history.scroll_end(animate=False)
|
||||
history.scroll_end(animate=False)
|
||||
|
||||
def toggle_logs(self) -> None:
|
||||
"""Toggle inline log display on/off. Backfills buffered logs on toggle ON."""
|
||||
@@ -1306,6 +1306,7 @@ class ChatRepl(Vertical):
|
||||
def handle_execution_completed(self, output: dict[str, Any]) -> None:
|
||||
"""Handle execution finishing successfully."""
|
||||
indicator = self.query_one("#processing-indicator", Label)
|
||||
indicator.update("")
|
||||
indicator.display = False
|
||||
|
||||
# Write the final streaming snapshot to permanent history (if any)
|
||||
@@ -1333,6 +1334,7 @@ class ChatRepl(Vertical):
|
||||
def handle_execution_failed(self, error: str) -> None:
|
||||
"""Handle execution failing."""
|
||||
indicator = self.query_one("#processing-indicator", Label)
|
||||
indicator.update("")
|
||||
indicator.display = False
|
||||
|
||||
self._write_history(f"[bold red]Error:[/bold red] {error}")
|
||||
@@ -1406,8 +1408,11 @@ class ChatRepl(Vertical):
|
||||
self._active_node_id = None
|
||||
|
||||
def handle_internal_output(self, node_id: str, content: str) -> None:
|
||||
"""Show output from non-client-facing nodes."""
|
||||
self._write_history(f"[dim cyan]⟨{node_id}⟩[/dim cyan] {content}")
|
||||
"""Buffer output from non-client-facing nodes. Only display if logs are ON."""
|
||||
line = f"[dim cyan]⟨{node_id}⟩[/dim cyan] {content}"
|
||||
self._log_buffer.append(line)
|
||||
if self._show_logs:
|
||||
self._write_history(line)
|
||||
|
||||
def handle_execution_paused(self, node_id: str, reason: str) -> None:
|
||||
"""Show that execution has been paused."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -66,6 +66,7 @@ class TestHealthCheckerRegistry:
|
||||
"github",
|
||||
"resend",
|
||||
"google_calendar_oauth",
|
||||
"google",
|
||||
"slack",
|
||||
"discord",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user