Merge pull request #5121 from TimothyZhang7/fix/credential-error-types

Fix(micro-fix)/credential error types
This commit is contained in:
Timothy @aden
2026-02-19 16:23:31 -08:00
committed by GitHub
10 changed files with 937 additions and 211 deletions
+179 -53
View File
@@ -1,58 +1,118 @@
# Changelog
# Release Notes
## v0.5.1 (2026-02-18)
**Release Date:** February 18, 2026
**Tag:** v0.5.1
A major release introducing the Hive Coder meta-agent, multi-graph runtime, TUI overhaul, subscription model support, and 5 new tool integrations.
## The Hive Gets a Brain
### Highlights
v0.5.1 is our most ambitious release yet. Hive agents can now **build other agents** -- the new Hive Coder meta-agent writes, tests, and fixes agent packages from natural language. The runtime grows multi-graph support so one session can orchestrate multiple agents simultaneously. The TUI gets a complete overhaul with an in-app agent picker, live streaming, and seamless escalation to the Coder. And we're now provider-agnostic: Claude Code subscriptions, OpenAI-compatible endpoints, and any LiteLLM-supported model work out of the box.
- **Hive Coder** — A native meta-agent that builds and modifies Hive agent packages from natural-language specifications, complete with reference docs, guardian watchdog, and `hive code` CLI command.
- **Multi-Graph Runtime** — AgentRuntime now supports loading, managing, and switching between multiple agent graphs within a single session.
- **TUI Revamp** — In-app agent picker, PDF attachments, streaming output pane, Hive Coder escalation, and runtime-optional startup.
- **Subscription & Endpoint Support** — First-class Claude Code OAuth subscription support and OpenAI-compatible endpoint routing.
---
### Features
## Highlights
- **Multi-graph agent sessions**: `add_graph`/`remove_graph` on AgentRuntime plus 6 lifecycle tools (`load_agent`, `unload_agent`, `start_agent`, `restart_agent`, `list_agents`, `get_user_presence`)
- **Claude Code subscription support**: Automatic OAuth token refresh via `use_claude_code_subscription` config, with auto-detection in quickstart
- **OpenAI-compatible endpoints**: `api_base` and `extra_kwargs` in `RuntimeConfig` for routing LLM traffic through any compatible API
- **Interactive credential setup**: Guided `CredentialSetupSession` with health checks and encrypted storage, accessible via `hive setup-credentials` or automatic prompting
- **Agent escalation to Hive Coder**: Client-facing nodes can call `escalate_to_coder` to hand off to Hive Coder with automatic state preservation and restoration
- **Coder Tools MCP server**: File I/O, fuzzy-match editing, git snapshots, and sandboxed shell execution for the Hive Coder agent
- **Pre-start confirmation prompt**: Interactive prompt before agent execution allowing credential updates or abort
### Hive Coder -- The Agent That Builds Agents
### TUI
A native meta-agent that lives inside the framework at `core/framework/agents/hive_coder/`. Give it a natural-language specification and it produces a complete agent package -- goal definition, node prompts, edge routing, MCP tool wiring, tests, and all boilerplate files.
- **In-app agent picker** (Ctrl+A): Tabbed modal for browsing Your Agents, Framework, and Example agents with metadata badges
- **Runtime-optional startup**: TUI launches without a pre-loaded agent, showing the agent picker on startup
- **Hive Coder escalation** (Ctrl+E): Escalate to Hive Coder and return with `/coder` and `/back` commands
- **PDF attachment support**: `/attach` and `/detach` commands with native OS file dialog
- **Streaming output pane**: Dedicated RichLog widget for live LLM token streaming
- **Multi-graph commands**: `/graphs`, `/graph <id>`, `/load <path>`, `/unload <id>` for managing multiple graphs
- **Agent Guardian watchdog**: Event-driven monitor that catches secondary agent failures with automatic remediation
```bash
# Launch the Coder directly
hive code
### Tool Integrations
# Or escalate from any running agent (TUI)
Ctrl+E # or /coder in chat
```
- **Discord**: 4 MCP tools (`discord_list_guilds`, `discord_list_channels`, `discord_send_message`, `discord_get_messages`) with rate-limit retry
- **Exa Search API**: 4 AI-powered search tools (`exa_search`, `exa_find_similar`, `exa_get_contents`, `exa_answer`) with neural/keyword search
- **Razorpay**: 6 payment tools for payments, invoices, payment links, and refunds
- **Google Docs**: Document creation, reading, and editing with OAuth credential support
- **Gmail enhancements**: Expanded mail operations
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`)
- **Test generation** -- structural tests for forever-alive agents that don't hang on `runner.run()`
### Multi-Graph Agent Runtime
`AgentRuntime` now supports loading, managing, and switching between multiple agent graphs within a single session. Six new lifecycle tools give agents (and the TUI) full control:
```python
# Load a second agent into the runtime
await runtime.add_graph("exports/deep_research_agent")
# Tools available to agents:
# load_agent, unload_agent, start_agent, restart_agent, list_agents, get_user_presence
```
The Hive Coder uses multi-graph internally -- when you escalate from a worker agent, the Coder loads as a separate graph while the worker stays alive in the background.
### TUI Revamp
The Terminal UI gets a ground-up rebuild with five major additions:
- **Agent Picker** (Ctrl+A) -- tabbed modal screen for browsing Your Agents, Framework agents, and Examples with metadata badges (node count, tool count, session count, tags)
- **Runtime-optional startup** -- TUI launches without a pre-loaded agent, showing the picker on first open
- **Live streaming pane** -- dedicated RichLog widget shows LLM tokens as they arrive, replacing the old one-token-per-line display
- **PDF attachments** -- `/attach` and `/detach` commands with native OS file dialog (macOS, Linux, Windows)
- **Multi-graph commands** -- `/graphs`, `/graph <id>`, `/load <path>`, `/unload <id>` for managing agent graphs in-session
### Provider-Agnostic LLM Support
Hive is no longer Anthropic-only. v0.5.1 adds first-class support for:
- **Claude Code subscriptions** -- `use_claude_code_subscription: true` in `~/.hive/configuration.json` reads OAuth tokens from `~/.claude/.credentials.json` with automatic refresh
- **OpenAI-compatible endpoints** -- `api_base` config routes traffic through any compatible API (Azure OpenAI, vLLM, Ollama, etc.)
- **Any LiteLLM model** -- `RuntimeConfig` now passes `api_key`, `api_base`, and `extra_kwargs` through to LiteLLM
The quickstart script auto-detects Claude Code subscriptions and ZAI Code installations.
---
## What's New
### Architecture & Runtime
- **Hive Coder meta-agent** -- Natural-language agent builder with reference docs, guardian watchdog, and `hive code` CLI command. (@TimothyZhang7)
- **Multi-graph agent sessions** -- `add_graph`/`remove_graph` on AgentRuntime with 6 lifecycle tools (`load_agent`, `unload_agent`, `start_agent`, `restart_agent`, `list_agents`, `get_user_presence`). (@TimothyZhang7)
- **Claude Code subscription support** -- OAuth token refresh via `use_claude_code_subscription` config, auto-detection in quickstart, LiteLLM header patching. (@TimothyZhang7)
- **OpenAI-compatible endpoint support** -- `api_base` and `extra_kwargs` in `RuntimeConfig` for any OpenAI-compatible API. (@TimothyZhang7)
- **Remove deprecated node types** -- Delete `FlexibleGraphExecutor`, `WorkerNode`, `HybridJudge`, `CodeSandbox`, `Plan`, `FunctionNode`, `LLMNode`, `RouterNode`. Deprecated types (`llm_tool_use`, `llm_generate`, `function`, `router`, `human_input`) now raise `RuntimeError` with migration guidance. (@TimothyZhang7)
- **Interactive credential setup** -- Guided `CredentialSetupSession` with health checks and encrypted storage, accessible via `hive setup-credentials` or automatic prompting on credential errors. (@RichardTang-Aden)
- **Pre-start confirmation prompt** -- Interactive prompt before agent execution allowing credential updates or abort. (@RichardTang-Aden)
- **Event bus multi-graph support** -- `graph_id` on events, `filter_graph` on subscriptions, `ESCALATION_REQUESTED` event type, `exclude_own_graph` filter. (@TimothyZhang7)
### TUI Improvements
- **In-app agent picker** (Ctrl+A) -- Tabbed modal for browsing agents with metadata badges (nodes, tools, sessions, tags). (@TimothyZhang7)
- **Runtime-optional TUI startup** -- Launches without a pre-loaded agent, shows agent picker on startup. (@TimothyZhang7)
- **Hive Coder escalation** (Ctrl+E) -- Escalate to Hive Coder and return; also available via `/coder` and `/back` chat commands. (@TimothyZhang7)
- **PDF attachment support** -- `/attach` and `/detach` commands with native OS file dialog. (@TimothyZhang7)
- **Streaming output pane** -- Dedicated RichLog widget for live LLM token streaming. (@TimothyZhang7)
- **Multi-graph TUI commands** -- `/graphs`, `/graph <id>`, `/load <path>`, `/unload <id>`. (@TimothyZhang7)
- **Agent Guardian watchdog** -- Event-driven monitor that catches secondary agent failures and triggers automatic remediation, with `--no-guardian` CLI flag. (@TimothyZhang7)
### 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 |
### Infrastructure
- **Remove deprecated node types**: Delete `FlexibleGraphExecutor`, `WorkerNode`, `HybridJudge`, `CodeSandbox`, `Plan`, `FunctionNode`, `LLMNode`, `RouterNode`; deprecated types now raise `RuntimeError` with migration guidance
- **Default node type → `event_loop`**: `NodeSpec.node_type` defaults to `"event_loop"` instead of `"llm_tool_use"`
- **Default `max_node_visits` → 0 (unlimited)**: Nodes default to unlimited visits, reducing friction for feedback loops and forever-alive agents
- **Remove `function` field from NodeSpec**: Follows deprecation of `FunctionNode`
- **LiteLLM OAuth patch**: Correct header construction for OAuth tokens (remove `x-api-key` when Bearer token is present)
- **Event bus multi-graph support**: `graph_id` on events, `filter_graph` on subscriptions, `ESCALATION_REQUESTED` event type
- **ExecutionStream graph_id tagging**: Streams carry `graph_id` propagated to all emitted events
- **Orchestrator config centralization**: Reads `api_key`, `api_base`, `extra_kwargs` from centralized config
- **System prompt datetime injection**: All system prompts now include current date/time for time-aware behavior
- **Utils module exports**: Proper `__init__.py` exports for the utils module
- **Default node type → `event_loop`** -- `NodeSpec.node_type` defaults to `"event_loop"` instead of `"llm_tool_use"`. (@TimothyZhang7)
- **Default `max_node_visits` → 0 (unlimited)** -- Nodes default to unlimited visits, reducing friction for feedback loops and forever-alive agents. (@TimothyZhang7)
- **Remove `function` field from NodeSpec** -- Follows deprecation of `FunctionNode`. (@TimothyZhang7)
- **LiteLLM OAuth patch** -- Correct header construction for OAuth tokens (remove `x-api-key` when Bearer token is present). (@TimothyZhang7)
- **Orchestrator config centralization** -- Reads `api_key`, `api_base`, `extra_kwargs` from centralized `~/.hive/configuration.json`. (@TimothyZhang7)
- **System prompt datetime injection** -- All system prompts now include current date/time for time-aware agent behavior. (@TimothyZhang7)
- **Utils module exports** -- Proper `__init__.py` exports for the utils module. (@Siddharth2624)
- **Increased default max_tokens** -- Opus 4.6 defaults to 32768, Sonnet 4.5 to 16384 (up from 8192). (@TimothyZhang7)
### Bug Fixes
---
## Bug Fixes
- Flush WIP accumulator outputs on cancel/failure so edge conditions see correct values on resume
- Stall detection state preserved across resume (no more resets on checkpoint restore)
@@ -61,21 +121,87 @@ A major release introducing the Hive Coder meta-agent, multi-graph runtime, TUI
- Add `_awaiting_input` flag to EventLoopNode to prevent input injection race conditions
- Fix TUI streaming display (tokens no longer appear one-per-line)
- Fix `_return_from_escalation` crash when ChatRepl widgets not yet mounted
- Fix tools registration problems for Google Docs credentials (@RichardTang-Aden)
- Fix email agent version conflicts (@RichardTang-Aden)
- Fix coder tool timeouts (120s for tests, 300s cap for commands)
### Agent Updates
## Documentation
- Consolidate email agents into single `email_inbox_management` agent
- Update prompts for Deep Research, Job Hunter, Tech News Reporter, and Vulnerability Assessment agents
### Breaking Changes
- Deprecated node types (`llm_tool_use`, `llm_generate`, `function`, `router`, `human_input`) now raise `RuntimeError` instead of warnings — migrate to `event_loop`
- `NodeSpec.node_type` defaults to `"event_loop"` (was `"llm_tool_use"`)
- `NodeSpec.max_node_visits` defaults to `0` / unlimited (was `1`)
- `NodeSpec.function` field removed
- Clarify installation and prevent root pip install misuse (@paarths-collab)
---
## v0.5.0
## Agent Updates
Initial public release.
- **Email Inbox Management** -- Consolidate `gmail_inbox_guardian` and `inbox_management` into a single unified agent with updated prompts and config. (@RichardTang-Aden, @bryanadenhq)
- **Job Hunter** -- Updated node prompts, config, and agent metadata; added PDF resume selection. (@bryanadenhq)
- **Deep Research Agent** -- Revised node implementations with updated prompts and output handling.
- **Tech News Reporter** -- Revised node prompts for improved output quality.
- **Vulnerability Assessment** -- Expanded prompts with more detailed assessment instructions. (@bryanadenhq)
---
## Breaking Changes
- **Deprecated node types raise `RuntimeError`** -- `llm_tool_use`, `llm_generate`, `function`, `router`, `human_input` now fail instead of warning. Migrate to `event_loop`.
- **`NodeSpec.node_type` defaults to `"event_loop"`** (was `"llm_tool_use"`)
- **`NodeSpec.max_node_visits` defaults to `0` / unlimited** (was `1`)
- **`NodeSpec.function` field removed** -- `FunctionNode` is deleted; use event_loop nodes with tools instead.
---
## Community Contributors
A huge thank you to everyone who contributed to this release:
- **Richard Tang** (@RichardTang-Aden) -- Interactive credential setup, pre-start confirmation, email agent consolidation, tool registration fixes, lint and formatting
- **Pravin Mishra** (@mishrapravin114) -- Discord integration with 4 MCP tools
- **Jeet Karia** (@JeetKaria06) -- Exa Search API integration with 4 AI-powered search tools
- **Shivam Shahi** (@shivamshahi07) -- Razorpay payment processing integration
- **Siddharth Varshney** (@Siddharth2624) -- Utils module exports
- **@haliaeetusvocifer** -- Google Docs integration with OAuth support
- **Bryan** (@bryanadenhq) -- PDF selection, inbox agent fixes, Job Hunter and Vulnerability Assessment updates
- **@paarths-collab** -- Documentation improvements
---
## Upgrading
```bash
git pull origin main
uv sync
```
### Migration Guide
If your agents use deprecated node types, update them:
```python
# Before (v0.5.0) -- these now raise RuntimeError
NodeSpec(node_type="llm_tool_use", ...)
NodeSpec(node_type="function", function=my_func, ...)
# After (v0.5.1) -- use event_loop for everything
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.
To try the new Hive Coder:
```bash
# Launch Coder directly
hive code
# Or from TUI -- press Ctrl+E to escalate
hive tui
```
---
## What's Next
- **Agent-to-agent communication** -- one agent's output triggers another agent's entry point
- **Cost visibility** -- detailed runtime log of LLM costs per node and per session
- **Persistent webhook subscriptions** -- survive agent restarts without re-registering
- **Remote agent deployment** -- run agents as long-lived services with HTTP APIs
+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
+216 -31
View File
@@ -47,18 +47,62 @@ 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 as e:
logger.warning("Aden pre-sync unavailable: %s", e)
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)
else:
logger.warning(
"Pre-sync: %s (id=%s) available but key '%s' returned None",
spec.env_var,
cred_id,
spec.credential_key,
)
except Exception as e:
logger.warning(
"Pre-sync failed for %s (id=%s): %s",
spec.env_var,
cred_id,
e,
)
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 +116,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 +146,34 @@ def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
node_type_to_cred[nt] = cred_name
missing: list[str] = []
invalid: list[str] = []
# Aden-backed creds where ADEN_API_KEY is set but integration not connected
aden_not_connected: list[str] = []
failed_cred_names: list[str] = [] # all cred names that need (re-)collection
has_aden_key = bool(os.environ.get("ADEN_API_KEY"))
checked: set[str] = set()
# Credentials that are present and should be health-checked
to_verify: list[tuple[str, str]] = [] # (cred_name, used_by_label)
def _check_credential(spec, cred_name: str, label: str) -> None:
cred_id = spec.credential_id or cred_name
if not store.is_available(cred_id):
# If ADEN_API_KEY is set and this is an Aden-only credential,
# the issue is that the integration isn't connected on hive.adenhq.com,
# NOT that the user needs to re-enter ADEN_API_KEY.
if has_aden_key and spec.aden_supported and not spec.direct_api_key_supported:
aden_not_connected.append(
f" {spec.env_var} for {label}"
f"\n Connect this integration at hive.adenhq.com first."
)
else:
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 tool credentials
for tool_name in sorted(required_tools):
@@ -104,13 +182,11 @@ def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
continue
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 spec.help_url:
entry += f"\n Get it at: {spec.help_url}"
missing.append(entry)
if not spec.required:
continue
affected = sorted(t for t in required_tools if t in spec.tools)
label = ", ".join(affected)
_check_credential(spec, cred_name, label)
# Check node type credentials (e.g., ANTHROPIC_API_KEY for LLM nodes)
for nt in sorted(node_types):
@@ -119,21 +195,130 @@ def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
continue
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 spec.help_url:
entry += f"\n Get it at: {spec.help_url}"
missing.append(entry)
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"
_check_credential(spec, 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 + aden_not_connected
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)
if aden_not_connected:
if missing or invalid:
lines.append("")
lines.append(
"Aden integrations not connected "
"(ADEN_API_KEY is set but OAuth tokens unavailable):\n"
)
lines.extend(aden_not_connected)
lines.append(
"\nTo fix: run /hive-credentials in Claude Code."
"\nIf you've already set up credentials, restart your terminal to load them."
"\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
+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
+49 -2
View File
@@ -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(
+99 -7
View File
@@ -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
@@ -371,7 +381,10 @@ class AdenTUI(App):
self.runtime = runner._agent_runtime
except CredentialError as e:
self.status_bar.set_graph_id("")
self.notify(f"Credential error: {e}", severity="error", timeout=10)
self._show_credential_setup(
str(agent_path),
credential_error=e,
)
return
except Exception as e:
self.status_bar.set_graph_id("")
@@ -388,6 +401,67 @@ class AdenTUI(App):
self.notify(f"Agent loaded: {agent_name}", severity="information", timeout=3)
def _show_credential_setup(
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.validation import build_setup_session_from_error
from framework.tui.screens.credential_setup import CredentialSetupScreen
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("")
error_msg = str(credential_error) if credential_error else ""
if "not connected" in error_msg or "Aden" in error_msg:
self.notify(
"ADEN_API_KEY is set but OAuth integrations "
"are not connected. Visit hive.adenhq.com "
"to connect them, then reload the agent.",
severity="warning",
timeout=15,
)
else:
self.notify(
"Credential error but no missing credentials "
"detected. Run 'hive setup-credentials' "
"from the terminal.",
severity="error",
timeout=10,
)
if callable(on_cancel):
on_cancel()
return
def _on_result(result: bool | None) -> None:
if result is True:
# 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",
timeout=5,
)
if callable(on_cancel):
on_cancel()
self.push_screen(CredentialSetupScreen(session), callback=_on_result)
# -- Agent picker --
def _show_agent_picker_initial(self) -> None:
@@ -484,10 +558,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"
@@ -503,7 +587,15 @@ class AdenTUI(App):
self._runner = runner
self.runtime = coder_runtime
except (CredentialError, Exception) as e:
except CredentialError as e:
self.status_bar.set_graph_id("")
self._show_credential_setup(
str(hive_coder_path),
on_cancel=self._restore_from_escalation_stack,
credential_error=e,
)
return
except Exception as e:
self.status_bar.set_graph_id("")
self.notify(f"Failed to load coder: {e}", severity="error", timeout=10)
self._restore_from_escalation_stack()
@@ -0,0 +1,315 @@
"""Credential setup ModalScreen for configuring missing agent credentials."""
from __future__ import annotations
import os
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label
from framework.credentials.setup import CredentialSetupSession, MissingCredential
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.
"""
BINDINGS = [
Binding("escape", "dismiss_setup", "Cancel"),
]
DEFAULT_CSS = """
CredentialSetupScreen {
align: center middle;
}
#cred-container {
width: 80%;
max-width: 100;
height: 80%;
background: $surface;
border: heavy $primary;
padding: 1 2;
}
#cred-title {
text-align: center;
text-style: bold;
width: 100%;
color: $text;
}
#cred-subtitle {
text-align: center;
width: 100%;
margin-bottom: 1;
}
#cred-scroll {
height: 1fr;
}
.cred-entry {
margin-bottom: 1;
padding: 1;
background: $panel;
height: auto;
}
.cred-entry Input {
margin-top: 1;
}
.cred-buttons {
height: auto;
margin-top: 1;
align: center middle;
}
.cred-buttons Button {
margin: 0 1;
}
#cred-footer {
text-align: center;
width: 100%;
margin-top: 1;
}
"""
def __init__(self, session: CredentialSetupSession) -> 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)
with Vertical(id="cred-container"):
yield Label("Credential Setup", id="cred-title")
yield Label(
f"[dim]{n} credential{'s' if n != 1 else ''} needed to run this agent[/dim]",
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
if affected:
yield Label(f"[dim]Required by: {', '.join(affected)}[/dim]")
if cred.description:
yield Label(f"[dim]{cred.description}[/dim]")
if cred.help_url:
yield Label(f"[cyan]Get key:[/cyan] {cred.help_url}")
yield Input(
placeholder="Paste API key...",
password=True,
id=f"key-{i}",
)
with Vertical(classes="cred-buttons"):
yield Button("Save & Continue", variant="primary", id="btn-save")
yield Button("Skip", variant="default", id="btn-skip")
yield Label(
"[dim]Enter[/dim] Submit [dim]Esc[/dim] Cancel",
id="cred-footer",
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-save":
self._save_credentials()
elif event.button.id == "btn-skip":
self.dismiss(None)
def _save_credentials(self) -> None:
"""Collect inputs, store credentials, and dismiss."""
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
configured += 1 # ADEN_API_KEY itself counts as configured
# Run Aden sync for all Aden-backed creds (best-effort)
if aden_key or os.environ.get("ADEN_API_KEY"):
self._sync_aden_credentials()
# 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:
continue
try:
self._session._store_credential(cred, value)
configured += 1
except Exception as e:
self.notify(f"Error storing {cred.env_var}: {e}", severity="error")
if configured > 0:
self.dismiss(True)
else:
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."""
# Build the Aden sync components directly so we get real errors
# instead of CredentialStore.with_aden_sync() silently falling back.
try:
from framework.credentials.aden import (
AdenCachedStorage,
AdenClientConfig,
AdenCredentialClient,
AdenSyncProvider,
)
from framework.credentials.storage import EncryptedFileStorage
client = AdenCredentialClient(AdenClientConfig(base_url="https://api.adenhq.com"))
provider = AdenSyncProvider(client=client)
local_storage = EncryptedFileStorage()
cached_storage = AdenCachedStorage(
local_storage=local_storage,
aden_provider=provider,
)
except Exception as e:
self.notify(
f"Aden setup error: {e}",
severity="error",
timeout=8,
)
return 0
# Sync all integrations from Aden to get the provider index populated
try:
from framework.credentials import CredentialStore
store = CredentialStore(
storage=cached_storage,
providers=[provider],
auto_refresh=True,
)
num_synced = provider.sync_all(store)
if num_synced == 0:
self.notify(
"No active integrations found in Aden. "
"Connect integrations at hive.adenhq.com.",
severity="warning",
timeout=8,
)
except Exception as e:
self.notify(
f"Aden sync error: {e}",
severity="error",
timeout=8,
)
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):
try:
value = store.get_key(cred_id, cred.credential_key)
if value:
os.environ[cred.env_var] = value
self._persist_to_local_store(cred_id, cred.credential_key, value)
synced += 1
else:
self.notify(
f"{cred.credential_name}: key "
f"'{cred.credential_key}' not found "
f"in credential '{cred_id}'",
severity="warning",
timeout=8,
)
except Exception as e:
self.notify(
f"{cred.credential_name} extraction failed: {e}",
severity="error",
timeout=8,
)
else:
self.notify(
f"{cred.credential_name} (id='{cred_id}') "
f"not found in Aden. Connect this "
f"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)
+7
View File
@@ -966,6 +966,10 @@ class ChatRepl(Vertical):
stream_log = self.query_one("#streaming-output", RichLog)
stream_log.clear()
stream_log.display = False
# Hiding the streaming pane makes chat-history taller (1fr reclaims
# the space). Re-scroll so subsequent _write_history calls see
# is_vertical_scroll_end == True.
self.query_one("#chat-history", RichLog).scroll_end(animate=False)
def flush_streaming(self) -> None:
"""Flush any accumulated streaming text to history.
@@ -1236,6 +1240,9 @@ class ChatRepl(Vertical):
stream_log = self.query_one("#streaming-output", RichLog)
if not stream_log.display:
stream_log.display = True
# Showing the streaming pane shrinks chat-history (height: 1fr).
# Re-scroll so _write_history still sees is_vertical_scroll_end.
self.query_one("#chat-history", RichLog).scroll_end(animate=False)
# Rewrite the full snapshot as a single block so text wraps
# naturally instead of one token per line.
@@ -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",
}