deer-flow/backend/app/gateway/capabilities.py
lukyturnflow bef8c9c2e3
fix(skills): keep disabled skills disabled when the extensions config cannot be read (#7705)
* fix(skills): fail closed when the extensions config cannot be read

* test(skills): cover a disabled skill when the extensions config cannot be read

* style(tests): format the fail-closed regression for ruff

* style(tests): fit the docstring and signature inside the 240-column limit

* fix(skills): fail closed in the user-scoped listing when the config cannot be read

* test(skills): cover the user-scoped listing with an unreadable extensions config

* fix(gateway): keep skill read surfaces loud about an unreadable extensions config

Folding the user-scoped degraded mode in (#7704) also stopped the skill
read endpoints from failing on a config that cannot be read: the storage
now answers with an all-disabled listing, which regressed the #7155
contract (a generic 500 with the host path only in the server log) into
a 200 that presents a broken deployment as a legitimately empty one.

Keep both properties: the storage still fails closed for the runtime
gate, and the read surfaces re-validate the config explicitly so the
raised error reaches each handler's generic 500 branch unchanged.
First-run behavior is untouched: no config file resolves to an empty
config without raising, so default-enabled still applies.

* fix(skills): redact config-load failures and keep the read preflight off the event loop

Review follow-ups:

- Log the failure shape instead of the exception text in both degraded
  modes: from_file resolves $VAR placeholders before validation, so its
  message can embed resolved credentials. Reuse _describe_load_failure,
  the loader's own redaction helper.
- Run the gateway read preflight through asyncio.to_thread in the
  capability adapter, matching the scan it precedes.
- Cover the user-scoped degraded branch with a CUSTOM skill that has
  per-user state on disk, so the second read's guard is directly
  regression-tested (verified red with the guard removed).

---------

Co-authored-by: Jay0130-a <309071492+Jay0130-a@users.noreply.github.com>
2026-11-05 15:07:07 +08:00

255 lines
12 KiB
Python

"""Adapters over existing integration services; no duplicate credential/config store."""
import asyncio
from dataclasses import dataclass
from typing import Any, Literal, Protocol
from uuid import uuid4
from fastapi import HTTPException, Request
from pydantic import BaseModel, Field, ValidationError
from app.gateway.deps import get_current_user_from_request, is_admin_user
from app.gateway.routers import integrations, mcp, skills
from deerflow.capabilities.business import connection_config
from deerflow.capabilities.catalog import PluginManifest
from deerflow.capabilities.runtime import ambiguous_installation_ids, installation_id
from deerflow.config.app_config import AppConfig
from deerflow.integrations.lark_cli import get_lark_integration_status
from deerflow.runtime.user_context import get_effective_user_id
class CapabilityInstallation(BaseModel):
id: str
plugin_id: str | None = None
adapter: str
name: str
description: str = ""
selectable: bool = True
installed: bool = True
enabled: bool | None = None
version: str | None = None
scope: str = "deployment"
auth_status: str = "unknown"
health: str = "unknown"
reference: str
category: str | None = None
icon: str | None = None
class InstallationList(BaseModel):
items: list[CapabilityInstallation] = Field(default_factory=list)
can_manage: bool = False
@dataclass(frozen=True)
class AdapterContext:
request: Request
config: AppConfig
user_id: str
scope: Literal["deployment", "user"] = "deployment"
class CapabilityAdapter(Protocol):
async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]: ...
async def install(self, context: AdapterContext, manifest: PluginManifest, name: str, configuration: dict[str, Any]) -> None: ...
def validate_mcp_connection(configuration: dict[str, Any]) -> None:
"""Validate the normalized transport definition, not manifest form fields."""
from urllib.parse import urlsplit
transport = configuration.get("type", configuration.get("transport", "stdio"))
if not isinstance(transport, str):
raise HTTPException(422, "Supply a supported MCP transport")
if transport in {"http", "sse"}:
url = configuration.get("url")
valid = False
try:
if isinstance(url, str):
parsed = urlsplit(url)
_ = parsed.port # Validate malformed ports too.
has_http_scheme = parsed.scheme in {"https", "http"}
has_host = bool(parsed.hostname)
has_credentials = parsed.username is not None or parsed.password is not None
has_whitespace = any(c.isspace() for c in url)
valid = has_http_scheme and has_host and not has_credentials and not parsed.fragment and not has_whitespace
except ValueError:
valid = False
if not valid:
raise HTTPException(422, "Supply an HTTP(S) MCP server URL without embedded credentials")
return
if transport != "stdio":
raise HTTPException(422, "Supply a supported MCP transport and its required connection fields")
command = configuration.get("command")
if not isinstance(command, str) or not command.strip():
raise HTTPException(422, "Supply a supported MCP transport and its required connection fields")
class MCPAdapter:
async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]:
if context.scope == "user":
from deerflow.mcp.user_config import read_user_mcp_config
try:
raw_config = await asyncio.to_thread(read_user_mcp_config, context.user_id)
except ValueError as exc:
mcp._raise_invalid_mcp_configuration(str(exc), cause=exc)
servers = mcp._mcp_server_responses_from_raw(raw_config)
else:
servers = await asyncio.to_thread(mcp._load_raw_mcp_server_responses)
result = []
ambiguous = ambiguous_installation_ids({name: server.model_dump() for name, server in servers.items()})
for name, server in servers.items():
raw = server.model_dump()
metadata = raw.get("capability") or {}
metadata = metadata if isinstance(metadata, dict) else {}
auth = server.user_auth
if auth and auth.enabled:
auth_status = "configured" if auth.users.get(context.user_id) else "required"
elif server.oauth or server.headers or server.env:
auth_status = "configured"
else:
auth_status = "not_required"
presentation = raw.get("presentation")
icon = presentation.get("icon") if isinstance(presentation, dict) else None
# Public discovery projects explicit safe fields, never connection
# URLs, commands, env, OAuth configuration, or another user's IDs.
identity = installation_id(name, raw)
result.append(
CapabilityInstallation(
id=f"ambiguous:{installation_id(name, {})}" if identity in ambiguous else identity,
selectable=identity not in ambiguous,
health="ambiguous" if identity in ambiguous else "unknown",
plugin_id=metadata.get("plugin_id") if isinstance(metadata.get("plugin_id"), str) else None,
adapter="mcp",
scope=context.scope,
name=name,
reference=name,
description=server.description or "",
enabled=server.enabled,
version=metadata.get("version") if isinstance(metadata.get("version"), str) else None,
auth_status=auth_status,
icon=icon if isinstance(icon, str) and icon.startswith("data:image/png;base64,") and len(icon) <= 100_000 else None,
)
)
return result
async def install(self, context: AdapterContext, manifest: PluginManifest, name: str, configuration: dict[str, Any]) -> None:
if not name.strip():
raise HTTPException(422, "Installation name is required")
if manifest.adapter == "mcp":
validate_mcp_connection(configuration)
definition = {**configuration, "capability": {"id": str(uuid4()), "plugin_id": manifest.id, "version": manifest.version}}
try:
body = mcp.McpConfigUpdateRequest(mcp_servers={name: mcp.McpServerConfigResponse.model_validate(definition)})
except ValidationError as error:
raise HTTPException(422, "Invalid MCP configuration") from error
if context.scope == "user":
from app.gateway.routers.personal_mcp import create_servers
await create_servers(context.request, body)
else:
await mcp.create_mcp_servers(context.request, body)
class BusinessAdapter(MCPAdapter):
"""Build only bundled providers; reuse the MCP store, lifecycle and discovery."""
async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]:
from deerflow.capabilities.business import CREDENTIALS
return [item for item in await super().list_installations(context) if item.plugin_id in CREDENTIALS]
async def install(self, context: AdapterContext, manifest: PluginManifest, name: str, configuration: dict[str, Any]) -> None:
try:
definition = connection_config(manifest.id, configuration)
except ValueError as error:
raise HTTPException(422, str(error)) from None
definition["description"] = manifest.description.get("en-US", "")
await super().install(context, manifest, name, definition)
class LarkAdapter:
async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]:
status = await asyncio.to_thread(get_lark_integration_status, context.user_id, context.config)
return [
CapabilityInstallation(
id="lark",
plugin_id="lark",
adapter="lark",
name="Lark / Feishu",
reference="lark",
installed=status.installed,
version=status.manifest_version,
scope="user",
auth_status="connected" if status.auth.status == "authenticated" and status.auth.verified else "configured" if status.auth.status == "authenticated" else "required",
health="unknown",
)
]
async def install(self, context: AdapterContext, manifest: PluginManifest, name: str, configuration: dict[str, Any]) -> None:
await integrations.install_lark(context.request, context.config)
class SkillAdapter:
async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]:
# The listing reports the enabled flag, which the storage layer fails
# closed on an unreadable extensions config; keep this read surface
# loud about the broken config instead of serving the degraded list.
# Blocking filesystem IO: always off the event loop, like the scan below.
await asyncio.to_thread(skills._assert_extensions_config_readable)
response = await asyncio.to_thread(lambda: skills._get_user_skill_storage(context.config).load_skills(enabled_only=False))
response = await skills._filter_visible_skills(context.request, context.config, response)
return [
CapabilityInstallation(
id=f"skill:{skill.category}:{skill.name}",
adapter="skills",
name=skill.name,
reference=skill.name,
description=skill.description,
category=str(skill.category),
enabled=skill.enabled,
scope="user" if str(skill.category) == "custom" else "deployment",
auth_status="not_required",
)
for skill in response
]
async def install(self, context: AdapterContext, manifest: PluginManifest, name: str, configuration: dict[str, Any]) -> None:
raise HTTPException(422, "Use the existing skill archive upload API")
class AdapterRegistry:
def __init__(self) -> None:
self._adapters: dict[str, CapabilityAdapter] = {}
def register(self, name: str, adapter: CapabilityAdapter) -> None:
if name in self._adapters:
raise ValueError(f"Adapter already registered: {name}")
self._adapters[name] = adapter
def get(self, name: str) -> CapabilityAdapter:
adapter = self._adapters.get(name)
if adapter is None:
raise HTTPException(422, "This capability only has a setup guide; no installation adapter is available")
return adapter
registry = AdapterRegistry()
registry.register("mcp", MCPAdapter())
registry.register("business", BusinessAdapter())
registry.register("lark", LarkAdapter())
registry.register("skills", SkillAdapter())
async def list_installations(adapter: str, request: Request, config: AppConfig, scope: Literal["deployment", "user", "all"] = "deployment") -> InstallationList:
user_id = get_effective_user_id()
if scope != "deployment":
user_id = str((await get_current_user_from_request(request)).id)
context = AdapterContext(request, config, user_id, "user" if scope == "user" else "deployment")
items = await registry.get(adapter).list_installations(context)
if scope == "all" and adapter in {"mcp", "business"}:
items += await registry.get(adapter).list_installations(AdapterContext(request, config, user_id, "user"))
return InstallationList(items=items, can_manage=scope == "user" or await is_admin_user(request))