Merge branch 'fix/google-tool-healthcheck' into feature/tui-credential-setup

This commit is contained in:
Timothy
2026-02-19 11:12:15 -08:00
10 changed files with 498 additions and 182 deletions
+9 -8
View File
@@ -24,6 +24,7 @@ Ctrl+E # or /coder in chat
```
The Coder ships with:
- **Reference documentation** -- anti-patterns, construction guide, and design patterns baked into its system prompt
- **Guardian watchdog** -- an event-driven monitor that catches agent failures and triggers automatic remediation
- **Coder Tools MCP server** -- file I/O, fuzzy-match editing, git snapshots, and sandboxed shell execution (`tools/coder_tools_server.py`)
@@ -90,13 +91,13 @@ The quickstart script auto-detects Claude Code subscriptions and ZAI Code instal
### New Tool Integrations
| Tool | Description | Contributor |
|------|-------------|-------------|
| **Discord** | 4 MCP tools (`discord_list_guilds`, `discord_list_channels`, `discord_send_message`, `discord_get_messages`) with rate-limit retry and channel filtering | @mishrapravin114 |
| **Exa Search API** | 4 AI-powered search tools (`exa_search`, `exa_find_similar`, `exa_get_contents`, `exa_answer`) with neural/keyword search, domain filters, and citation-backed answers | @JeetKaria06 |
| **Razorpay** | 6 payment processing tools for payments, invoices, payment links, and refunds with HTTP Basic Auth | @shivamshahi07 |
| **Google Docs** | Document creation, reading, and editing with OAuth credential support | @haliaeetusvocifer |
| **Gmail enhancements** | Expanded mail operations for inbox management | @bryanadenhq |
| Tool | Description | Contributor |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| **Discord** | 4 MCP tools (`discord_list_guilds`, `discord_list_channels`, `discord_send_message`, `discord_get_messages`) with rate-limit retry and channel filtering | @mishrapravin114 |
| **Exa Search API** | 4 AI-powered search tools (`exa_search`, `exa_find_similar`, `exa_get_contents`, `exa_answer`) with neural/keyword search, domain filters, and citation-backed answers | @JeetKaria06 |
| **Razorpay** | 6 payment processing tools for payments, invoices, payment links, and refunds with HTTP Basic Auth | @shivamshahi07 |
| **Google Docs** | Document creation, reading, and editing with OAuth credential support | @haliaeetusvocifer |
| **Gmail enhancements** | Expanded mail operations for inbox management | @bryanadenhq |
### Infrastructure
@@ -184,7 +185,7 @@ NodeSpec(node_type="function", function=my_func, ...)
NodeSpec(node_type="event_loop", ...) # or just omit node_type (it's the default now)
```
If your agents set `max_node_visits=1` explicitly, they'll still work. The only change is the *default* -- new agents without an explicit value now get unlimited visits.
If your agents set `max_node_visits=1` explicitly, they'll still work. The only change is the _default_ -- new agents without an explicit value now get unlimited visits.
To try the new Hive Coder:
+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
+179 -22
View File
@@ -47,18 +47,50 @@ class _CredentialCheck:
help_url: str = ""
def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
"""Check that required credentials are available before running an agent.
def _presync_aden_tokens(credential_specs: dict) -> None:
"""Sync Aden-backed OAuth tokens into env vars for validation.
Uses CredentialStoreAdapter.default() which includes Aden sync support,
correctly resolving OAuth credentials stored under hashed IDs.
When ADEN_API_KEY is available, fetches fresh OAuth tokens from the Aden
server and exports them to env vars. This ensures validation sees real
tokens instead of stale or mis-stored values in the encrypted store.
Only touches credentials that are ``aden_supported`` AND whose env var
is not already set (so explicit user exports always win).
"""
from framework.credentials.store import CredentialStore
Prints a summary of all credentials and their sources (encrypted store, env var).
Raises CredentialError with actionable guidance if any are missing.
try:
aden_store = CredentialStore.with_aden_sync(auto_sync=True)
except Exception:
logger.debug("Aden pre-sync unavailable, skipping")
return
for name, spec in credential_specs.items():
if not spec.aden_supported:
continue
if os.environ.get(spec.env_var):
continue # Already set — don't overwrite
cred_id = spec.credential_id or name
try:
value = aden_store.get_key(cred_id, spec.credential_key)
if value:
os.environ[spec.env_var] = value
logger.debug("Pre-synced %s from Aden", spec.env_var)
except Exception:
pass
def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool = True) -> None:
"""Check that required credentials are available and valid before running an agent.
Two-phase validation:
1. **Presence** is the credential set (env var, encrypted store, or Aden sync)?
2. **Health check** does the credential actually work? Uses each tool's
registered ``check_credential_health`` endpoint (lightweight HTTP call).
Args:
nodes: List of NodeSpec objects from the agent graph.
quiet: If True, suppress the credential summary output.
verify: If True (default), run health checks on present credentials.
"""
# Collect required tools and node types
required_tools = {tool for node in nodes if node.tools for tool in node.tools}
@@ -72,17 +104,24 @@ def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
from framework.credentials.storage import CompositeStorage, EncryptedFileStorage, EnvVarStorage
from framework.credentials.store import CredentialStore
# Build credential store
# Build credential store.
# Env vars take priority — if a user explicitly exports a fresh key it
# must win over a potentially stale value in the encrypted store.
#
# Pre-sync: when ADEN_API_KEY is available, sync OAuth tokens from Aden
# into env vars so validation sees fresh tokens instead of stale values
# in the encrypted store (e.g., a previously mis-stored google.enc).
if os.environ.get("ADEN_API_KEY"):
_presync_aden_tokens(CREDENTIAL_SPECS)
env_mapping = {
(spec.credential_id or name): spec.env_var for name, spec in CREDENTIAL_SPECS.items()
}
storages: list = [EnvVarStorage(env_mapping=env_mapping)]
env_storage = EnvVarStorage(env_mapping=env_mapping)
if os.environ.get("HIVE_CREDENTIAL_KEY"):
storages.insert(0, EncryptedFileStorage())
if len(storages) == 1:
storage = storages[0]
storage = CompositeStorage(primary=env_storage, fallbacks=[EncryptedFileStorage()])
else:
storage = CompositeStorage(primary=storages[0], fallbacks=storages[1:])
storage = env_storage
store = CredentialStore(storage=storage)
# Build reverse mappings
@@ -95,7 +134,11 @@ def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
node_type_to_cred[nt] = cred_name
missing: list[str] = []
invalid: list[str] = []
failed_cred_names: list[str] = [] # all cred names that need (re-)collection
checked: set[str] = set()
# Credentials that are present and should be health-checked
to_verify: list[tuple[str, str]] = [] # (cred_name, used_by_label)
# Check tool credentials
for tool_name in sorted(required_tools):
@@ -105,12 +148,18 @@ def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
checked.add(cred_name)
spec = CREDENTIAL_SPECS[cred_name]
cred_id = spec.credential_id or cred_name
if spec.required and not store.is_available(cred_id):
affected = sorted(t for t in required_tools if t in spec.tools)
entry = f" {spec.env_var} for {', '.join(affected)}"
if not spec.required:
continue
affected = sorted(t for t in required_tools if t in spec.tools)
label = ", ".join(affected)
if not store.is_available(cred_id):
entry = f" {spec.env_var} for {label}"
if spec.help_url:
entry += f"\n Get it at: {spec.help_url}"
missing.append(entry)
failed_cred_names.append(cred_name)
elif verify and spec.health_check_endpoint:
to_verify.append((cred_name, label))
# Check node type credentials (e.g., ANTHROPIC_API_KEY for LLM nodes)
for nt in sorted(node_types):
@@ -120,20 +169,128 @@ def validate_agent_credentials(nodes: list, quiet: bool = False) -> None:
checked.add(cred_name)
spec = CREDENTIAL_SPECS[cred_name]
cred_id = spec.credential_id or cred_name
if spec.required and not store.is_available(cred_id):
affected_types = sorted(t for t in node_types if t in spec.node_types)
entry = f" {spec.env_var} for {', '.join(affected_types)} nodes"
if not spec.required:
continue
affected_types = sorted(t for t in node_types if t in spec.node_types)
label = ", ".join(affected_types) + " nodes"
if not store.is_available(cred_id):
entry = f" {spec.env_var} for {label}"
if spec.help_url:
entry += f"\n Get it at: {spec.help_url}"
missing.append(entry)
failed_cred_names.append(cred_name)
elif verify and spec.health_check_endpoint:
to_verify.append((cred_name, label))
if missing:
# Phase 2: health-check present credentials
if to_verify:
try:
from aden_tools.credentials import check_credential_health
except ImportError:
check_credential_health = None # type: ignore[assignment]
if check_credential_health is not None:
for cred_name, label in to_verify:
spec = CREDENTIAL_SPECS[cred_name]
cred_id = spec.credential_id or cred_name
value = store.get(cred_id)
if not value:
continue
try:
result = check_credential_health(
cred_name,
value,
health_check_endpoint=spec.health_check_endpoint,
health_check_method=spec.health_check_method,
)
if not result.valid:
entry = f" {spec.env_var} for {label}{result.message}"
if spec.help_url:
entry += f"\n Get a new key at: {spec.help_url}"
invalid.append(entry)
failed_cred_names.append(cred_name)
except Exception as exc:
logger.debug("Health check for %s failed: %s", cred_name, exc)
errors = missing + invalid
if errors:
from framework.credentials.models import CredentialError
lines = ["Missing required credentials:\n"]
lines.extend(missing)
lines: list[str] = []
if missing:
lines.append("Missing credentials:\n")
lines.extend(missing)
if invalid:
if missing:
lines.append("")
lines.append("Invalid or expired credentials:\n")
lines.extend(invalid)
lines.append(
"\nTo fix: run /hive-credentials in Claude Code."
"\nIf you've already set up credentials, restart your terminal to load them."
)
raise CredentialError("\n".join(lines))
exc = CredentialError("\n".join(lines))
exc.failed_cred_names = failed_cred_names # type: ignore[attr-defined]
raise exc
def build_setup_session_from_error(
credential_error: Exception,
nodes: list | None = None,
agent_path: str | None = None,
):
"""Build a ``CredentialSetupSession`` that covers all failed credentials.
``validate_agent_credentials`` attaches ``failed_cred_names`` (both missing
and invalid) to the ``CredentialError``. This helper converts those names
into ``MissingCredential`` entries so the setup screen can re-collect them.
Falls back to the normal ``from_nodes`` / ``from_agent_path`` detection
when the attribute is absent.
Args:
credential_error: The ``CredentialError`` raised by validation.
nodes: Graph nodes (preferred avoids re-loading from disk).
agent_path: Agent directory path (used when nodes aren't available).
"""
from framework.credentials.setup import CredentialSetupSession, MissingCredential
# Start with normal detection (picks up truly missing creds)
if nodes is not None:
session = CredentialSetupSession.from_nodes(nodes)
elif agent_path is not None:
session = CredentialSetupSession.from_agent_path(agent_path)
else:
session = CredentialSetupSession(missing=[])
# Add credentials that are present but failed health checks
already = {m.credential_name for m in session.missing}
failed_names: list[str] = getattr(credential_error, "failed_cred_names", [])
if failed_names:
try:
from aden_tools.credentials import CREDENTIAL_SPECS
for name in failed_names:
if name in already:
continue
spec = CREDENTIAL_SPECS.get(name)
if spec is None:
continue
session.missing.append(
MissingCredential(
credential_name=name,
env_var=spec.env_var,
description=spec.description,
help_url=spec.help_url,
api_key_instructions=spec.api_key_instructions,
tools=list(spec.tools),
aden_supported=spec.aden_supported,
direct_api_key_supported=spec.direct_api_key_supported,
credential_id=spec.credential_id,
credential_key=spec.credential_key,
)
)
except ImportError:
pass
return session
+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(
+47 -20
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
@@ -369,9 +379,12 @@ class AdenTUI(App):
self._runner = runner
self.runtime = runner._agent_runtime
except CredentialError:
except CredentialError as e:
self.status_bar.set_graph_id("")
self._show_credential_setup(str(agent_path))
self._show_credential_setup(
str(agent_path),
credential_error=e,
)
return
except Exception as e:
self.status_bar.set_graph_id("")
@@ -392,21 +405,29 @@ class AdenTUI(App):
self,
agent_path: str,
on_cancel: object | None = None,
credential_error: Exception | None = None,
) -> None:
"""Show the credential setup screen for an agent with missing credentials.
Args:
agent_path: Path to the agent that needs credentials.
on_cancel: Callable to invoke if the user skips/cancels setup.
credential_error: The CredentialError from validation (carries
``failed_cred_names`` for both missing and invalid creds).
"""
from framework.credentials.setup import CredentialSetupSession
from framework.credentials.validation import build_setup_session_from_error
from framework.tui.screens.credential_setup import CredentialSetupScreen
session = CredentialSetupSession.from_agent_path(agent_path)
session = build_setup_session_from_error(
credential_error or Exception("unknown"),
agent_path=agent_path,
)
if not session.missing:
self.status_bar.set_graph_id("")
self.notify(
"Credential error but no missing credentials detected",
"Credential error but no missing credentials detected. "
"Run 'hive setup-credentials' from the terminal.",
severity="error",
timeout=10,
)
@@ -419,6 +440,7 @@ class AdenTUI(App):
# Credentials saved — retry loading the agent
self._do_load_agent(agent_path)
else:
self.status_bar.set_graph_id("")
self.notify(
"Credential setup skipped. Agent not loaded.",
severity="warning",
@@ -525,10 +547,20 @@ class AdenTUI(App):
framework_agents_dir = Path(__file__).resolve().parent.parent / "agents"
hive_coder_path = framework_agents_dir / "hive_coder"
import asyncio
import functools
loop = asyncio.get_event_loop()
try:
runner = AgentRunner.load(hive_coder_path, model=self._model)
load_fn = functools.partial(
AgentRunner.load,
str(hive_coder_path),
model=self._model,
interactive=False,
)
runner = await loop.run_in_executor(None, load_fn)
if runner._agent_runtime is None:
runner._setup()
await loop.run_in_executor(None, runner._setup)
coder_runtime = runner._agent_runtime
coder_runtime._graph_id = "hive_coder"
@@ -544,13 +576,12 @@ class AdenTUI(App):
self._runner = runner
self.runtime = coder_runtime
except CredentialError:
except CredentialError as e:
self.status_bar.set_graph_id("")
coder_path = str(hive_coder_path)
self.call_from_thread(
self._show_credential_setup,
coder_path,
self._restore_from_escalation_stack,
self._show_credential_setup(
str(hive_coder_path),
on_cancel=self._restore_from_escalation_stack,
credential_error=e,
)
return
except Exception as e:
@@ -864,10 +895,6 @@ class AdenTUI(App):
self.chat_repl.handle_node_started(event.node_id or "")
elif et == EventType.NODE_LOOP_ITERATION:
self.chat_repl.handle_loop_iteration(event.data.get("iteration", 0))
# Track active node in chat_repl for mid-execution input
if et == EventType.NODE_LOOP_STARTED:
self.chat_repl.handle_node_started(event.node_id or "")
elif et == EventType.NODE_LOOP_COMPLETED:
self.chat_repl.handle_node_completed(event.node_id or "")
+127 -2
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import os
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical, VerticalScroll
@@ -15,6 +17,10 @@ class CredentialSetupScreen(ModalScreen[bool | None]):
"""Modal screen for configuring missing agent credentials.
Shows a form with one password Input per missing credential.
For Aden-backed credentials (``aden_supported=True``), prompts for
``ADEN_API_KEY`` and runs the Aden sync flow instead of storing a
raw value.
Returns True on successful save, or None on cancel/skip.
"""
@@ -76,6 +82,13 @@ class CredentialSetupScreen(ModalScreen[bool | None]):
super().__init__()
self._session = session
self._missing: list[MissingCredential] = session.missing
# Track which credentials need Aden sync vs direct API key
self._aden_creds: set[int] = set()
self._needs_aden_key = False
for i, cred in enumerate(self._missing):
if cred.aden_supported and not cred.direct_api_key_supported:
self._aden_creds.add(i)
self._needs_aden_key = True
def compose(self) -> ComposeResult:
n = len(self._missing)
@@ -86,7 +99,28 @@ class CredentialSetupScreen(ModalScreen[bool | None]):
id="cred-subtitle",
)
with VerticalScroll(id="cred-scroll"):
# If any credential needs Aden, show ADEN_API_KEY input first
if self._needs_aden_key:
aden_key = os.environ.get("ADEN_API_KEY", "")
with Vertical(classes="cred-entry"):
yield Label("[bold]ADEN_API_KEY[/bold]")
aden_names = [
self._missing[i].credential_name for i in sorted(self._aden_creds)
]
yield Label(f"[dim]Required for OAuth sync: {', '.join(aden_names)}[/dim]")
yield Label("[cyan]Get key:[/cyan] https://hive.adenhq.com")
yield Input(
placeholder="Paste ADEN_API_KEY..."
if not aden_key
else "Already set (leave blank to keep)",
password=True,
id="key-aden",
)
# Show direct API key inputs for non-Aden credentials
for i, cred in enumerate(self._missing):
if i in self._aden_creds:
continue # Handled via Aden sync above
with Vertical(classes="cred-entry"):
yield Label(f"[bold]{cred.env_var}[/bold]")
affected = cred.tools or cred.node_types
@@ -117,11 +151,39 @@ class CredentialSetupScreen(ModalScreen[bool | None]):
def _save_credentials(self) -> None:
"""Collect inputs, store credentials, and dismiss."""
# Init encryption key (generates one if missing)
self._session._ensure_credential_key()
configured = 0
# Handle Aden-backed credentials
if self._needs_aden_key:
aden_input = self.query_one("#key-aden", Input)
aden_key = aden_input.value.strip()
if aden_key:
os.environ["ADEN_API_KEY"] = aden_key
# Persist to shell config
try:
from aden_tools.credentials.shell_config import (
add_env_var_to_shell_config,
)
add_env_var_to_shell_config(
"ADEN_API_KEY",
aden_key,
comment="Aden Platform API key",
)
except Exception:
pass
# Run Aden sync for all Aden-backed creds
if aden_key or os.environ.get("ADEN_API_KEY"):
synced = self._sync_aden_credentials()
configured += synced
# Handle direct API key credentials
for i, cred in enumerate(self._missing):
if i in self._aden_creds:
continue
input_widget = self.query_one(f"#key-{i}", Input)
value = input_widget.value.strip()
if not value:
@@ -135,7 +197,70 @@ class CredentialSetupScreen(ModalScreen[bool | None]):
if configured > 0:
self.dismiss(True)
else:
self.notify("No credentials entered", severity="warning", timeout=3)
self.notify("No credentials configured", severity="warning", timeout=3)
def _sync_aden_credentials(self) -> int:
"""Sync Aden-backed credentials and return count of successfully synced."""
try:
from framework.credentials import CredentialStore
store = CredentialStore.with_aden_sync(
base_url="https://api.adenhq.com",
auto_sync=True,
)
except Exception as e:
self.notify(f"Aden sync failed: {e}", severity="error", timeout=5)
return 0
synced = 0
for i in sorted(self._aden_creds):
cred = self._missing[i]
cred_id = cred.credential_id or cred.credential_name
if store.is_available(cred_id):
# Export the synced token to the current process
try:
value = store.get_key(cred_id, cred.credential_key)
if value:
os.environ[cred.env_var] = value
# Also persist under the canonical ID so the local
# encrypted store has the real token (overwrites any
# stale entry like a previously mis-stored google.enc).
self._persist_to_local_store(cred_id, cred.credential_key, value)
synced += 1
except Exception:
pass
else:
self.notify(
f"{cred.credential_name} not found in Aden. "
"Connect this integration at hive.adenhq.com first.",
severity="warning",
timeout=8,
)
return synced
@staticmethod
def _persist_to_local_store(cred_id: str, key_name: str, value: str) -> None:
"""Save a synced token to the local encrypted store under the canonical ID."""
try:
from pydantic import SecretStr
from framework.credentials.models import CredentialKey, CredentialObject, CredentialType
from framework.credentials.storage import EncryptedFileStorage
cred_obj = CredentialObject(
id=cred_id,
credential_type=CredentialType.OAUTH2,
keys={
key_name: CredentialKey(
name=key_name,
value=SecretStr(value),
),
},
auto_refresh=True,
)
EncryptedFileStorage().save(cred_obj)
except Exception:
pass # Best-effort; env var is the primary delivery mechanism
def action_dismiss_setup(self) -> None:
self.dismiss(None)
+15 -10
View File
@@ -85,17 +85,19 @@ class ChatRepl(Vertical):
width: 100%;
height: auto;
min-height: 0;
max-height: 50%;
background: $surface;
border: none;
max-height: 20%;
background: $panel;
border-top: solid $primary 40%;
display: none;
scrollbar-background: $panel;
scrollbar-color: $primary;
padding: 0 1;
}
ChatRepl > #processing-indicator {
width: 100%;
height: 1;
height: auto;
max-height: 4;
background: $primary 20%;
color: $text;
text-style: bold;
@@ -185,12 +187,10 @@ class ChatRepl(Vertical):
return self._FILE_URI_RE.sub(_shorten, text)
def _write_history(self, content: str) -> None:
"""Write to chat history, only auto-scrolling if user is at the bottom."""
"""Write to chat history and scroll to bottom."""
history = self.query_one("#chat-history", RichLog)
was_at_bottom = history.is_vertical_scroll_end
history.write(self._linkify(content))
if was_at_bottom:
history.scroll_end(animate=False)
history.scroll_end(animate=False)
def toggle_logs(self) -> None:
"""Toggle inline log display on/off. Backfills buffered logs on toggle ON."""
@@ -1306,6 +1306,7 @@ class ChatRepl(Vertical):
def handle_execution_completed(self, output: dict[str, Any]) -> None:
"""Handle execution finishing successfully."""
indicator = self.query_one("#processing-indicator", Label)
indicator.update("")
indicator.display = False
# Write the final streaming snapshot to permanent history (if any)
@@ -1333,6 +1334,7 @@ class ChatRepl(Vertical):
def handle_execution_failed(self, error: str) -> None:
"""Handle execution failing."""
indicator = self.query_one("#processing-indicator", Label)
indicator.update("")
indicator.display = False
self._write_history(f"[bold red]Error:[/bold red] {error}")
@@ -1406,8 +1408,11 @@ class ChatRepl(Vertical):
self._active_node_id = None
def handle_internal_output(self, node_id: str, content: str) -> None:
"""Show output from non-client-facing nodes."""
self._write_history(f"[dim cyan]⟨{node_id}⟩[/dim cyan] {content}")
"""Buffer output from non-client-facing nodes. Only display if logs are ON."""
line = f"[dim cyan]⟨{node_id}⟩[/dim cyan] {content}"
self._log_buffer.append(line)
if self._show_logs:
self._write_history(line)
def handle_execution_paused(self, node_id: str, reason: str) -> None:
"""Show that execution has been paused."""
@@ -162,56 +162,59 @@ class BraveSearchHealthChecker:
)
class GoogleCalendarHealthChecker:
"""Health checker for Google Calendar OAuth tokens."""
class OAuthBearerHealthChecker:
"""Generic health checker for OAuth2 Bearer token credentials.
Validates by making a GET request with ``Authorization: Bearer <token>``
to the given endpoint. Reused for Google Gmail, Google Calendar, and as
the automatic fallback for any credential spec that defines a
``health_check_endpoint`` but has no dedicated checker.
"""
ENDPOINT = "https://www.googleapis.com/calendar/v3/users/me/calendarList"
TIMEOUT = 10.0
def check(self, access_token: str) -> HealthCheckResult:
"""
Validate Google Calendar token by making lightweight API call.
def __init__(self, endpoint: str, service_name: str = "Service"):
self.endpoint = endpoint
self.service_name = service_name
Makes a GET request for 1 calendar to verify the token works.
"""
def check(self, access_token: str) -> HealthCheckResult:
try:
with httpx.Client(timeout=self.TIMEOUT) as client:
response = client.get(
self.ENDPOINT,
self.endpoint,
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
params={"maxResults": "1"},
)
if response.status_code == 200:
return HealthCheckResult(
valid=True,
message="Google Calendar credentials valid",
message=f"{self.service_name} credentials valid",
)
elif response.status_code == 401:
return HealthCheckResult(
valid=False,
message="Google Calendar token is invalid or expired",
message=f"{self.service_name} token is invalid or expired",
details={"status_code": 401},
)
elif response.status_code == 403:
return HealthCheckResult(
valid=False,
message="Google Calendar token lacks required scopes",
details={"status_code": 403, "required": "calendar"},
message=f"{self.service_name} token lacks required scopes",
details={"status_code": 403},
)
else:
return HealthCheckResult(
valid=False,
message=f"Google Calendar API returned status {response.status_code}",
message=f"{self.service_name} API returned status {response.status_code}",
details={"status_code": response.status_code},
)
except httpx.TimeoutException:
return HealthCheckResult(
valid=False,
message="Google Calendar API request timed out",
message=f"{self.service_name} API request timed out",
details={"error": "timeout"},
)
except httpx.RequestError as e:
@@ -220,11 +223,21 @@ class GoogleCalendarHealthChecker:
error_msg = "Request failed (details redacted for security)"
return HealthCheckResult(
valid=False,
message=f"Failed to connect to Google Calendar: {error_msg}",
message=f"Failed to connect to {self.service_name}: {error_msg}",
details={"error": error_msg},
)
class GoogleCalendarHealthChecker(OAuthBearerHealthChecker):
"""Health checker for Google Calendar OAuth tokens."""
def __init__(self):
super().__init__(
endpoint="https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=1",
service_name="Google Calendar",
)
class GoogleSearchHealthChecker:
"""Health checker for Google Custom Search API."""
@@ -679,12 +692,23 @@ class GoogleMapsHealthChecker:
)
class GoogleGmailHealthChecker(OAuthBearerHealthChecker):
"""Health checker for Google Gmail OAuth tokens."""
def __init__(self):
super().__init__(
endpoint="https://gmail.googleapis.com/gmail/v1/users/me/profile",
service_name="Gmail",
)
# Registry of health checkers
HEALTH_CHECKERS: dict[str, CredentialHealthChecker] = {
"discord": DiscordHealthChecker(),
"hubspot": HubSpotHealthChecker(),
"brave_search": BraveSearchHealthChecker(),
"google_calendar_oauth": GoogleCalendarHealthChecker(),
"google": GoogleGmailHealthChecker(),
"slack": SlackHealthChecker(),
"google_search": GoogleSearchHealthChecker(),
"google_maps": GoogleMapsHealthChecker(),
@@ -705,7 +729,12 @@ def check_credential_health(
Args:
credential_name: Name of the credential (e.g., 'hubspot', 'brave_search')
credential_value: The credential value to validate
**kwargs: Additional arguments passed to the checker (e.g., cse_id for Google)
**kwargs: Additional arguments passed to the checker.
- cse_id: CSE ID for Google Custom Search
- health_check_endpoint: Fallback endpoint URL when no dedicated
checker is registered. Used automatically by
``validate_agent_credentials`` from the credential spec.
- health_check_method: HTTP method for fallback (default GET).
Returns:
HealthCheckResult with validation status
@@ -720,12 +749,19 @@ def check_credential_health(
checker = HEALTH_CHECKERS.get(credential_name)
if checker is None:
# No health checker registered - assume valid
return HealthCheckResult(
valid=True,
message=f"No health checker for '{credential_name}', assuming valid",
details={"no_checker": True},
)
# No dedicated checker — try generic fallback using the spec's endpoint
endpoint = kwargs.get("health_check_endpoint")
if endpoint:
checker = OAuthBearerHealthChecker(
endpoint=endpoint,
service_name=credential_name.replace("_", " ").title(),
)
else:
return HealthCheckResult(
valid=True,
message=f"No health checker for '{credential_name}', assuming valid",
details={"no_checker": True},
)
# Special case for Google which needs CSE ID
if credential_name == "google_search" and "cse_id" in kwargs:
+1
View File
@@ -66,6 +66,7 @@ class TestHealthCheckerRegistry:
"github",
"resend",
"google_calendar_oauth",
"google",
"slack",
"discord",
}