ForcePilot/backend/package/yuxi/channel/plugins/discovery.py
Kris dd1857221c feat(channel/plugins): 新增渠道插件系统完整实现
实现了渠道插件的全生命周期管理能力,包括:
1. 插件清单的加载、解析与存储
2. 依赖校验与拓扑排序安装/卸载
3. 插件发现与安全沙箱执行环境
4. 插件注册中心与目录管理能力
2026-05-21 10:27:58 +08:00

292 lines
10 KiB
Python

from __future__ import annotations
import importlib
import logging
import os
import pkgutil
import threading
from pathlib import Path
from typing import Any
from yuxi.channel.protocols import MetaProtocol
from .manifest import ChannelPluginManifest
from .registry import ChannelPluginRegistry
from .sandbox import PluginSandbox, SandboxViolationError
logger = logging.getLogger(__name__)
_CacheEntry = tuple[float, float, list[Path]]
class ChannelPluginDiscovery:
"""自动发现项目中的渠道插件。"""
_path_allowlist: list[str] | None = None
_path_allowlist_loaded: bool = False
_discovery_cache: dict[str, _CacheEntry] = {}
_discovery_cache_lock: threading.Lock | None = None
@classmethod
def _ensure_path_allowlist_loaded(cls) -> None:
if cls._path_allowlist_loaded:
return
cls._path_allowlist_loaded = True
cls._load_path_allowlist_from_env()
@classmethod
def _load_path_allowlist_from_env(cls) -> None:
allowed = []
env_val = os.getenv("YUXI_PLUGIN_ALLOWLIST")
if env_val:
for entry in env_val.split(os.pathsep):
entry = entry.strip()
if entry:
allowed.append(str(Path(entry).resolve()))
extensions_dir = Path(__file__).resolve().parent.parent / "extensions"
if extensions_dir.exists():
allowed.append(str(extensions_dir))
cls._path_allowlist = allowed
@classmethod
def set_path_allowlist(cls, paths: list[str]) -> None:
cls._path_allowlist = [str(Path(p).resolve()) for p in paths]
cls._path_allowlist_loaded = True
@classmethod
def get_path_allowlist(cls) -> list[str]:
cls._ensure_path_allowlist_loaded()
return list(cls._path_allowlist) if cls._path_allowlist else []
@classmethod
def clear_path_allowlist(cls) -> None:
cls._path_allowlist = None
cls._path_allowlist_loaded = False
@classmethod
def _ensure_cache_lock(cls) -> threading.Lock:
if cls._discovery_cache_lock is None:
cls._discovery_cache_lock = threading.Lock()
return cls._discovery_cache_lock
@classmethod
def clear_discovery_cache(cls) -> None:
with cls._ensure_cache_lock():
cls._discovery_cache.clear()
@classmethod
def _is_path_allowed(cls, path: Path) -> bool:
cls._ensure_path_allowlist_loaded()
if not cls._path_allowlist:
return False
resolved = str(path.resolve())
return any(resolved == allowed or resolved.startswith(allowed + os.sep) for allowed in cls._path_allowlist)
@classmethod
def validate_plugin_path(cls, path: str | Path, purpose: str = "load") -> Path:
"""验证插件路径安全性并返回解析后的路径。
校验规则:
1. 路径不能包含 ``..`` 组件(防御深度,防止目录遍历)
2. 路径必须在白名单目录内
3. 白名单为空时拒绝所有路径(安全默认)
Args:
path: 要验证的路径
purpose: 操作目的描述(用于日志和错误消息)
Returns:
解析后的绝对路径
Raises:
PermissionError: 路径校验失败,存在安全风险
"""
cls._ensure_path_allowlist_loaded()
raw_path = Path(path)
if ".." in raw_path.parts:
logger.warning(
"Path traversal attempt blocked for %s: '%s' contains '..' component",
purpose, path,
)
raise PermissionError(
f"Path traversal not allowed for plugin {purpose}: '{path}'"
)
resolved = raw_path.resolve()
if not cls._path_allowlist:
logger.warning(
"Plugin path rejected for %s (allowlist is empty): %s",
purpose, resolved,
)
raise PermissionError(
f"Plugin {purpose} rejected: no allowed directories configured"
)
if not cls._is_path_allowed(resolved):
logger.warning(
"Plugin path not in allowlist for %s: '%s' (resolved: %s)",
purpose, path, resolved,
)
raise PermissionError(
f"Plugin path not in allowlist for {purpose}: '{resolved}'"
)
return resolved
@classmethod
def discover_builtin(cls, package_name: str = "yuxi.channels") -> list[Any]:
"""扫描指定 Python 包下的所有模块,注册实现了 ConfigProtocol 的类。
导入前对每个模块源文件执行 AST 静态安全分析,
拦截包含 ``__import__()`` 调用、``from ... import *`` 等危险语法结构的模块。
"""
discovered = []
try:
package = importlib.import_module(package_name)
except ImportError:
logger.warning("Cannot import package '%s' for plugin discovery", package_name)
return discovered
package_path = getattr(package, "__path__", None)
if not package_path:
logger.warning("Package '%s' has no __path__", package_name)
return discovered
for _, module_name, is_pkg in pkgutil.iter_modules(package_path):
if is_pkg:
continue
full_name = f"{package_name}.{module_name}"
module_file = Path(package_path[0]) / f"{module_name}.py"
if module_file.exists():
try:
source = module_file.read_text(encoding="utf-8")
PluginSandbox.validate_source(source, str(module_file))
except SandboxViolationError:
logger.warning(
"Builtin plugin '%s' failed AST security validation: %s",
full_name, module_file,
)
continue
try:
module = importlib.import_module(full_name)
except Exception:
logger.exception("Failed to import module %s", full_name)
continue
for attr_name in dir(module):
obj = getattr(module, attr_name)
if not isinstance(obj, type):
continue
if getattr(obj, "__module__", None) != full_name:
continue
if not isinstance(obj, MetaProtocol):
continue
plugin_id = getattr(obj, "id", None)
if not plugin_id or not isinstance(plugin_id, str) or not plugin_id.strip():
continue
try:
ChannelPluginRegistry.register(obj)
discovered.append(obj)
except ValueError:
continue
return discovered
@classmethod
def discover_from_path(cls, plugin_path: str | Path) -> list[Any]:
"""从文件系统路径加载插件模块(带路径白名单校验、扫描缓存和沙箱执行)。"""
discovered = []
plugin_path = Path(plugin_path)
if not plugin_path.exists():
logger.warning("Plugin path does not exist: %s", plugin_path)
return discovered
if not cls._is_path_allowed(plugin_path):
logger.warning("Plugin path not in allowlist: %s", plugin_path)
return discovered
if plugin_path.is_file() and plugin_path.suffix == ".py":
module_files = [plugin_path]
elif plugin_path.is_dir():
module_files = cls._scan_with_cache(plugin_path)
else:
return discovered
sandbox = PluginSandbox()
for file_path in module_files:
try:
module = sandbox.load_module_from_file(file_path, file_path.stem)
except Exception:
logger.exception("Failed to load plugin from %s", file_path)
continue
for attr_name in dir(module):
obj = getattr(module, attr_name)
if not isinstance(obj, type):
continue
if getattr(obj, "__module__", None) != module.__name__:
continue
if not isinstance(obj, MetaProtocol):
continue
plugin_id = getattr(obj, "id", None)
if not plugin_id or not isinstance(plugin_id, str) or not plugin_id.strip():
continue
try:
ChannelPluginRegistry.register(obj)
discovered.append(obj)
except ValueError:
continue
return discovered
@classmethod
def _scan_with_cache(cls, plugin_path: Path) -> list[Path]:
resolved = str(plugin_path.resolve())
with cls._ensure_cache_lock():
cached = cls._discovery_cache.get(resolved)
if cached is not None:
_max_mtime, dir_mtime, files = cached
try:
if plugin_path.stat().st_mtime <= dir_mtime and cls._cache_still_valid(files, _max_mtime):
return list(files)
except OSError:
pass
module_files = list(plugin_path.rglob("*.py"))
max_mtime = max(
(f.stat().st_mtime for f in module_files),
default=0.0,
)
try:
dir_mtime = plugin_path.stat().st_mtime
except OSError:
dir_mtime = 0.0
with cls._ensure_cache_lock():
cls._discovery_cache[resolved] = (max_mtime, dir_mtime, module_files)
return module_files
@staticmethod
def _cache_still_valid(files: list[Path], cached_max_mtime: float) -> bool:
for f in files:
try:
if f.stat().st_mtime > cached_max_mtime:
return False
except OSError:
return False
return True
@classmethod
def discover_manifests(cls, manifest_dir: str | Path) -> list[ChannelPluginManifest]:
"""从清单目录加载插件元数据。"""
from .manifest import load_manifests_from_directory
return load_manifests_from_directory(manifest_dir)