Merge pull request #5121 from TimothyZhang7/fix/credential-error-types
Fix(micro-fix)/credential error types
This commit is contained in:
+179
-53
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -66,6 +66,7 @@ class TestHealthCheckerRegistry:
|
||||
"github",
|
||||
"resend",
|
||||
"google_calendar_oauth",
|
||||
"google",
|
||||
"slack",
|
||||
"discord",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user