"""Plugin management for the local console; credentials never leave this API.""" from concurrent.futures import ThreadPoolExecutor import io from pathlib import Path from threading import Lock from typing import Literal import uuid import zipfile from fastapi import APIRouter, HTTPException from fastapi.exceptions import RequestValidationError from fastapi.routing import APIRoute from fastapi.responses import JSONResponse from pydantic import BaseModel, Field, ValidationError from artemis.plugins.installer import download, install_archive, install_local, load_catalog from artemis.plugins.models import PluginManifest, PluginProfile, PluginSelection from artemis.plugins.runtime import PluginRuntime from artemis.plugins.store import PluginStore class PluginRoute(APIRoute): def get_route_handler(self): handler = super().get_route_handler() async def sanitized(request): try: return await handler(request) except RequestValidationError: return JSONResponse( status_code=422, content={ "detail": "插件配置格式不正确,请检查字段类型和清单;输入内容不会回显。" }, ) return sanitized router = APIRouter(prefix="/api/plugins", tags=["plugins"], route_class=PluginRoute) _executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="plugin-installer") _jobs: dict[str, dict] = {} _lock = Lock() def get_store() -> PluginStore: return PluginStore() class InstallRequest(BaseModel): plugin_id: str | None = None version: str | None = None local_path: str | None = None class EnableRequest(BaseModel): enabled: bool class DiagnoseRequest(BaseModel): profile: str = "default" class CustomRequest(BaseModel): manifest: PluginManifest skill_content: str = Field(default="", max_length=262144) class ResourceConfirmation(BaseModel): resource: str = Field(min_length=1, max_length=256) confirmed_stopped: Literal[True] def public_error(exc: Exception) -> str: return "插件清单或配置格式不正确" if isinstance(exc, ValidationError) else str(exc) @router.get("") def list_plugins(): store = get_store() return {"plugins": store.list(), "blocked_resources": store.blocked_resources()} @router.post("/resources/confirm-stopped") def confirm_resource_stopped(request: ResourceConfirmation): from artemis.runtime.device_lock import DeviceExecutionLock if any( request.resource in owner.resource_ids for owner in DeviceExecutionLock.get_active_owners().values() ): raise HTTPException(409, "此资源仍被任务占用,请先停止相关任务并确认插件动作已停止") get_store().confirm_resource_stopped(request.resource) return {"ok": True} @router.get("/catalog") def marketplace(): try: return load_catalog().model_dump() except Exception: raise HTTPException(502, "暂时无法读取插件市场,请稍后重试;已安装插件不受影响") from None @router.post("/install", status_code=202) def install_plugin(request: InstallRequest): if bool(request.local_path) == bool(request.plugin_id): raise HTTPException(400, "请选择一个市场插件或本地安装包") with _lock: if any(j["status"] in {"queued", "running"} for j in _jobs.values()): raise HTTPException(409, "已有插件正在安装,请等待安装结束") for key in list(_jobs)[:-30]: del _jobs[key] job_id = str(uuid.uuid4()) _jobs[job_id] = {"id": job_id, "status": "queued", "stage": "等待安装", "progress": 0} def progress(stage, percent): with _lock: _jobs[job_id].update(status="running", stage=stage, progress=percent) def run(): try: store = get_store() if request.local_path: installed = install_local(store, Path(request.local_path), progress) else: progress("获取市场索引", 0) candidates = [ p for p in load_catalog().plugins if p.manifest.id == request.plugin_id and (request.version is None or p.manifest.version == request.version) ] from packaging.version import Version compatible = [] for entry in candidates: try: entry.manifest.check_compatible() compatible.append(entry) except ValueError: continue if not compatible: raise ValueError("没有已验证且兼容的插件版本") entry = max(compatible, key=lambda p: Version(p.manifest.version)) installed = install_archive( store, download(entry.archive_url, progress), entry=entry, progress=progress ) with _lock: _jobs[job_id].update( status="completed", stage="安装完成,请配置并启用", progress=100, plugin_id=installed["manifest"]["id"], ) except Exception as exc: with _lock: # HTTP exceptions may contain signed URLs; expose only our validation messages. message = ( public_error(exc) if isinstance(exc, (ValueError, FileNotFoundError)) else "下载或安装失败,请检查网络及安装包" ) _jobs[job_id].update(status="failed", stage=message) _executor.submit(run) return {"job_id": job_id} @router.get("/install/{job_id}") def install_progress(job_id: str): with _lock: if job_id not in _jobs: raise HTTPException(404, "安装记录不存在,服务可能已重启;请刷新插件列表") return dict(_jobs[job_id]) @router.post("/custom") def register_custom(request: CustomRequest): buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as archive: archive.writestr("plugin.json", request.manifest.model_dump_json()) if request.manifest.skill: archive.writestr(request.manifest.skill, request.skill_content) try: return install_archive(get_store(), buffer.getvalue()) except (ValueError, OSError) as exc: raise HTTPException(400, public_error(exc)) from None @router.put("/{plugin_id}/profiles") def save_profile(plugin_id: str, profile: PluginProfile): try: get_store().save_profile(plugin_id, profile) return get_store().get(plugin_id) except ValueError as exc: raise HTTPException(400, public_error(exc)) from None @router.put("/{plugin_id}/enabled") def enable_plugin(plugin_id: str, request: EnableRequest): try: get_store().set_enabled(plugin_id, request.enabled) return get_store().get(plugin_id) except ValueError as exc: raise HTTPException(400, public_error(exc)) from None @router.post("/{plugin_id}/diagnose") async def diagnose_plugin(plugin_id: str, request: DiagnoseRequest): runtime = None result = {"ok": False, "message": "插件连接失败,请检查启用状态、授权、命令和配置"} try: store = get_store() manifest = PluginManifest.model_validate(store.get(plugin_id)["manifest"]) if manifest.workspace and manifest.server is None and manifest.skill is None: store.resolve(PluginSelection(id=plugin_id, profile=request.profile)) return {"ok": True, "tools": {}, "message": "工作区扩展已就绪"} runtime = PluginRuntime( [PluginSelection(id=plugin_id, profile=request.profile)], store=store, target=manifest.targets[0], ) loaded = await runtime.load(plugin_id) result = { "ok": loaded.status == "success", "tools": loaded.structured_content or {}, "message": "插件连接正常", } except Exception: result = {"ok": False, "message": "插件连接失败,请检查启用状态、授权、命令和配置"} finally: if runtime: try: await runtime.aclose() except RuntimeError: result = {"ok": False, "message": "插件未确认停止,请检查受保护的资源"} return result @router.delete("/{plugin_id}") def uninstall_plugin(plugin_id: str): try: get_store().uninstall(plugin_id) return {"ok": True} except (ValueError, OSError) as exc: raise HTTPException(409, public_error(exc)) from None