220 lines
7.1 KiB
Python
220 lines
7.1 KiB
Python
|
|
"""
|
|||
|
|
插件加载器 — 加载、校验、沙箱执行第三方节点插件
|
|||
|
|
|
|||
|
|
插件规范 (manifest.json):
|
|||
|
|
{
|
|||
|
|
"name": "my-plugin",
|
|||
|
|
"version": "1.0.0",
|
|||
|
|
"description": "...",
|
|||
|
|
"author": "...",
|
|||
|
|
"node_type": "custom_action",
|
|||
|
|
"node_label": "自定义操作",
|
|||
|
|
"category": "custom",
|
|||
|
|
"entry": "execute.py",
|
|||
|
|
"inputs_schema": {"type": "object", "properties": {...}},
|
|||
|
|
"outputs_schema": {"type": "object", "properties": {...}}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
执行函数签名:
|
|||
|
|
async def execute(inputs: dict, context: dict) -> dict
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
import json
|
|||
|
|
import logging
|
|||
|
|
import os
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
import tempfile
|
|||
|
|
import traceback
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any, Dict, List, Optional
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
# 插件存储根目录
|
|||
|
|
PLUGINS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "plugins"
|
|||
|
|
PLUGINS_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_manifest(manifest: dict) -> tuple[bool, str]:
|
|||
|
|
"""校验 manifest.json 是否合法。"""
|
|||
|
|
required = ["name", "version", "node_type"]
|
|||
|
|
for key in required:
|
|||
|
|
if key not in manifest:
|
|||
|
|
return False, f"缺少必填字段: {key}"
|
|||
|
|
if not isinstance(manifest.get("name"), str) or not manifest["name"].strip():
|
|||
|
|
return False, "name 必须是非空字符串"
|
|||
|
|
if not isinstance(manifest.get("node_type"), str) or not manifest["node_type"].strip():
|
|||
|
|
return False, "node_type 必须是非空字符串"
|
|||
|
|
# node_type 必须是合法的标识符
|
|||
|
|
import re
|
|||
|
|
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', manifest["node_type"]):
|
|||
|
|
return False, "node_type 必须是合法的 Python 标识符"
|
|||
|
|
return True, "ok"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_plugin_code(plugin_id: str, code: str, node_type: str) -> str:
|
|||
|
|
"""将插件代码写入磁盘并返回文件路径。"""
|
|||
|
|
plugin_dir = PLUGINS_DIR / plugin_id
|
|||
|
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
file_path = plugin_dir / f"{node_type}.py"
|
|||
|
|
file_path.write_text(code, encoding="utf-8")
|
|||
|
|
return str(file_path)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def unload_plugin_code(plugin_id: str):
|
|||
|
|
"""从磁盘删除插件代码。"""
|
|||
|
|
import shutil
|
|||
|
|
plugin_dir = PLUGINS_DIR / plugin_id
|
|||
|
|
if plugin_dir.exists():
|
|||
|
|
shutil.rmtree(plugin_dir)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _make_sandbox_globals():
|
|||
|
|
"""构建受限的内建函数集。"""
|
|||
|
|
safe_builtins = {
|
|||
|
|
"True": True, "False": False, "None": None,
|
|||
|
|
"abs": abs, "all": all, "any": any, "bool": bool,
|
|||
|
|
"dict": dict, "enumerate": enumerate, "filter": filter,
|
|||
|
|
"float": float, "int": int, "isinstance": isinstance,
|
|||
|
|
"len": len, "list": list, "map": map, "max": max,
|
|||
|
|
"min": min, "range": range, "round": round, "set": set,
|
|||
|
|
"sorted": sorted, "str": str, "sum": sum, "tuple": tuple,
|
|||
|
|
"type": type, "zip": zip,
|
|||
|
|
"print": print, "json": json,
|
|||
|
|
"Exception": Exception, "ValueError": ValueError,
|
|||
|
|
"TypeError": TypeError, "KeyError": KeyError,
|
|||
|
|
}
|
|||
|
|
return {"__builtins__": safe_builtins}
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def execute_plugin_sandbox(
|
|||
|
|
code: str,
|
|||
|
|
inputs: Dict[str, Any],
|
|||
|
|
context: Optional[Dict[str, Any]] = None,
|
|||
|
|
timeout_seconds: int = 30,
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""在沙箱中异步执行插件代码。
|
|||
|
|
|
|||
|
|
使用 subprocess 隔离执行,超时自动终止,防止恶意代码影响主进程。
|
|||
|
|
"""
|
|||
|
|
context = context or {}
|
|||
|
|
|
|||
|
|
# 将代码和输入写入临时脚本
|
|||
|
|
wrapper_code = f'''
|
|||
|
|
import json
|
|||
|
|
import sys
|
|||
|
|
import traceback
|
|||
|
|
|
|||
|
|
# 用户插件代码
|
|||
|
|
{code}
|
|||
|
|
|
|||
|
|
# 准备输入
|
|||
|
|
_inputs = json.loads(sys.stdin.read())
|
|||
|
|
_ctx = _inputs.get("__context__", {{}})
|
|||
|
|
_user_inputs = _inputs.get("__inputs__", {{}})
|
|||
|
|
|
|||
|
|
# 查找 execute 函数
|
|||
|
|
if "execute" not in dir():
|
|||
|
|
print(json.dumps({{"ok": False, "error": "插件缺少 execute 函数"}}))
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
import asyncio
|
|||
|
|
result = asyncio.run(execute(_user_inputs, _ctx))
|
|||
|
|
print(json.dumps({{"ok": True, "result": result}}, ensure_ascii=False, default=str))
|
|||
|
|
except Exception as e:
|
|||
|
|
print(json.dumps({{"ok": False, "error": str(e), "traceback": traceback.format_exc()}}, ensure_ascii=False))
|
|||
|
|
'''
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
proc = await asyncio.wait_for(
|
|||
|
|
asyncio.create_subprocess_exec(
|
|||
|
|
sys.executable, "-c", wrapper_code,
|
|||
|
|
stdin=asyncio.subprocess.PIPE,
|
|||
|
|
stdout=asyncio.subprocess.PIPE,
|
|||
|
|
stderr=asyncio.subprocess.PIPE,
|
|||
|
|
),
|
|||
|
|
timeout=5,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
input_data = json.dumps({"__inputs__": inputs, "__context__": context})
|
|||
|
|
stdout, stderr = await asyncio.wait_for(
|
|||
|
|
proc.communicate(input_data.encode("utf-8")),
|
|||
|
|
timeout=timeout_seconds,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if stderr:
|
|||
|
|
logger.warning("插件沙箱 stderr: %s", stderr.decode("utf-8", errors="replace")[:500])
|
|||
|
|
|
|||
|
|
result = json.loads(stdout.decode("utf-8"))
|
|||
|
|
if result.get("ok"):
|
|||
|
|
return {"success": True, "result": result["result"]}
|
|||
|
|
else:
|
|||
|
|
return {"success": False, "error": result.get("error", "未知错误")}
|
|||
|
|
|
|||
|
|
except asyncio.TimeoutError:
|
|||
|
|
return {"success": False, "error": f"插件执行超时({timeout_seconds}秒)"}
|
|||
|
|
except json.JSONDecodeError as e:
|
|||
|
|
return {"success": False, "error": f"插件返回解析失败: {e}"}
|
|||
|
|
except Exception as e:
|
|||
|
|
return {"success": False, "error": f"插件执行异常: {e}"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def list_plugin_node_types() -> List[Dict[str, Any]]:
|
|||
|
|
"""列出当前已加载的插件节点类型,供工作流编辑器使用。"""
|
|||
|
|
from app.core.database import SessionLocal
|
|||
|
|
from app.models.plugin import NodePlugin
|
|||
|
|
|
|||
|
|
db = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
plugins = db.query(NodePlugin).filter(NodePlugin.enabled == True).all()
|
|||
|
|
return [
|
|||
|
|
{
|
|||
|
|
"id": p.id,
|
|||
|
|
"node_type": p.node_type,
|
|||
|
|
"node_label": p.node_label or p.name,
|
|||
|
|
"category": p.category,
|
|||
|
|
"description": p.description,
|
|||
|
|
"inputs_schema": p.inputs_schema,
|
|||
|
|
"outputs_schema": p.outputs_schema,
|
|||
|
|
"icon": p.icon,
|
|||
|
|
}
|
|||
|
|
for p in plugins
|
|||
|
|
]
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def register_plugin_node_type(plugin) -> bool:
|
|||
|
|
"""将插件注册到工具注册表,使工作流编辑器可用。"""
|
|||
|
|
from app.services.tool_registry import tool_registry
|
|||
|
|
|
|||
|
|
node_type = plugin.node_type
|
|||
|
|
node_label = plugin.node_label or plugin.name
|
|||
|
|
|
|||
|
|
schema = {
|
|||
|
|
"name": node_type,
|
|||
|
|
"description": plugin.description or f"自定义节点: {node_label}",
|
|||
|
|
"parameters": {
|
|||
|
|
"type": "object",
|
|||
|
|
"properties": (plugin.inputs_schema or {}).get("properties", {}),
|
|||
|
|
"required": (plugin.inputs_schema or {}).get("required", []),
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
tool_registry.register_external_tool(
|
|||
|
|
name=node_type,
|
|||
|
|
description=schema["description"],
|
|||
|
|
parameters=schema["parameters"],
|
|||
|
|
category=f"plugin:{plugin.category}",
|
|||
|
|
)
|
|||
|
|
logger.info("插件节点类型已注册: %s", node_type)
|
|||
|
|
return True
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning("插件节点类型注册失败 [%s]: %s", node_type, e)
|
|||
|
|
return False
|