Files
hive/core/framework/server/routes_credentials.py
T
Hundao 589c5b06fe fix: resolve all ruff lint and format errors across codebase (#7058)
- Auto-fixed 70 lint errors (import sorting, aliased errors, datetime.UTC)
- Fixed 85 remaining errors manually:
  - E501: wrapped long lines in queen_profiles, catalog, routes_credentials
  - F821: added missing TYPE_CHECKING imports for AgentHost, ToolRegistry,
    HookContext, HookResult; added runtime imports where needed
  - F811: removed duplicate method definitions in queen_lifecycle_tools
  - F841/B007: removed unused variables in discovery.py
  - W291: removed trailing whitespace in queen nodes
  - E402: moved import to top of queen_memory_v2.py
  - Fixed AgentRuntime -> AgentHost in example template type annotations
- Reformatted 343 files with ruff format
2026-04-16 19:30:01 +08:00

491 lines
19 KiB
Python

"""Credential CRUD routes."""
import asyncio
import logging
import os
from aiohttp import web
from pydantic import SecretStr
from framework.credentials.models import CredentialKey, CredentialObject
from framework.credentials.store import CredentialStore
from framework.server.app import validate_agent_path
logger = logging.getLogger(__name__)
_llm_key_providers_cache: dict | None = None
def _get_llm_key_providers() -> dict:
"""Lazily load the PROVIDERS dict from scripts/check_llm_key.py (cached)."""
global _llm_key_providers_cache
if _llm_key_providers_cache is None:
import importlib.util
from pathlib import Path as _Path
script = _Path(__file__).resolve().parents[3] / "scripts" / "check_llm_key.py"
if not script.exists():
logger.warning("check_llm_key.py not found at %s — key validation disabled", script)
_llm_key_providers_cache = {}
return _llm_key_providers_cache
spec = importlib.util.spec_from_file_location("check_llm_key", script)
if spec is None or spec.loader is None:
logger.warning("Failed to load spec for %s — key validation disabled", script)
_llm_key_providers_cache = {}
return _llm_key_providers_cache
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
_llm_key_providers_cache = mod.PROVIDERS
return _llm_key_providers_cache
def _get_store(request: web.Request) -> CredentialStore:
return request.app["credential_store"]
def _invalidate_queen_credentials_cache(request: web.Request) -> None:
"""Force every live Queen session to rebuild its ambient credentials block.
Called after credential save/delete so newly added or removed integrations
appear in the Queen's prompt on her next turn instead of waiting for the
cache TTL to expire.
"""
manager = request.app.get("manager")
if manager is None:
return
sessions = getattr(manager, "_sessions", None)
if not sessions:
return
for session in sessions.values():
phase_state = getattr(session, "phase_state", None)
if phase_state is None:
continue
provider = getattr(phase_state, "credentials_prompt_provider", None)
invalidate = getattr(provider, "invalidate", None)
if callable(invalidate):
try:
invalidate()
except Exception:
logger.debug(
"Credentials cache invalidate failed for session %s",
getattr(session, "id", "?"),
exc_info=True,
)
def _credential_to_dict(cred: CredentialObject) -> dict:
"""Serialize a CredentialObject to JSON — never include secret values."""
return {
"credential_id": cred.id,
"credential_type": str(cred.credential_type),
"key_names": list(cred.keys.keys()),
"created_at": cred.created_at.isoformat() if cred.created_at else None,
"updated_at": cred.updated_at.isoformat() if cred.updated_at else None,
}
async def handle_list_credentials(request: web.Request) -> web.Response:
"""GET /api/credentials — list all credential metadata (no secrets)."""
store = _get_store(request)
cred_ids = store.list_credentials()
credentials = []
for cid in cred_ids:
cred = store.get_credential(cid, refresh_if_needed=False)
if cred:
credentials.append(_credential_to_dict(cred))
return web.json_response({"credentials": credentials})
async def handle_get_credential(request: web.Request) -> web.Response:
"""GET /api/credentials/{credential_id} — get single credential metadata."""
credential_id = request.match_info["credential_id"]
store = _get_store(request)
cred = store.get_credential(credential_id, refresh_if_needed=False)
if cred is None:
return web.json_response({"error": f"Credential '{credential_id}' not found"}, status=404)
return web.json_response(_credential_to_dict(cred))
async def handle_save_credential(request: web.Request) -> web.Response:
"""POST /api/credentials — store a credential.
Body: {"credential_id": "...", "keys": {"key_name": "value", ...}}
"""
body = await request.json()
credential_id = body.get("credential_id")
keys = body.get("keys")
if not credential_id or not keys or not isinstance(keys, dict):
return web.json_response({"error": "credential_id and keys are required"}, status=400)
# ADEN_API_KEY is stored in the encrypted store via key_storage module
if credential_id == "aden_api_key":
key = keys.get("api_key", "").strip()
if not key:
return web.json_response({"error": "api_key is required"}, status=400)
from framework.credentials.key_storage import save_aden_api_key
save_aden_api_key(key)
# Immediately sync OAuth tokens from Aden (runs in executor because
# _presync_aden_tokens makes blocking HTTP calls to the Aden server).
try:
from aden_tools.credentials import CREDENTIAL_SPECS
from framework.credentials.validation import _presync_aden_tokens
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _presync_aden_tokens, CREDENTIAL_SPECS)
except Exception as exc:
logger.warning("Aden token sync after key save failed: %s", exc)
_invalidate_queen_credentials_cache(request)
return web.json_response({"saved": "aden_api_key"}, status=201)
store = _get_store(request)
cred = CredentialObject(
id=credential_id,
keys={k: CredentialKey(name=k, value=SecretStr(v)) for k, v in keys.items()},
)
store.save_credential(cred)
_invalidate_queen_credentials_cache(request)
return web.json_response({"saved": credential_id}, status=201)
async def handle_delete_credential(request: web.Request) -> web.Response:
"""DELETE /api/credentials/{credential_id} — delete a credential."""
credential_id = request.match_info["credential_id"]
if credential_id == "aden_api_key":
from framework.credentials.key_storage import delete_aden_api_key
deleted = delete_aden_api_key()
if not deleted:
return web.json_response({"error": "Credential 'aden_api_key' not found"}, status=404)
return web.json_response({"deleted": True})
store = _get_store(request)
deleted_from_store = store.delete_credential(credential_id)
# Also clear the env var for this process so the key doesn't
# reappear via the env-var fallback in _resolve_api_key().
from framework.server.routes_config import PROVIDER_ENV_VARS
env_var = PROVIDER_ENV_VARS.get(credential_id.lower())
deleted_from_env = False
if env_var and os.environ.pop(env_var, None) is not None:
deleted_from_env = True
if not deleted_from_store and not deleted_from_env:
return web.json_response({"error": f"Credential '{credential_id}' not found"}, status=404)
_invalidate_queen_credentials_cache(request)
return web.json_response({"deleted": True})
async def handle_check_agent(request: web.Request) -> web.Response:
"""POST /api/credentials/check-agent — check and validate agent credentials.
Uses the same ``validate_agent_credentials`` as agent startup:
1. Presence — is the credential available (env, encrypted store, Aden)?
2. Health check — does the credential actually work (lightweight HTTP call)?
Body: {"agent_path": "...", "verify": true}
"""
body = await request.json()
agent_path = body.get("agent_path")
verify = body.get("verify", True)
if not agent_path:
return web.json_response({"error": "agent_path is required"}, status=400)
try:
agent_path = str(validate_agent_path(agent_path))
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
try:
from framework.credentials.setup import load_agent_nodes
from framework.credentials.validation import (
ensure_credential_key_env,
validate_agent_credentials,
)
# Load env vars from shell config (same as runtime startup)
ensure_credential_key_env()
nodes = load_agent_nodes(agent_path)
result = validate_agent_credentials(nodes, verify=verify, raise_on_error=False, force_refresh=True)
# If any credential needs Aden, include ADEN_API_KEY as a first-class row
if any(c.aden_supported for c in result.credentials):
aden_key_status = {
"credential_name": "Aden Platform",
"credential_id": "aden_api_key",
"env_var": "ADEN_API_KEY",
"description": "API key from the Developers tab in Settings",
"help_url": "https://hive.adenhq.com/",
"tools": [],
"node_types": [],
"available": result.has_aden_key,
"valid": None,
"validation_message": None,
"direct_api_key_supported": True,
"aden_supported": True, # renders with "Authorize" button to open Aden
"credential_key": "api_key",
}
required = [aden_key_status] + [_status_to_dict(c) for c in result.credentials]
else:
required = [_status_to_dict(c) for c in result.credentials]
return web.json_response(
{
"required": required,
"has_aden_key": result.has_aden_key,
}
)
except Exception as e:
logger.exception(f"Error checking agent credentials: {e}")
return web.json_response(
{"error": "Internal server error while checking credentials"},
status=500,
)
def _status_to_dict(c) -> dict:
"""Convert a CredentialStatus to the JSON dict expected by the frontend."""
return {
"credential_name": c.credential_name,
"credential_id": c.credential_id,
"env_var": c.env_var,
"description": c.description,
"help_url": c.help_url,
"tools": c.tools,
"node_types": c.node_types,
"available": c.available,
"direct_api_key_supported": c.direct_api_key_supported,
"aden_supported": c.aden_supported,
"credential_key": c.credential_key,
"valid": c.valid,
"validation_message": c.validation_message,
"alternative_group": c.alternative_group,
}
def _collect_accounts_by_provider() -> dict[str, list[dict]]:
"""Snapshot connected accounts grouped by provider (credential_id).
Returns a dict mapping provider → list of account dicts with the
fields the frontend needs to render per-account rows. Best-effort —
returns {} if the adapter cannot be built.
"""
try:
from aden_tools.credentials.store_adapter import CredentialStoreAdapter
adapter = CredentialStoreAdapter.default()
grouped: dict[str, list[dict]] = {}
for acct in adapter.get_all_account_info():
provider = acct.get("provider", "")
if not provider:
continue
grouped.setdefault(provider, []).append(
{
"provider": provider,
"alias": acct.get("alias", ""),
"identity": acct.get("identity", {}) or {},
"source": acct.get("source", "aden"),
"credential_id": acct.get("credential_id", provider),
}
)
return grouped
except Exception:
logger.debug("Failed to collect accounts for specs response", exc_info=True)
return {}
async def handle_resync_credentials(request: web.Request) -> web.Response:
"""POST /api/credentials/resync — force-resync Aden OAuth tokens.
Called by the frontend after the user completes an OAuth flow on
hive.adenhq.com so the new account appears in Hive without waiting
for a cache TTL. Returns the current connected-accounts snapshot so
the caller can diff against what it had before opening the Aden tab.
"""
try:
from aden_tools.credentials import CREDENTIAL_SPECS
from framework.credentials.validation import _presync_aden_tokens, ensure_credential_key_env
ensure_credential_key_env()
if not os.environ.get("ADEN_API_KEY"):
return web.json_response(
{"error": "Aden API key not configured", "accounts_by_provider": {}},
status=400,
)
loop = asyncio.get_running_loop()
# _presync_aden_tokens makes blocking HTTP calls to the Aden server.
await loop.run_in_executor(None, lambda: _presync_aden_tokens(CREDENTIAL_SPECS, force=True))
_invalidate_queen_credentials_cache(request)
accounts_by_provider = _collect_accounts_by_provider()
return web.json_response(
{
"synced": True,
"accounts_by_provider": accounts_by_provider,
}
)
except Exception as exc:
logger.exception("Error during credential resync: %s", exc)
return web.json_response(
{"error": "Internal server error during resync"},
status=500,
)
async def handle_list_specs(request: web.Request) -> web.Response:
"""GET /api/credentials/specs — list ALL credential specs with availability."""
try:
from aden_tools.credentials import CREDENTIAL_SPECS
from framework.credentials.storage import (
CompositeStorage,
EncryptedFileStorage,
EnvVarStorage,
)
from framework.credentials.store import CredentialStore
from framework.credentials.validation import _presync_aden_tokens, ensure_credential_key_env
ensure_credential_key_env()
has_aden_key = bool(os.environ.get("ADEN_API_KEY"))
if has_aden_key:
_presync_aden_tokens(CREDENTIAL_SPECS)
# Build composite store (env → encrypted file)
env_mapping = {(spec.credential_id or name): spec.env_var for name, spec in CREDENTIAL_SPECS.items()}
env_storage = EnvVarStorage(env_mapping=env_mapping)
if os.environ.get("HIVE_CREDENTIAL_KEY"):
storage = CompositeStorage(primary=env_storage, fallbacks=[EncryptedFileStorage()])
else:
storage = env_storage
store = CredentialStore(storage=storage)
# Snapshot accounts once — the adapter walks the same specs internally
# and hits both Aden and local stores, so we reuse it for every row.
accounts_by_provider = _collect_accounts_by_provider()
specs = []
any_aden = False
for name, spec in CREDENTIAL_SPECS.items():
cred_id = spec.credential_id or name
if spec.aden_supported:
any_aden = True
accounts = accounts_by_provider.get(cred_id, [])
# Pure-OAuth (Aden-only, no direct API key) credentials are
# authoritative through Aden — the accounts list is the source of
# truth. Local stores can hold stale cache entries after a remote
# deletion, so trusting `store.is_available()` here would surface
# ghost "Connected" rows with no accounts and no add affordance.
if spec.aden_supported and not spec.direct_api_key_supported:
available = len(accounts) > 0
else:
available = store.is_available(cred_id)
specs.append(
{
"credential_name": name,
"credential_id": cred_id,
"env_var": spec.env_var,
"description": spec.description,
"help_url": spec.help_url,
"api_key_instructions": spec.api_key_instructions,
"tools": spec.tools,
"aden_supported": spec.aden_supported,
"direct_api_key_supported": spec.direct_api_key_supported,
"credential_key": spec.credential_key,
"credential_group": spec.credential_group,
"available": available,
"accounts": accounts,
}
)
# Include aden_api_key synthetic row if any spec uses Aden
if any_aden:
specs.insert(
0,
{
"credential_name": "Aden Platform",
"credential_id": "aden_api_key",
"env_var": "ADEN_API_KEY",
"description": "API key from the Developers tab in Settings",
"help_url": "https://hive.adenhq.com/",
"api_key_instructions": (
"1. Go to hive.adenhq.com\n2. Open Settings > Developers\n3. Copy your API key"
),
"tools": [],
"aden_supported": True,
"direct_api_key_supported": True,
"credential_key": "api_key",
"credential_group": "",
"available": has_aden_key,
},
)
return web.json_response({"specs": specs, "has_aden_key": has_aden_key})
except Exception as e:
logger.exception(f"Error listing credential specs: {e}")
return web.json_response(
{"error": "Internal server error while listing credential specs"},
status=500,
)
async def handle_validate_key(request: web.Request) -> web.Response:
"""POST /api/credentials/validate-key — health-check an LLM provider key.
Body: {"provider_id": "anthropic", "api_key": "sk-..."}
Returns: {"valid": bool|null, "message": str}
Runs the same checks as ``quickstart.sh`` (scripts/check_llm_key.py)
but in-process — no subprocess overhead.
"""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON body"}, status=400)
provider_id = body.get("provider_id", "").strip()
api_key = body.get("api_key", "").strip()
if not provider_id or not api_key:
return web.json_response({"error": "provider_id and api_key are required"}, status=400)
try:
checker = _get_llm_key_providers().get(provider_id)
if not checker:
return web.json_response({"valid": True, "message": f"No health check for {provider_id}"})
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, lambda: checker(api_key))
return web.json_response(result)
except Exception as exc:
logger.warning("LLM key validation failed for %s: %s", provider_id, exc)
return web.json_response({"valid": None, "message": f"Validation error: {exc}"})
def register_routes(app: web.Application) -> None:
"""Register credential routes on the application."""
# specs and check-agent must be registered BEFORE the {credential_id} wildcard
app.router.add_get("/api/credentials/specs", handle_list_specs)
app.router.add_post("/api/credentials/check-agent", handle_check_agent)
app.router.add_post("/api/credentials/resync", handle_resync_credentials)
app.router.add_post("/api/credentials/validate-key", handle_validate_key)
app.router.add_get("/api/credentials", handle_list_credentials)
app.router.add_post("/api/credentials", handle_save_credential)
app.router.add_get("/api/credentials/{credential_id}", handle_get_credential)
app.router.add_delete("/api/credentials/{credential_id}", handle_delete_credential)