From 3d4c4ed01bbd950b06404847abefaceecfe1f35d Mon Sep 17 00:00:00 2001 From: Weilong Liao <37870767+Soulter@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:45:59 +0800 Subject: [PATCH] fix: validate plugin install sources (#9061) --- astrbot/core/star/star_manager.py | 76 +-- astrbot/core/star/updator.py | 134 ++++++ astrbot/core/zip_updator.py | 78 ++- astrbot/dashboard/api/plugins.py | 13 + astrbot/dashboard/schemas.py | 7 + astrbot/dashboard/services/plugin_service.py | 209 +++++++- .../src/api/generated/openapi-v1/sdk.gen.ts | 12 +- .../src/api/generated/openapi-v1/types.gen.ts | 21 + dashboard/src/api/v1.ts | 6 + .../src/components/shared/ExtensionCard.vue | 55 ++- .../locales/en-US/features/extension.json | 18 +- .../locales/ru-RU/features/extension.json | 18 +- .../locales/zh-CN/features/extension.json | 18 +- dashboard/src/views/ExtensionPage.vue | 50 +- .../views/extension/InstalledPluginsTab.vue | 73 ++- .../src/views/extension/useExtensionPage.js | 447 ++++++++++++++---- openspec/openapi-v1.yaml | 43 ++ tests/test_dashboard.py | 16 +- tests/test_fastapi_v1_dashboard.py | 382 +++++++++++++-- tests/test_plugin_manager.py | 67 ++- tests/test_updator_socks.py | 186 +++++++- 21 files changed, 1668 insertions(+), 261 deletions(-) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index b455214a5..53ee5045e 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -52,7 +52,7 @@ from .error_messages import format_plugin_error from .filter.permission import PermissionType, PermissionTypeFilter from .star import star_map, star_registry from .star_handler import EventType, star_handlers_registry -from .updator import PluginUpdator +from .updator import PLUGIN_METADATA_FILENAMES, PluginUpdator try: from watchfiles import PythonFilter, awatch @@ -465,38 +465,44 @@ class PluginManager: @staticmethod def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | None: - """先寻找 metadata.yaml 文件,如果不存在,则使用插件对象的 info() 函数获取元数据。 + """Load plugin metadata from metadata.yaml or metadata.yml. - Notes: 旧版本 AstrBot 插件可能使用的是 info() 函数来获取元数据。 + Args: + plugin_path: Plugin directory path. + plugin_obj: Deprecated compatibility argument; ignored. + + Returns: + Loaded plugin metadata, or None if no metadata file exists. """ + del plugin_obj metadata = None + metadata_label = "metadata.yaml" + plugin_root = Path(plugin_path) - if not os.path.exists(plugin_path): + if not plugin_root.exists(): raise Exception("插件不存在。") - if os.path.exists(os.path.join(plugin_path, "metadata.yaml")): - with open( - os.path.join(plugin_path, "metadata.yaml"), - encoding="utf-8", - ) as f: + metadata_path = next( + ( + plugin_root / filename + for filename in PLUGIN_METADATA_FILENAMES + if (plugin_root / filename).exists() + ), + None, + ) + if metadata_path: + metadata_label = metadata_path.name + with metadata_path.open(encoding="utf-8") as f: metadata = yaml.safe_load(f) - elif plugin_obj and hasattr(plugin_obj, "info"): - # 使用 info() 函数 - metadata = plugin_obj.info() if isinstance(metadata, dict): if "desc" not in metadata and "description" in metadata: metadata["desc"] = metadata["description"] - if ( - "name" not in metadata - or "desc" not in metadata - or "version" not in metadata - or "author" not in metadata - ): - raise Exception( - "插件元数据信息不完整。name, desc, version, author 是必须的字段。", - ) + try: + PluginUpdator.validate_plugin_metadata(metadata, metadata_label) + except ValueError as exc: + raise Exception(f"插件元数据校验失败:{exc!s}") from exc metadata = StarMetadata( name=metadata["name"], author=metadata["author"], @@ -577,32 +583,42 @@ class PluginManager: def _validate_importable_name(plugin_name: str) -> None: if "/" in plugin_name or "\\" in plugin_name: raise ValueError( - "metadata.yaml 中 name 含有路径分隔符,不可用于 importlib 加载。" + "metadata 文件中 name 含有路径分隔符,不可用于 importlib 加载。" ) if not plugin_name.isidentifier() or keyword.iskeyword(plugin_name): raise Exception( - "metadata.yaml 中 name 不是合法的模块名称(应为合法 Python 标识符且非关键字)。" + "metadata 文件中 name 不是合法的模块名称(应为合法 Python 标识符且非关键字)。" ) @staticmethod def _get_plugin_dir_name_from_metadata(plugin_path: str) -> str: - metadata_path = os.path.join(plugin_path, "metadata.yaml") - if not os.path.exists(metadata_path): - raise Exception("未找到 metadata.yaml,无法获取插件目录名。") + plugin_root = Path(plugin_path) + metadata_path = next( + ( + plugin_root / filename + for filename in PLUGIN_METADATA_FILENAMES + if (plugin_root / filename).exists() + ), + None, + ) + if metadata_path is None: + raise Exception( + "未找到 metadata.yaml 或 metadata.yml,无法获取插件目录名。" + ) - with open(metadata_path, encoding="utf-8") as f: + with metadata_path.open(encoding="utf-8") as f: metadata = yaml.safe_load(f) if not isinstance(metadata, dict): - raise Exception("metadata.yaml 格式错误。") + raise Exception(f"{metadata_path.name} 格式错误。") plugin_name = metadata.get("name") if not isinstance(plugin_name, str) or not plugin_name.strip(): - raise Exception("metadata.yaml 中缺少 name 字段。") + raise Exception(f"{metadata_path.name} 中缺少 name 字段。") plugin_dir_name = PluginManager._normalize_plugin_dir_name(plugin_name) if not plugin_dir_name: - raise Exception("metadata.yaml 中 name 字段内容非法。") + raise Exception(f"{metadata_path.name} 中 name 字段内容非法。") PluginManager._validate_importable_name(plugin_dir_name) return plugin_dir_name diff --git a/astrbot/core/star/updator.py b/astrbot/core/star/updator.py index 9eeda1cb8..653971241 100644 --- a/astrbot/core/star/updator.py +++ b/astrbot/core/star/updator.py @@ -1,6 +1,8 @@ import os import zipfile +import yaml + from astrbot.core import logger from astrbot.core.utils.astrbot_path import get_astrbot_plugin_path from astrbot.core.utils.io import ensure_dir, remove_dir @@ -8,6 +10,9 @@ from astrbot.core.utils.io import ensure_dir, remove_dir from ..star.star import StarMetadata from ..updator import RepoZipUpdator +PLUGIN_METADATA_FILENAMES = ("metadata.yaml", "metadata.yml") +PLUGIN_METADATA_REQUIRED_FIELDS = ("name", "desc", "version", "author") + class PluginUpdator(RepoZipUpdator): def __init__(self, repo_mirror: str = "", verify: str | bool | None = None) -> None: @@ -58,6 +63,7 @@ class PluginUpdator(RepoZipUpdator): elif repo_url: await self.download_from_repo_url(plugin_path, repo_url, proxy=proxy) + self.validate_plugin_archive(plugin_path + ".zip") try: remove_dir(plugin_path) except BaseException as e: @@ -69,7 +75,135 @@ class PluginUpdator(RepoZipUpdator): return plugin_path + @classmethod + def find_plugin_metadata_entry(cls, entries: list[str]) -> str | None: + """Find AstrBot plugin metadata in archive entries. + + Args: + entries: Zip archive member names. + + Returns: + The original archive entry name for plugin metadata, or None. + """ + update_dir = cls._resolve_archive_root_dir(entries) + portable_update_dir = os.path.normpath(update_dir).replace("\\", "/") + if portable_update_dir == ".": + portable_update_dir = "" + + entries_by_portable_path = {} + for entry in entries: + portable_entry = os.path.normpath(entry).replace("\\", "/") + if portable_entry in ("", "."): + continue + entries_by_portable_path[portable_entry] = entry + + metadata_candidates = ( + [ + f"{portable_update_dir}/{filename}" + for filename in PLUGIN_METADATA_FILENAMES + ] + if portable_update_dir + else list(PLUGIN_METADATA_FILENAMES) + ) + for candidate in metadata_candidates: + if candidate in entries_by_portable_path: + return entries_by_portable_path[candidate] + return None + + @staticmethod + def validate_plugin_metadata(metadata: object, metadata_label: str) -> None: + """Validate AstrBot plugin metadata content. + + Args: + metadata: Parsed metadata YAML content. + metadata_label: Metadata filename or archive entry for error messages. + + Raises: + ValueError: If metadata is malformed or misses required fields. + """ + if not isinstance(metadata, dict): + raise ValueError(f"{metadata_label} 格式错误。") + + normalized_metadata = dict(metadata) + if "desc" not in normalized_metadata and "description" in normalized_metadata: + normalized_metadata["desc"] = normalized_metadata["description"] + + missing_fields = [ + field + for field in PLUGIN_METADATA_REQUIRED_FIELDS + if field not in normalized_metadata + ] + if missing_fields: + raise ValueError( + f"{metadata_label} 中缺少必需字段: {', '.join(missing_fields)}。" + ) + + invalid_fields = [ + field + for field in PLUGIN_METADATA_REQUIRED_FIELDS + if not isinstance(normalized_metadata[field], str) + or not normalized_metadata[field].strip() + ] + if invalid_fields: + raise ValueError( + f"{metadata_label} 中字段 {', '.join(invalid_fields)} 必须是非空字符串。" + ) + + @classmethod + def inspect_plugin_archive(cls, zip_path: str) -> dict[str, object]: + """Inspect plugin metadata in an AstrBot plugin archive. + + Args: + zip_path: Path to the plugin archive. + + Returns: + A dict containing the metadata entry and parsed metadata. + + Raises: + ValueError: If the archive is not a valid AstrBot plugin. + """ + try: + with zipfile.ZipFile(zip_path, "r") as z: + metadata_entry = cls.find_plugin_metadata_entry(z.namelist()) + if metadata_entry is None: + raise ValueError( + "压缩包不是合法的 AstrBot 插件:未找到 metadata.yaml 或 metadata.yml。" + ) + + try: + metadata_text = z.read(metadata_entry).decode("utf-8") + metadata = yaml.safe_load(metadata_text) + except UnicodeDecodeError as exc: + raise ValueError(f"{metadata_entry} 必须使用 UTF-8 编码。") from exc + except yaml.YAMLError as exc: + raise ValueError(f"{metadata_entry} 格式错误。") from exc + + cls.validate_plugin_metadata(metadata, metadata_entry) + return { + "metadata_entry": metadata_entry, + "metadata": metadata, + } + except zipfile.BadZipFile as exc: + raise ValueError("插件压缩包格式错误。") from exc + + @classmethod + def validate_plugin_archive(cls, zip_path: str) -> str: + """Validate that an archive contains a valid AstrBot plugin. + + Args: + zip_path: Path to the plugin archive. + + Returns: + The archive entry name of the plugin metadata file. + + Raises: + ValueError: If the archive is not a valid AstrBot plugin. + """ + inspection = cls.inspect_plugin_archive(zip_path) + return str(inspection["metadata_entry"]) + def unzip_file(self, zip_path: str, target_dir: str) -> None: + self.validate_plugin_archive(zip_path) ensure_dir(target_dir) logger.info(f"Extracting archive: {zip_path}") with zipfile.ZipFile(zip_path, "r") as z: diff --git a/astrbot/core/zip_updator.py b/astrbot/core/zip_updator.py index 83c7da8e4..44335a56a 100644 --- a/astrbot/core/zip_updator.py +++ b/astrbot/core/zip_updator.py @@ -54,6 +54,55 @@ class RepoZipUpdator: return body return body[:max_len] + "...[truncated]" + async def fetch_github_default_branch(self, author: str, repo: str) -> str | None: + """Fetch the default branch for a GitHub repository. + + Args: + author: GitHub repository owner. + repo: GitHub repository name. + + Returns: + The default branch name, or None if it cannot be resolved. + """ + url = f"https://api.github.com/repos/{author}/{repo}" + try: + async with self._create_httpx_client(timeout=10.0) as client: + response = await client.get(url) + response.raise_for_status() + repo_info = response.json() + except Exception as exc: + logger.debug("获取 GitHub 默认分支失败 %s/%s: %s", author, repo, exc) + return None + + default_branch = str(repo_info.get("default_branch") or "").strip() + return default_branch or None + + async def resolve_github_source_branch( + self, + repo_url: str, + ) -> tuple[str, str, str]: + """Resolve the GitHub branch used for repository source downloads. + + Args: + repo_url: GitHub repository URL, optionally with a tree branch. + + Returns: + Repository owner, name, and resolved source branch. + + Raises: + ValueError: If the repository URL is invalid. + """ + author, repo, branch = self.parse_github_url(repo_url) + if branch: + return author, repo, branch + + default_branch = await self.fetch_github_default_branch(author, repo) + if default_branch: + return author, repo, default_branch + + logger.info("未能获取 %s/%s 的默认分支,将尝试 main 分支", author, repo) + return author, repo, "main" + async def _download_file( self, url: str, @@ -225,32 +274,13 @@ class RepoZipUpdator: async def download_from_repo_url( self, target_path: str, repo_url: str, proxy="" ) -> None: - author, repo, branch = self.parse_github_url(repo_url) + author, repo, branch = await self.resolve_github_source_branch(repo_url) logger.info(f"正在下载更新 {repo} ...") - - if branch: - logger.info(f"正在从指定分支 {branch} 下载 {author}/{repo}") - release_url = ( - f"https://github.com/{author}/{repo}/archive/refs/heads/{branch}.zip" - ) - else: - try: - release_url = f"https://api.github.com/repos/{author}/{repo}/releases" - releases = await self.fetch_release_info(url=release_url) - except Exception as e: - logger.warning( - f"获取 {author}/{repo} 的 GitHub Releases 失败: {e},将尝试下载默认分支", - ) - releases = [] - if not releases: - # 如果没有最新版本,下载默认分支 - logger.info(f"正在从默认分支下载 {author}/{repo}") - release_url = ( - f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip" - ) - else: - release_url = releases[0]["zipball_url"] + logger.info(f"正在从分支 {branch} 下载 {author}/{repo}") + release_url = ( + f"https://github.com/{author}/{repo}/archive/refs/heads/{branch}.zip" + ) if proxy: proxy = proxy.rstrip("/") diff --git a/astrbot/dashboard/api/plugins.py b/astrbot/dashboard/api/plugins.py index f47292008..9291bd119 100644 --- a/astrbot/dashboard/api/plugins.py +++ b/astrbot/dashboard/api/plugins.py @@ -28,6 +28,7 @@ from astrbot.dashboard.schemas import ( PluginSourceRequest, PluginUninstallRequest, PluginUpdateRequest, + PluginValidateRepoRequest, PluginVersionSupportRequest, ) from astrbot.dashboard.services.config_service import ( @@ -485,6 +486,18 @@ async def check_plugin_version_support( return await _check_plugin_version_support_payload(_model_dict(payload), service) +@router.post("/plugins/validate/repo") +async def validate_plugin_repo( + payload: PluginValidateRepoRequest, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return await _run_service( + service.validate_plugin_repo(_model_dict(payload)), + log_label="/api/plugin/validate-repo", + ) + + @router.post("/plugins/install/github") async def install_plugin_from_github( payload: PluginInstallRequest, diff --git a/astrbot/dashboard/schemas.py b/astrbot/dashboard/schemas.py index 50a7f20a1..f37449c53 100644 --- a/astrbot/dashboard/schemas.py +++ b/astrbot/dashboard/schemas.py @@ -504,7 +504,14 @@ class PluginInstallRequest(OpenModel): ignore_version_check: bool | None = None +class PluginValidateRepoRequest(OpenModel): + repository: str | None = None + url: str | None = None + proxy: str | None = None + + class PluginSourceBindRequest(OpenModel): + install_method: str | None = None registry_url: str | None = None market_plugin_id: str | None = None diff --git a/astrbot/dashboard/services/plugin_service.py b/astrbot/dashboard/services/plugin_service.py index 854cffd1f..7994d0b36 100644 --- a/astrbot/dashboard/services/plugin_service.py +++ b/astrbot/dashboard/services/plugin_service.py @@ -14,6 +14,7 @@ from urllib.parse import urlparse import aiohttp import certifi +import yaml from astrbot.api import sp from astrbot.core import DEMO_MODE, file_token_service, logger @@ -29,6 +30,7 @@ from astrbot.core.star.star_manager import ( PluginManager, PluginVersionUnsupportedError, ) +from astrbot.core.star.updator import PLUGIN_METADATA_FILENAMES from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path PLUGIN_UPDATE_CONCURRENCY = 3 @@ -37,6 +39,9 @@ PLUGIN_UPDATE_FAILED_MESSAGE = "更新失败,请查看服务端日志。" PLUGIN_INSTALL_SOURCES_KEY = "plugin_install_sources" PLUGIN_DEFAULT_REGISTRY_NAME = "Default" PLUGIN_UPDATE_DISABLED_MESSAGE = "该插件不是通过插件市场安装,无法检测或执行更新。" +PLUGIN_UPDATE_SOURCE_REQUIRED_MESSAGE = "请先选择插件安装源后再更新。" +PLUGIN_REPO_VALIDATE_TIMEOUT_SECONDS = 15 +PLUGIN_METADATA_MAX_BYTES = 1024 * 1024 PLUGIN_COMPONENT_TYPE_ORDER = { "page": 0, "skill": 1, @@ -392,14 +397,14 @@ class PluginService: plugin: StarMetadata, records: dict[str, dict[str, Any]], ) -> dict[str, Any] | None: - """Resolve a plugin source, defaulting legacy missing records to official market. + """Resolve a plugin source for display. Args: plugin: Loaded plugin metadata. records: Persisted source records. Returns: - The persisted source record, an implicit default market record, or None. + The persisted source record, an implicit display-only source record, or None. """ record = self.resolve_plugin_install_source(plugin, records) if isinstance(record, dict): @@ -416,6 +421,7 @@ class PluginService: "registry_name": PLUGIN_DEFAULT_REGISTRY_NAME, "repo": str(plugin.repo or "").strip(), "download_url": "", + "implicit": True, } if plugin_name: implicit_record["name"] = plugin_name @@ -620,11 +626,12 @@ class PluginService: installed_at: str | None, install_source: dict[str, Any] | None, ) -> dict: - updates_enabled = ( - isinstance(install_source, dict) - and install_source.get("install_method") == "market" - and not plugin.reserved + install_method = ( + str(install_source.get("install_method") or "").strip().lower() + if isinstance(install_source, dict) + else "" ) + updates_enabled = install_method in {"market", "github"} and not plugin.reserved return { "name": plugin.name, "marketplace_name": (plugin.name or "").replace("_", "-"), @@ -1307,8 +1314,29 @@ class PluginService: if not plugin: raise PluginServiceError("插件不存在") records = await self.get_plugin_install_sources() - record = self.resolve_effective_plugin_install_source(plugin, records) - if not isinstance(record, dict) or record.get("install_method") != "market": + record = self.resolve_plugin_install_source(plugin, records) + if not isinstance(record, dict) or record.get("implicit"): + raise PluginServiceError( + PLUGIN_UPDATE_SOURCE_REQUIRED_MESSAGE, + public_message=PLUGIN_UPDATE_SOURCE_REQUIRED_MESSAGE, + ) + + install_method = str(record.get("install_method") or "").strip().lower() + if install_method == "github": + repo_url = str(record.get("repo") or plugin.repo or "").strip() + if not repo_url: + raise PluginServiceError( + PLUGIN_UPDATE_DISABLED_MESSAGE, + public_message=PLUGIN_UPDATE_DISABLED_MESSAGE, + ) + return { + "record": record, + "market_plugin": None, + "download_url": "", + "repo": repo_url, + } + + if install_method != "market": raise PluginServiceError( PLUGIN_UPDATE_DISABLED_MESSAGE, public_message=PLUGIN_UPDATE_DISABLED_MESSAGE, @@ -1364,6 +1392,45 @@ class PluginService: public_message="当前插件缺少仓库地址,无法更换插件源。", ) + install_method = str(payload.get("install_method") or "market").strip().lower() + if install_method in {"github", "repo"}: + records = await self.get_plugin_install_sources() + old_record = self.resolve_plugin_install_source(plugin, records) + installed_at = ( + old_record.get("installed_at") + if isinstance(old_record, dict) and old_record.get("installed_at") + else self.get_plugin_installed_at(plugin) + or datetime.now(timezone.utc).isoformat() + ) + record = { + "schema_version": 1, + "root_dir_name": plugin.root_dir_name, + "install_method": "github", + "registry_url": None, + "registry_name": "Repository", + "repo": plugin_repo, + "download_url": "", + "installed_at": installed_at, + "updated_at": datetime.now(timezone.utc).isoformat(), + } + plugin_name_value = str(plugin.name or "").strip() + if plugin_name_value: + record["name"] = plugin_name_value + record["marketplace_name"] = plugin_name_value.replace("_", "-") + + for key in (plugin.root_dir_name, plugin.name): + if key: + records.pop(key, None) + records[plugin.root_dir_name] = record + await self.save_plugin_install_sources(records) + return record, "插件源已更新。" + + if install_method != "market": + raise PluginServiceError( + "不支持的插件源类型。", + public_message="不支持的插件源类型。", + ) + market_plugin_id = self.get_market_plugin_id(payload) if not market_plugin_id: raise PluginServiceError( @@ -1534,6 +1601,130 @@ class PluginService: public_message="当前 AstrBot 版本不满足插件要求", ) from exc + async def validate_plugin_repo(self, data: object) -> tuple[dict[str, Any], str]: + """Validate whether a GitHub repository contains AstrBot plugin metadata. + + Args: + data: Dashboard request payload containing repository or url. + + Returns: + Plugin metadata fetched from the GitHub repository and a success message. + + Raises: + PluginServiceError: If the repository is not a valid AstrBot plugin. + """ + payload = self._payload(data) + repo_url = str(payload.get("url") or payload.get("repository") or "").strip() + if not repo_url: + raise PluginServiceError("缺少插件仓库地址") + if not repo_url.startswith(("http://", "https://")): + repo_url = f"https://github.com/{repo_url}" + + proxy = str(payload.get("proxy") or "").strip().removesuffix("/") + try: + ( + author, + repo, + branch, + ) = await self.plugin_manager.updator.resolve_github_source_branch(repo_url) + except ValueError as exc: + raise PluginServiceError( + "请输入有效的 GitHub 仓库地址。", + public_message="请输入有效的 GitHub 仓库地址。", + ) from exc + + ssl_context = ssl.create_default_context(cafile=certifi.where()) + connector = aiohttp.TCPConnector(ssl=ssl_context) + try: + async with aiohttp.ClientSession( + trust_env=True, + connector=connector, + timeout=aiohttp.ClientTimeout( + total=PLUGIN_REPO_VALIDATE_TIMEOUT_SECONDS + ), + ) as session: + for filename in PLUGIN_METADATA_FILENAMES: + raw_url = ( + f"https://raw.githubusercontent.com/" + f"{author}/{repo}/{branch}/{filename}" + ) + request_url = f"{proxy}/{raw_url}" if proxy else raw_url + async with session.get(request_url) as response: + if response.status != 200: + continue + + content_length = response.headers.get("Content-Length") + if content_length: + try: + if int(content_length) > PLUGIN_METADATA_MAX_BYTES: + raise PluginServiceError( + f"{filename} 超过 1MB。", + public_message=f"{filename} 超过 1MB。", + ) + except ValueError: + pass + + metadata_bytes = await response.content.read( + PLUGIN_METADATA_MAX_BYTES + 1 + ) + if len(metadata_bytes) > PLUGIN_METADATA_MAX_BYTES: + raise PluginServiceError( + f"{filename} 超过 1MB。", + public_message=f"{filename} 超过 1MB。", + ) + try: + metadata_text = metadata_bytes.decode("utf-8") + except UnicodeDecodeError as exc: + raise PluginServiceError( + f"{filename} 必须使用 UTF-8 编码。", + public_message=f"{filename} 必须使用 UTF-8 编码。", + ) from exc + try: + metadata = yaml.safe_load(metadata_text) + except yaml.YAMLError as exc: + raise PluginServiceError( + f"{filename} 格式错误。", + public_message=f"{filename} 格式错误。", + ) from exc + try: + self.plugin_manager.updator.validate_plugin_metadata( + metadata, + filename, + ) + except ValueError as exc: + raise PluginServiceError( + str(exc), + public_message=f"插件校验失败:{exc!s}", + ) from exc + + metadata = dict(metadata) if isinstance(metadata, dict) else {} + if "desc" not in metadata and "description" in metadata: + metadata["desc"] = metadata["description"] + return { + "valid": True, + "metadata_entry": filename, + "metadata_branch": branch, + "name": str(metadata.get("name") or ""), + "display_name": metadata.get("display_name"), + "desc": str(metadata.get("desc") or ""), + "version": str(metadata.get("version") or ""), + "author": metadata.get("author"), + "repo": str(metadata.get("repo") or repo_url), + }, "插件校验通过。" + + raise PluginServiceError( + "未在 GitHub 仓库根目录找到 metadata.yaml 或 metadata.yml。", + public_message="未在 GitHub 仓库根目录找到 metadata.yaml 或 metadata.yml。", + ) + except Exception as exc: + if isinstance(exc, PluginServiceError): + raise + logger.warning("插件仓库校验失败 %s: %s", repo_url, exc) + raise PluginServiceError( + "插件校验失败", + public_message="插件校验失败,请查看服务端日志。", + ) from exc + async def install_plugin_upload( self, *, @@ -1893,7 +2084,9 @@ class PluginService: __all__ = [ "PLUGIN_UPDATE_CONCURRENCY", "PLUGIN_OPERATION_FAILED_MESSAGE", + "PLUGIN_UPDATE_DISABLED_MESSAGE", "PLUGIN_UPDATE_FAILED_MESSAGE", + "PLUGIN_UPDATE_SOURCE_REQUIRED_MESSAGE", "PluginService", "PluginServiceError", "PluginServiceWarning", diff --git a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts index 1030a5415..a75ccf577 100644 --- a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import { createClient, createConfig, type OptionsLegacyParser, formDataBodySerializer } from '@hey-api/client-axios'; -import type { LoginData, LoginError, LoginResponse, LogoutError, LogoutResponse, GetAuthSetupStatusError, GetAuthSetupStatusResponse, SetupAuthData, SetupAuthError, SetupAuthResponse, SetupTotpData, SetupTotpError, SetupTotpResponse, RecoverTotpError, RecoverTotpResponse, UpdateAuthAccountData, UpdateAuthAccountError, UpdateAuthAccountResponse, ListApiKeysError, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyError, CreateApiKeyResponse, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyResponse, DeleteApiKeyData, DeleteApiKeyError, DeleteApiKeyResponse, GetSystemConfigSchemaError, GetSystemConfigSchemaResponse, GetSystemConfigError, GetSystemConfigResponse, UpdateSystemConfigData, UpdateSystemConfigError, UpdateSystemConfigResponse, GetSystemConfigRuntimeError, GetSystemConfigRuntimeResponse, GetConfigProfileSchemaError, GetConfigProfileSchemaResponse, ListConfigProfilesError, ListConfigProfilesResponse, CreateConfigProfileData, CreateConfigProfileError, CreateConfigProfileResponse, GetConfigProfileData, GetConfigProfileError, GetConfigProfileResponse, UpdateConfigProfileContentData, UpdateConfigProfileContentError, UpdateConfigProfileContentResponse, RenameConfigProfileData, RenameConfigProfileError, RenameConfigProfileResponse, DeleteConfigProfileData, DeleteConfigProfileError, DeleteConfigProfileResponse, ListConfigRoutesError, ListConfigRoutesResponse, ReplaceConfigRoutesData, ReplaceConfigRoutesError, ReplaceConfigRoutesResponse, UpsertConfigRouteData, UpsertConfigRouteError, UpsertConfigRouteResponse, DeleteConfigRouteData, DeleteConfigRouteError, DeleteConfigRouteResponse, ListBotTypesError, ListBotTypesResponse, RegisterBotTypeData, RegisterBotTypeError, RegisterBotTypeResponse, ListBotsData, ListBotsError, ListBotsResponse, CreateBotData, CreateBotError, CreateBotResponse, ListBotStatsError, ListBotStatsResponse, GetBotByIdData, GetBotByIdError, GetBotByIdResponse, UpdateBotByIdData, UpdateBotByIdError, UpdateBotByIdResponse, DeleteBotByIdData, DeleteBotByIdError, DeleteBotByIdResponse, SetBotEnabledByIdData, SetBotEnabledByIdError, SetBotEnabledByIdResponse, TestBotByIdData, TestBotByIdError, TestBotByIdResponse, GetBotData, GetBotError, GetBotResponse, UpdateBotData, UpdateBotError, UpdateBotResponse, DeleteBotData, DeleteBotError, DeleteBotResponse, SetBotEnabledData, SetBotEnabledError, SetBotEnabledResponse, TestBotData, TestBotError, TestBotResponse, GetProviderSchemaError, GetProviderSchemaResponse, ListProviderSourcesError, ListProviderSourcesResponse, CreateProviderSourceData, CreateProviderSourceError, CreateProviderSourceResponse, GetProviderSourceByIdData, GetProviderSourceByIdError, GetProviderSourceByIdResponse, UpsertProviderSourceByIdData, UpsertProviderSourceByIdError, UpsertProviderSourceByIdResponse, DeleteProviderSourceByIdData, DeleteProviderSourceByIdError, DeleteProviderSourceByIdResponse, ListProviderSourceModelsByIdData, ListProviderSourceModelsByIdError, ListProviderSourceModelsByIdResponse, ListProvidersBySourceIdData, ListProvidersBySourceIdError, ListProvidersBySourceIdResponse, CreateProviderInSourceByIdData, CreateProviderInSourceByIdError, CreateProviderInSourceByIdResponse, GetProviderSourceData, GetProviderSourceError, GetProviderSourceResponse, UpsertProviderSourceData, UpsertProviderSourceError, UpsertProviderSourceResponse, DeleteProviderSourceData, DeleteProviderSourceError, DeleteProviderSourceResponse, ListProviderSourceModelsData, ListProviderSourceModelsError, ListProviderSourceModelsResponse, ListProvidersBySourceData, ListProvidersBySourceError, ListProvidersBySourceResponse, CreateProviderInSourceData, CreateProviderInSourceError, CreateProviderInSourceResponse, ListProvidersData, ListProvidersError, ListProvidersResponse, CreateProviderData, CreateProviderError, CreateProviderResponse, GetProviderByIdData, GetProviderByIdError, GetProviderByIdResponse, UpdateProviderByIdData, UpdateProviderByIdError, UpdateProviderByIdResponse, DeleteProviderByIdData, DeleteProviderByIdError, DeleteProviderByIdResponse, SetProviderEnabledByIdData, SetProviderEnabledByIdError, SetProviderEnabledByIdResponse, TestProviderByIdData, TestProviderByIdError, TestProviderByIdResponse, GetProviderEmbeddingDimensionByIdData, GetProviderEmbeddingDimensionByIdError, GetProviderEmbeddingDimensionByIdResponse, GetProviderData, GetProviderError, GetProviderResponse, UpdateProviderData, UpdateProviderError, UpdateProviderResponse, DeleteProviderData, DeleteProviderError, DeleteProviderResponse, SetProviderEnabledData, SetProviderEnabledError, SetProviderEnabledResponse, TestProviderData, TestProviderError, TestProviderResponse, GetProviderEmbeddingDimensionData, GetProviderEmbeddingDimensionError, GetProviderEmbeddingDimensionResponse, SendChatMessageData, SendChatMessageError, SendChatMessageResponse, OpenChatWebSocketData, OpenLiveChatWebSocketData, OpenUnifiedChatWebSocketData, ListChatSessionsData, ListChatSessionsError, ListChatSessionsResponse, CreateChatSessionData, CreateChatSessionError, CreateChatSessionResponse, BatchDeleteChatSessionsData, BatchDeleteChatSessionsError, BatchDeleteChatSessionsResponse, GetChatSessionData, GetChatSessionError, GetChatSessionResponse, UpdateChatSessionData, UpdateChatSessionError, UpdateChatSessionResponse, DeleteChatSessionData, DeleteChatSessionError, DeleteChatSessionResponse, StopChatSessionData, StopChatSessionError, StopChatSessionResponse, UpdateChatMessageData, UpdateChatMessageError, UpdateChatMessageResponse, RegenerateChatMessageData, RegenerateChatMessageError, RegenerateChatMessageResponse, ListChatConfigsError, ListChatConfigsResponse, CreateChatThreadData, CreateChatThreadError, CreateChatThreadResponse, GetChatThreadData, GetChatThreadError, GetChatThreadResponse, DeleteChatThreadData, DeleteChatThreadError, DeleteChatThreadResponse, SendChatThreadMessageData, SendChatThreadMessageError, SendChatThreadMessageResponse, ListChatProjectsError, ListChatProjectsResponse, CreateChatProjectData, CreateChatProjectError, CreateChatProjectResponse, GetChatProjectData, GetChatProjectError, GetChatProjectResponse, UpdateChatProjectData, UpdateChatProjectError, UpdateChatProjectResponse, DeleteChatProjectData, DeleteChatProjectError, DeleteChatProjectResponse, ListChatProjectSessionsData, ListChatProjectSessionsError, ListChatProjectSessionsResponse, AddChatProjectSessionData, AddChatProjectSessionError, AddChatProjectSessionResponse, RemoveChatProjectSessionData, RemoveChatProjectSessionError, RemoveChatProjectSessionResponse, SendImMessageData, SendImMessageError, SendImMessageResponse, ListImBotsError, ListImBotsResponse, UploadFileData, UploadFileError, UploadFileResponse, UploadOpenApiFileData, UploadOpenApiFileError, UploadOpenApiFileResponse, DownloadOpenApiFileData, DownloadOpenApiFileError, DownloadOpenApiFileResponse, GetFileByNameData, GetFileByNameError, GetFileByNameResponse, GetTokenFileData, GetTokenFileError, GetTokenFileResponse, GetAttachmentData, GetAttachmentError, GetAttachmentResponse, DeleteAttachmentData, DeleteAttachmentError, DeleteAttachmentResponse, DownloadAttachmentData, DownloadAttachmentError, DownloadAttachmentResponse, ListPluginsData, ListPluginsError, ListPluginsResponse, GetPluginByIdData, GetPluginByIdError, GetPluginByIdResponse, UninstallPluginByIdData, UninstallPluginByIdError, UninstallPluginByIdResponse, GetPluginConfigByIdData, GetPluginConfigByIdError, GetPluginConfigByIdResponse, UpdatePluginConfigByIdData, UpdatePluginConfigByIdError, UpdatePluginConfigByIdResponse, GetPluginConfigSchemaByIdData, GetPluginConfigSchemaByIdError, GetPluginConfigSchemaByIdResponse, ListPluginConfigFilesByIdData, ListPluginConfigFilesByIdError, ListPluginConfigFilesByIdResponse, UploadPluginConfigFilesByIdData, UploadPluginConfigFilesByIdError, UploadPluginConfigFilesByIdResponse, DeletePluginConfigFileByIdData, DeletePluginConfigFileByIdError, DeletePluginConfigFileByIdResponse, GetPluginReadmeByIdData, GetPluginReadmeByIdError, GetPluginReadmeByIdResponse, GetPluginChangelogByIdData, GetPluginChangelogByIdError, GetPluginChangelogByIdResponse, ReloadPluginByIdData, ReloadPluginByIdError, ReloadPluginByIdResponse, SetPluginEnabledByIdData, SetPluginEnabledByIdError, SetPluginEnabledByIdResponse, ListPluginPagesByIdData, ListPluginPagesByIdError, ListPluginPagesByIdResponse, GetPluginPageByIdData, GetPluginPageByIdError, GetPluginPageByIdResponse, GetPluginPageAssetByIdData, GetPluginPageAssetByIdError, GetPluginPageAssetByIdResponse, GetPluginData, GetPluginError, GetPluginResponse, UninstallPluginData, UninstallPluginError, UninstallPluginResponse, GetPluginConfigData, GetPluginConfigError, GetPluginConfigResponse, UpdatePluginConfigData, UpdatePluginConfigError, UpdatePluginConfigResponse, GetPluginConfigSchemaData, GetPluginConfigSchemaError, GetPluginConfigSchemaResponse, ListPluginConfigFilesData, ListPluginConfigFilesError, ListPluginConfigFilesResponse, UploadPluginConfigFilesData, UploadPluginConfigFilesError, UploadPluginConfigFilesResponse, DeletePluginConfigFileData, DeletePluginConfigFileError, DeletePluginConfigFileResponse, GetPluginReadmeData, GetPluginReadmeError, GetPluginReadmeResponse, GetPluginChangelogData, GetPluginChangelogError, GetPluginChangelogResponse, ReloadPluginData, ReloadPluginError, ReloadPluginResponse, BindPluginSourceData, BindPluginSourceError, BindPluginSourceResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ListFailedPluginsError, ListFailedPluginsResponse, UninstallFailedPluginData, UninstallFailedPluginError, UninstallFailedPluginResponse, ReloadFailedPluginData, ReloadFailedPluginError, ReloadFailedPluginResponse, InstallPluginFromGithubData, InstallPluginFromGithubError, InstallPluginFromGithubResponse, InstallPluginFromUrlData, InstallPluginFromUrlError, InstallPluginFromUrlResponse, InstallPluginFromUploadData, InstallPluginFromUploadError, InstallPluginFromUploadResponse, ListPluginMarketData, ListPluginMarketError, ListPluginMarketResponse, ListPluginMarketCategoriesError, ListPluginMarketCategoriesResponse, ListPluginSourcesError, ListPluginSourcesResponse, CreatePluginSourceData, CreatePluginSourceError, CreatePluginSourceResponse, ReplacePluginSourcesData, ReplacePluginSourcesError, ReplacePluginSourcesResponse, DeletePluginSourceData, DeletePluginSourceError, DeletePluginSourceResponse, DeletePluginSourceByIdData, DeletePluginSourceByIdError, DeletePluginSourceByIdResponse, ListPluginPagesData, ListPluginPagesError, ListPluginPagesResponse, GetPluginPageData, GetPluginPageError, GetPluginPageResponse, GetPluginPageAssetData, GetPluginPageAssetError, GetPluginPageAssetResponse, GetPluginPageBridgeSdkError, GetPluginPageBridgeSdkResponse, GetPluginExtensionRouteData, GetPluginExtensionRouteError, GetPluginExtensionRouteResponse, PostPluginExtensionRouteData, PostPluginExtensionRouteError, PostPluginExtensionRouteResponse, PutPluginExtensionRouteData, PutPluginExtensionRouteError, PutPluginExtensionRouteResponse, PatchPluginExtensionRouteData, PatchPluginExtensionRouteError, PatchPluginExtensionRouteResponse, DeletePluginExtensionRouteData, DeletePluginExtensionRouteError, DeletePluginExtensionRouteResponse, ListCommandsData, ListCommandsError, ListCommandsResponse, UpdateCommandData, UpdateCommandError, UpdateCommandResponse, ListCommandConflictsError, ListCommandConflictsResponse, ListToolsData, ListToolsError, ListToolsResponse, SetToolEnabledData, SetToolEnabledError, SetToolEnabledResponse, SetToolPermissionData, SetToolPermissionError, SetToolPermissionResponse, ListMcpServersError, ListMcpServersResponse, CreateMcpServerData, CreateMcpServerError, CreateMcpServerResponse, UpdateMcpServerByNameData, UpdateMcpServerByNameError, UpdateMcpServerByNameResponse, DeleteMcpServerByNameData, DeleteMcpServerByNameError, DeleteMcpServerByNameResponse, SetMcpServerEnabledByNameData, SetMcpServerEnabledByNameError, SetMcpServerEnabledByNameResponse, TestMcpServerByNameData, TestMcpServerByNameError, TestMcpServerByNameResponse, UpdateMcpServerData, UpdateMcpServerError, UpdateMcpServerResponse, DeleteMcpServerData, DeleteMcpServerError, DeleteMcpServerResponse, SetMcpServerEnabledData, SetMcpServerEnabledError, SetMcpServerEnabledResponse, TestMcpServerData, TestMcpServerError, TestMcpServerResponse, SyncModelScopeMcpServersData, SyncModelScopeMcpServersError, SyncModelScopeMcpServersResponse, ListSkillsData, ListSkillsError, ListSkillsResponse, UploadSkillData, UploadSkillError, UploadSkillResponse, UploadSkillsBatchData, UploadSkillsBatchError, UploadSkillsBatchResponse, UpdateSkillByNameData, UpdateSkillByNameError, UpdateSkillByNameResponse, DeleteSkillByNameData, DeleteSkillByNameError, DeleteSkillByNameResponse, DownloadSkillByNameData, DownloadSkillByNameError, DownloadSkillByNameResponse, ListSkillFilesByNameData, ListSkillFilesByNameError, ListSkillFilesByNameResponse, GetSkillFileByNameData, GetSkillFileByNameError, GetSkillFileByNameResponse, UpdateSkillFileByNameData, UpdateSkillFileByNameError, UpdateSkillFileByNameResponse, UpdateSkillData, UpdateSkillError, UpdateSkillResponse, DeleteSkillData, DeleteSkillError, DeleteSkillResponse, DownloadSkillData, DownloadSkillError, DownloadSkillResponse, ListSkillFilesData, ListSkillFilesError, ListSkillFilesResponse, GetSkillFileData, GetSkillFileError, GetSkillFileResponse, UpdateSkillFileData, UpdateSkillFileError, UpdateSkillFileResponse, ListNeoSkillCandidatesData, ListNeoSkillCandidatesError, ListNeoSkillCandidatesResponse, ListNeoSkillReleasesData, ListNeoSkillReleasesError, ListNeoSkillReleasesResponse, GetNeoSkillPayloadData, GetNeoSkillPayloadError, GetNeoSkillPayloadResponse, EvaluateNeoSkillCandidateData, EvaluateNeoSkillCandidateError, EvaluateNeoSkillCandidateResponse, PromoteNeoSkillCandidateData, PromoteNeoSkillCandidateError, PromoteNeoSkillCandidateResponse, RollbackNeoSkillReleaseData, RollbackNeoSkillReleaseError, RollbackNeoSkillReleaseResponse, SyncNeoSkillReleaseData, SyncNeoSkillReleaseError, SyncNeoSkillReleaseResponse, DeleteNeoSkillCandidateData, DeleteNeoSkillCandidateError, DeleteNeoSkillCandidateResponse, DeleteNeoSkillReleaseData, DeleteNeoSkillReleaseError, DeleteNeoSkillReleaseResponse, ListKnowledgeBasesData, ListKnowledgeBasesError, ListKnowledgeBasesResponse, CreateKnowledgeBaseData, CreateKnowledgeBaseError, CreateKnowledgeBaseResponse, GetKnowledgeBaseData, GetKnowledgeBaseError, GetKnowledgeBaseResponse, UpdateKnowledgeBaseData, UpdateKnowledgeBaseError, UpdateKnowledgeBaseResponse, DeleteKnowledgeBaseData, DeleteKnowledgeBaseError, DeleteKnowledgeBaseResponse, GetKnowledgeBaseStatsData, GetKnowledgeBaseStatsError, GetKnowledgeBaseStatsResponse, ListKnowledgeDocumentsData, ListKnowledgeDocumentsError, ListKnowledgeDocumentsResponse, UploadKnowledgeDocumentData, UploadKnowledgeDocumentError, UploadKnowledgeDocumentResponse, ImportKnowledgeDocumentsData, ImportKnowledgeDocumentsError, ImportKnowledgeDocumentsResponse, ImportKnowledgeDocumentFromUrlData, ImportKnowledgeDocumentFromUrlError, ImportKnowledgeDocumentFromUrlResponse, GetKnowledgeDocumentData, GetKnowledgeDocumentError, GetKnowledgeDocumentResponse, DeleteKnowledgeDocumentData, DeleteKnowledgeDocumentError, DeleteKnowledgeDocumentResponse, ListKnowledgeChunksData, ListKnowledgeChunksError, ListKnowledgeChunksResponse, DeleteKnowledgeChunkData, DeleteKnowledgeChunkError, DeleteKnowledgeChunkResponse, RetrieveKnowledgeBaseData, RetrieveKnowledgeBaseError, RetrieveKnowledgeBaseResponse, GetKnowledgeTaskData, GetKnowledgeTaskError, GetKnowledgeTaskResponse, GetPersonaTreeError, GetPersonaTreeResponse, ListPersonasData, ListPersonasError, ListPersonasResponse, CreatePersonaData, CreatePersonaError, CreatePersonaResponse, GetPersonaByIdData, GetPersonaByIdError, GetPersonaByIdResponse, UpdatePersonaByIdData, UpdatePersonaByIdError, UpdatePersonaByIdResponse, DeletePersonaByIdData, DeletePersonaByIdError, DeletePersonaByIdResponse, GetPersonaData, GetPersonaError, GetPersonaResponse, UpdatePersonaData, UpdatePersonaError, UpdatePersonaResponse, DeletePersonaData, DeletePersonaError, DeletePersonaResponse, ListPersonaFoldersData, ListPersonaFoldersError, ListPersonaFoldersResponse, CreatePersonaFolderData, CreatePersonaFolderError, CreatePersonaFolderResponse, UpdatePersonaFolderData, UpdatePersonaFolderError, UpdatePersonaFolderResponse, DeletePersonaFolderData, DeletePersonaFolderError, DeletePersonaFolderResponse, MovePersonaItemData, MovePersonaItemError, MovePersonaItemResponse, ReorderPersonaItemsData, ReorderPersonaItemsError, ReorderPersonaItemsResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, ListActiveUmosError, ListActiveUmosResponse, ListSessionRulesData, ListSessionRulesError, ListSessionRulesResponse, UpsertSessionRuleData, UpsertSessionRuleError, UpsertSessionRuleResponse, DeleteSessionRulesData, DeleteSessionRulesError, DeleteSessionRulesResponse, BatchUpdateSessionProviderData, BatchUpdateSessionProviderError, BatchUpdateSessionProviderResponse, BatchUpdateSessionServiceData, BatchUpdateSessionServiceError, BatchUpdateSessionServiceResponse, ListSessionGroupsError, ListSessionGroupsResponse, CreateSessionGroupData, CreateSessionGroupError, CreateSessionGroupResponse, UpdateSessionGroupData, UpdateSessionGroupError, UpdateSessionGroupResponse, DeleteSessionGroupData, DeleteSessionGroupError, DeleteSessionGroupResponse, ListConversationsData, ListConversationsError, ListConversationsResponse, BatchDeleteConversationsData, BatchDeleteConversationsError, BatchDeleteConversationsResponse, GetConversationData, GetConversationError, GetConversationResponse, UpdateConversationData, UpdateConversationError, UpdateConversationResponse, DeleteConversationData, DeleteConversationError, DeleteConversationResponse, ReplaceConversationMessagesData, ReplaceConversationMessagesError, ReplaceConversationMessagesResponse, ExportConversationsData, ExportConversationsError, ExportConversationsResponse, GetStatsData, GetStatsError, GetStatsResponse, GetProviderTokenStatsData, GetProviderTokenStatsError, GetProviderTokenStatsResponse, GetVersionError, GetVersionResponse, GetPublicVersionsError, GetPublicVersionsResponse, GetFirstNoticeData, GetFirstNoticeError, GetFirstNoticeResponse, TestGhproxyConnectionData, TestGhproxyConnectionError, TestGhproxyConnectionResponse, ListChangelogVersionsError, ListChangelogVersionsResponse, GetChangelogData, GetChangelogError, GetChangelogResponse, GetStartTimeError, GetStartTimeResponse, GetStorageStatusError, GetStorageStatusResponse, CleanupStorageData, CleanupStorageError, CleanupStorageResponse, RestartCoreError, RestartCoreResponse, ListBackupsData, ListBackupsError, ListBackupsResponse, CreateBackupData, CreateBackupError, CreateBackupResponse, UploadBackupData, UploadBackupError, UploadBackupResponse, InitBackupUploadData, InitBackupUploadError, InitBackupUploadResponse, UploadBackupChunkData, UploadBackupChunkError, UploadBackupChunkResponse, CompleteBackupUploadData, CompleteBackupUploadError, CompleteBackupUploadResponse, AbortBackupUploadData, AbortBackupUploadError, AbortBackupUploadResponse, GetBackupProgressData, GetBackupProgressError, GetBackupProgressResponse, DownloadBackupData, DownloadBackupError, DownloadBackupResponse, RenameBackupData, RenameBackupError, RenameBackupResponse, DeleteBackupData, DeleteBackupError, DeleteBackupResponse, CheckBackupData, CheckBackupError, CheckBackupResponse, ImportBackupData, ImportBackupError, ImportBackupResponse, CheckUpdateError, CheckUpdateResponse, ListReleasesData, ListReleasesError, ListReleasesResponse, UpdateCoreData, UpdateCoreError, UpdateCoreResponse, UpdateDashboardData, UpdateDashboardError, UpdateDashboardResponse, GetUpdateProgressData, GetUpdateProgressError, GetUpdateProgressResponse, InstallPipPackageData, InstallPipPackageError, InstallPipPackageResponse, ListCronJobsData, ListCronJobsError, ListCronJobsResponse, CreateCronJobData, CreateCronJobError, CreateCronJobResponse, UpdateCronJobData, UpdateCronJobError, UpdateCronJobResponse, DeleteCronJobData, DeleteCronJobError, DeleteCronJobResponse, RunCronJobData, RunCronJobError, RunCronJobResponse, StreamLiveLogsError, StreamLiveLogsResponse, GetLogHistoryError, GetLogHistoryResponse, GetTraceSettingsError, GetTraceSettingsResponse, UpdateTraceSettingsData, UpdateTraceSettingsError, UpdateTraceSettingsResponse, ListT2iTemplatesError, ListT2iTemplatesResponse, CreateT2iTemplateData, CreateT2iTemplateError, CreateT2iTemplateResponse, GetActiveT2iTemplateError, GetActiveT2iTemplateResponse, SetActiveT2iTemplateData, SetActiveT2iTemplateError, SetActiveT2iTemplateResponse, ResetDefaultT2iTemplateError, ResetDefaultT2iTemplateResponse, GetT2iTemplateData, GetT2iTemplateError, GetT2iTemplateResponse, UpdateT2iTemplateData, UpdateT2iTemplateError, UpdateT2iTemplateResponse, DeleteT2iTemplateData, DeleteT2iTemplateError, DeleteT2iTemplateResponse, GetSubagentConfigError, GetSubagentConfigResponse, UpdateSubagentConfigData, UpdateSubagentConfigError, UpdateSubagentConfigResponse, ListSubagentAvailableToolsError, ListSubagentAvailableToolsResponse, VerifyPlatformWebhookData, VerifyPlatformWebhookError, VerifyPlatformWebhookResponse, ReceivePlatformWebhookData, ReceivePlatformWebhookError, ReceivePlatformWebhookResponse } from './types.gen'; +import type { LoginData, LoginError, LoginResponse, LogoutError, LogoutResponse, GetAuthSetupStatusError, GetAuthSetupStatusResponse, SetupAuthData, SetupAuthError, SetupAuthResponse, SetupTotpData, SetupTotpError, SetupTotpResponse, RecoverTotpError, RecoverTotpResponse, UpdateAuthAccountData, UpdateAuthAccountError, UpdateAuthAccountResponse, ListApiKeysError, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyError, CreateApiKeyResponse, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyResponse, DeleteApiKeyData, DeleteApiKeyError, DeleteApiKeyResponse, GetSystemConfigSchemaError, GetSystemConfigSchemaResponse, GetSystemConfigError, GetSystemConfigResponse, UpdateSystemConfigData, UpdateSystemConfigError, UpdateSystemConfigResponse, GetSystemConfigRuntimeError, GetSystemConfigRuntimeResponse, GetConfigProfileSchemaError, GetConfigProfileSchemaResponse, ListConfigProfilesError, ListConfigProfilesResponse, CreateConfigProfileData, CreateConfigProfileError, CreateConfigProfileResponse, GetConfigProfileData, GetConfigProfileError, GetConfigProfileResponse, UpdateConfigProfileContentData, UpdateConfigProfileContentError, UpdateConfigProfileContentResponse, RenameConfigProfileData, RenameConfigProfileError, RenameConfigProfileResponse, DeleteConfigProfileData, DeleteConfigProfileError, DeleteConfigProfileResponse, ListConfigRoutesError, ListConfigRoutesResponse, ReplaceConfigRoutesData, ReplaceConfigRoutesError, ReplaceConfigRoutesResponse, UpsertConfigRouteData, UpsertConfigRouteError, UpsertConfigRouteResponse, DeleteConfigRouteData, DeleteConfigRouteError, DeleteConfigRouteResponse, ListBotTypesError, ListBotTypesResponse, RegisterBotTypeData, RegisterBotTypeError, RegisterBotTypeResponse, ListBotsData, ListBotsError, ListBotsResponse, CreateBotData, CreateBotError, CreateBotResponse, ListBotStatsError, ListBotStatsResponse, GetBotByIdData, GetBotByIdError, GetBotByIdResponse, UpdateBotByIdData, UpdateBotByIdError, UpdateBotByIdResponse, DeleteBotByIdData, DeleteBotByIdError, DeleteBotByIdResponse, SetBotEnabledByIdData, SetBotEnabledByIdError, SetBotEnabledByIdResponse, TestBotByIdData, TestBotByIdError, TestBotByIdResponse, GetBotData, GetBotError, GetBotResponse, UpdateBotData, UpdateBotError, UpdateBotResponse, DeleteBotData, DeleteBotError, DeleteBotResponse, SetBotEnabledData, SetBotEnabledError, SetBotEnabledResponse, TestBotData, TestBotError, TestBotResponse, GetProviderSchemaError, GetProviderSchemaResponse, ListProviderSourcesError, ListProviderSourcesResponse, CreateProviderSourceData, CreateProviderSourceError, CreateProviderSourceResponse, GetProviderSourceByIdData, GetProviderSourceByIdError, GetProviderSourceByIdResponse, UpsertProviderSourceByIdData, UpsertProviderSourceByIdError, UpsertProviderSourceByIdResponse, DeleteProviderSourceByIdData, DeleteProviderSourceByIdError, DeleteProviderSourceByIdResponse, ListProviderSourceModelsByIdData, ListProviderSourceModelsByIdError, ListProviderSourceModelsByIdResponse, ListProvidersBySourceIdData, ListProvidersBySourceIdError, ListProvidersBySourceIdResponse, CreateProviderInSourceByIdData, CreateProviderInSourceByIdError, CreateProviderInSourceByIdResponse, GetProviderSourceData, GetProviderSourceError, GetProviderSourceResponse, UpsertProviderSourceData, UpsertProviderSourceError, UpsertProviderSourceResponse, DeleteProviderSourceData, DeleteProviderSourceError, DeleteProviderSourceResponse, ListProviderSourceModelsData, ListProviderSourceModelsError, ListProviderSourceModelsResponse, ListProvidersBySourceData, ListProvidersBySourceError, ListProvidersBySourceResponse, CreateProviderInSourceData, CreateProviderInSourceError, CreateProviderInSourceResponse, ListProvidersData, ListProvidersError, ListProvidersResponse, CreateProviderData, CreateProviderError, CreateProviderResponse, GetProviderByIdData, GetProviderByIdError, GetProviderByIdResponse, UpdateProviderByIdData, UpdateProviderByIdError, UpdateProviderByIdResponse, DeleteProviderByIdData, DeleteProviderByIdError, DeleteProviderByIdResponse, SetProviderEnabledByIdData, SetProviderEnabledByIdError, SetProviderEnabledByIdResponse, TestProviderByIdData, TestProviderByIdError, TestProviderByIdResponse, GetProviderEmbeddingDimensionByIdData, GetProviderEmbeddingDimensionByIdError, GetProviderEmbeddingDimensionByIdResponse, GetProviderData, GetProviderError, GetProviderResponse, UpdateProviderData, UpdateProviderError, UpdateProviderResponse, DeleteProviderData, DeleteProviderError, DeleteProviderResponse, SetProviderEnabledData, SetProviderEnabledError, SetProviderEnabledResponse, TestProviderData, TestProviderError, TestProviderResponse, GetProviderEmbeddingDimensionData, GetProviderEmbeddingDimensionError, GetProviderEmbeddingDimensionResponse, SendChatMessageData, SendChatMessageError, SendChatMessageResponse, OpenChatWebSocketData, OpenLiveChatWebSocketData, OpenUnifiedChatWebSocketData, ListChatSessionsData, ListChatSessionsError, ListChatSessionsResponse, CreateChatSessionData, CreateChatSessionError, CreateChatSessionResponse, BatchDeleteChatSessionsData, BatchDeleteChatSessionsError, BatchDeleteChatSessionsResponse, GetChatSessionData, GetChatSessionError, GetChatSessionResponse, UpdateChatSessionData, UpdateChatSessionError, UpdateChatSessionResponse, DeleteChatSessionData, DeleteChatSessionError, DeleteChatSessionResponse, StopChatSessionData, StopChatSessionError, StopChatSessionResponse, UpdateChatMessageData, UpdateChatMessageError, UpdateChatMessageResponse, RegenerateChatMessageData, RegenerateChatMessageError, RegenerateChatMessageResponse, ListChatConfigsError, ListChatConfigsResponse, CreateChatThreadData, CreateChatThreadError, CreateChatThreadResponse, GetChatThreadData, GetChatThreadError, GetChatThreadResponse, DeleteChatThreadData, DeleteChatThreadError, DeleteChatThreadResponse, SendChatThreadMessageData, SendChatThreadMessageError, SendChatThreadMessageResponse, ListChatProjectsError, ListChatProjectsResponse, CreateChatProjectData, CreateChatProjectError, CreateChatProjectResponse, GetChatProjectData, GetChatProjectError, GetChatProjectResponse, UpdateChatProjectData, UpdateChatProjectError, UpdateChatProjectResponse, DeleteChatProjectData, DeleteChatProjectError, DeleteChatProjectResponse, ListChatProjectSessionsData, ListChatProjectSessionsError, ListChatProjectSessionsResponse, AddChatProjectSessionData, AddChatProjectSessionError, AddChatProjectSessionResponse, RemoveChatProjectSessionData, RemoveChatProjectSessionError, RemoveChatProjectSessionResponse, SendImMessageData, SendImMessageError, SendImMessageResponse, ListImBotsError, ListImBotsResponse, UploadFileData, UploadFileError, UploadFileResponse, UploadOpenApiFileData, UploadOpenApiFileError, UploadOpenApiFileResponse, DownloadOpenApiFileData, DownloadOpenApiFileError, DownloadOpenApiFileResponse, GetFileByNameData, GetFileByNameError, GetFileByNameResponse, GetTokenFileData, GetTokenFileError, GetTokenFileResponse, GetAttachmentData, GetAttachmentError, GetAttachmentResponse, DeleteAttachmentData, DeleteAttachmentError, DeleteAttachmentResponse, DownloadAttachmentData, DownloadAttachmentError, DownloadAttachmentResponse, ListPluginsData, ListPluginsError, ListPluginsResponse, GetPluginByIdData, GetPluginByIdError, GetPluginByIdResponse, UninstallPluginByIdData, UninstallPluginByIdError, UninstallPluginByIdResponse, GetPluginConfigByIdData, GetPluginConfigByIdError, GetPluginConfigByIdResponse, UpdatePluginConfigByIdData, UpdatePluginConfigByIdError, UpdatePluginConfigByIdResponse, GetPluginConfigSchemaByIdData, GetPluginConfigSchemaByIdError, GetPluginConfigSchemaByIdResponse, ListPluginConfigFilesByIdData, ListPluginConfigFilesByIdError, ListPluginConfigFilesByIdResponse, UploadPluginConfigFilesByIdData, UploadPluginConfigFilesByIdError, UploadPluginConfigFilesByIdResponse, DeletePluginConfigFileByIdData, DeletePluginConfigFileByIdError, DeletePluginConfigFileByIdResponse, GetPluginReadmeByIdData, GetPluginReadmeByIdError, GetPluginReadmeByIdResponse, GetPluginChangelogByIdData, GetPluginChangelogByIdError, GetPluginChangelogByIdResponse, ReloadPluginByIdData, ReloadPluginByIdError, ReloadPluginByIdResponse, SetPluginEnabledByIdData, SetPluginEnabledByIdError, SetPluginEnabledByIdResponse, ListPluginPagesByIdData, ListPluginPagesByIdError, ListPluginPagesByIdResponse, GetPluginPageByIdData, GetPluginPageByIdError, GetPluginPageByIdResponse, GetPluginPageAssetByIdData, GetPluginPageAssetByIdError, GetPluginPageAssetByIdResponse, GetPluginData, GetPluginError, GetPluginResponse, UninstallPluginData, UninstallPluginError, UninstallPluginResponse, GetPluginConfigData, GetPluginConfigError, GetPluginConfigResponse, UpdatePluginConfigData, UpdatePluginConfigError, UpdatePluginConfigResponse, GetPluginConfigSchemaData, GetPluginConfigSchemaError, GetPluginConfigSchemaResponse, ListPluginConfigFilesData, ListPluginConfigFilesError, ListPluginConfigFilesResponse, UploadPluginConfigFilesData, UploadPluginConfigFilesError, UploadPluginConfigFilesResponse, DeletePluginConfigFileData, DeletePluginConfigFileError, DeletePluginConfigFileResponse, GetPluginReadmeData, GetPluginReadmeError, GetPluginReadmeResponse, GetPluginChangelogData, GetPluginChangelogError, GetPluginChangelogResponse, ReloadPluginData, ReloadPluginError, ReloadPluginResponse, BindPluginSourceData, BindPluginSourceError, BindPluginSourceResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ValidatePluginRepoData, ValidatePluginRepoError, ValidatePluginRepoResponse, ListFailedPluginsError, ListFailedPluginsResponse, UninstallFailedPluginData, UninstallFailedPluginError, UninstallFailedPluginResponse, ReloadFailedPluginData, ReloadFailedPluginError, ReloadFailedPluginResponse, InstallPluginFromGithubData, InstallPluginFromGithubError, InstallPluginFromGithubResponse, InstallPluginFromUrlData, InstallPluginFromUrlError, InstallPluginFromUrlResponse, InstallPluginFromUploadData, InstallPluginFromUploadError, InstallPluginFromUploadResponse, ListPluginMarketData, ListPluginMarketError, ListPluginMarketResponse, ListPluginMarketCategoriesError, ListPluginMarketCategoriesResponse, ListPluginSourcesError, ListPluginSourcesResponse, CreatePluginSourceData, CreatePluginSourceError, CreatePluginSourceResponse, ReplacePluginSourcesData, ReplacePluginSourcesError, ReplacePluginSourcesResponse, DeletePluginSourceData, DeletePluginSourceError, DeletePluginSourceResponse, DeletePluginSourceByIdData, DeletePluginSourceByIdError, DeletePluginSourceByIdResponse, ListPluginPagesData, ListPluginPagesError, ListPluginPagesResponse, GetPluginPageData, GetPluginPageError, GetPluginPageResponse, GetPluginPageAssetData, GetPluginPageAssetError, GetPluginPageAssetResponse, GetPluginPageBridgeSdkError, GetPluginPageBridgeSdkResponse, GetPluginExtensionRouteData, GetPluginExtensionRouteError, GetPluginExtensionRouteResponse, PostPluginExtensionRouteData, PostPluginExtensionRouteError, PostPluginExtensionRouteResponse, PutPluginExtensionRouteData, PutPluginExtensionRouteError, PutPluginExtensionRouteResponse, PatchPluginExtensionRouteData, PatchPluginExtensionRouteError, PatchPluginExtensionRouteResponse, DeletePluginExtensionRouteData, DeletePluginExtensionRouteError, DeletePluginExtensionRouteResponse, ListCommandsData, ListCommandsError, ListCommandsResponse, UpdateCommandData, UpdateCommandError, UpdateCommandResponse, ListCommandConflictsError, ListCommandConflictsResponse, ListToolsData, ListToolsError, ListToolsResponse, SetToolEnabledData, SetToolEnabledError, SetToolEnabledResponse, SetToolPermissionData, SetToolPermissionError, SetToolPermissionResponse, ListMcpServersError, ListMcpServersResponse, CreateMcpServerData, CreateMcpServerError, CreateMcpServerResponse, UpdateMcpServerByNameData, UpdateMcpServerByNameError, UpdateMcpServerByNameResponse, DeleteMcpServerByNameData, DeleteMcpServerByNameError, DeleteMcpServerByNameResponse, SetMcpServerEnabledByNameData, SetMcpServerEnabledByNameError, SetMcpServerEnabledByNameResponse, TestMcpServerByNameData, TestMcpServerByNameError, TestMcpServerByNameResponse, UpdateMcpServerData, UpdateMcpServerError, UpdateMcpServerResponse, DeleteMcpServerData, DeleteMcpServerError, DeleteMcpServerResponse, SetMcpServerEnabledData, SetMcpServerEnabledError, SetMcpServerEnabledResponse, TestMcpServerData, TestMcpServerError, TestMcpServerResponse, SyncModelScopeMcpServersData, SyncModelScopeMcpServersError, SyncModelScopeMcpServersResponse, ListSkillsData, ListSkillsError, ListSkillsResponse, UploadSkillData, UploadSkillError, UploadSkillResponse, UploadSkillsBatchData, UploadSkillsBatchError, UploadSkillsBatchResponse, UpdateSkillByNameData, UpdateSkillByNameError, UpdateSkillByNameResponse, DeleteSkillByNameData, DeleteSkillByNameError, DeleteSkillByNameResponse, DownloadSkillByNameData, DownloadSkillByNameError, DownloadSkillByNameResponse, ListSkillFilesByNameData, ListSkillFilesByNameError, ListSkillFilesByNameResponse, GetSkillFileByNameData, GetSkillFileByNameError, GetSkillFileByNameResponse, UpdateSkillFileByNameData, UpdateSkillFileByNameError, UpdateSkillFileByNameResponse, UpdateSkillData, UpdateSkillError, UpdateSkillResponse, DeleteSkillData, DeleteSkillError, DeleteSkillResponse, DownloadSkillData, DownloadSkillError, DownloadSkillResponse, ListSkillFilesData, ListSkillFilesError, ListSkillFilesResponse, GetSkillFileData, GetSkillFileError, GetSkillFileResponse, UpdateSkillFileData, UpdateSkillFileError, UpdateSkillFileResponse, ListNeoSkillCandidatesData, ListNeoSkillCandidatesError, ListNeoSkillCandidatesResponse, ListNeoSkillReleasesData, ListNeoSkillReleasesError, ListNeoSkillReleasesResponse, GetNeoSkillPayloadData, GetNeoSkillPayloadError, GetNeoSkillPayloadResponse, EvaluateNeoSkillCandidateData, EvaluateNeoSkillCandidateError, EvaluateNeoSkillCandidateResponse, PromoteNeoSkillCandidateData, PromoteNeoSkillCandidateError, PromoteNeoSkillCandidateResponse, RollbackNeoSkillReleaseData, RollbackNeoSkillReleaseError, RollbackNeoSkillReleaseResponse, SyncNeoSkillReleaseData, SyncNeoSkillReleaseError, SyncNeoSkillReleaseResponse, DeleteNeoSkillCandidateData, DeleteNeoSkillCandidateError, DeleteNeoSkillCandidateResponse, DeleteNeoSkillReleaseData, DeleteNeoSkillReleaseError, DeleteNeoSkillReleaseResponse, ListKnowledgeBasesData, ListKnowledgeBasesError, ListKnowledgeBasesResponse, CreateKnowledgeBaseData, CreateKnowledgeBaseError, CreateKnowledgeBaseResponse, GetKnowledgeBaseData, GetKnowledgeBaseError, GetKnowledgeBaseResponse, UpdateKnowledgeBaseData, UpdateKnowledgeBaseError, UpdateKnowledgeBaseResponse, DeleteKnowledgeBaseData, DeleteKnowledgeBaseError, DeleteKnowledgeBaseResponse, GetKnowledgeBaseStatsData, GetKnowledgeBaseStatsError, GetKnowledgeBaseStatsResponse, ListKnowledgeDocumentsData, ListKnowledgeDocumentsError, ListKnowledgeDocumentsResponse, UploadKnowledgeDocumentData, UploadKnowledgeDocumentError, UploadKnowledgeDocumentResponse, ImportKnowledgeDocumentsData, ImportKnowledgeDocumentsError, ImportKnowledgeDocumentsResponse, ImportKnowledgeDocumentFromUrlData, ImportKnowledgeDocumentFromUrlError, ImportKnowledgeDocumentFromUrlResponse, GetKnowledgeDocumentData, GetKnowledgeDocumentError, GetKnowledgeDocumentResponse, DeleteKnowledgeDocumentData, DeleteKnowledgeDocumentError, DeleteKnowledgeDocumentResponse, ListKnowledgeChunksData, ListKnowledgeChunksError, ListKnowledgeChunksResponse, DeleteKnowledgeChunkData, DeleteKnowledgeChunkError, DeleteKnowledgeChunkResponse, RetrieveKnowledgeBaseData, RetrieveKnowledgeBaseError, RetrieveKnowledgeBaseResponse, GetKnowledgeTaskData, GetKnowledgeTaskError, GetKnowledgeTaskResponse, GetPersonaTreeError, GetPersonaTreeResponse, ListPersonasData, ListPersonasError, ListPersonasResponse, CreatePersonaData, CreatePersonaError, CreatePersonaResponse, GetPersonaByIdData, GetPersonaByIdError, GetPersonaByIdResponse, UpdatePersonaByIdData, UpdatePersonaByIdError, UpdatePersonaByIdResponse, DeletePersonaByIdData, DeletePersonaByIdError, DeletePersonaByIdResponse, GetPersonaData, GetPersonaError, GetPersonaResponse, UpdatePersonaData, UpdatePersonaError, UpdatePersonaResponse, DeletePersonaData, DeletePersonaError, DeletePersonaResponse, ListPersonaFoldersData, ListPersonaFoldersError, ListPersonaFoldersResponse, CreatePersonaFolderData, CreatePersonaFolderError, CreatePersonaFolderResponse, UpdatePersonaFolderData, UpdatePersonaFolderError, UpdatePersonaFolderResponse, DeletePersonaFolderData, DeletePersonaFolderError, DeletePersonaFolderResponse, MovePersonaItemData, MovePersonaItemError, MovePersonaItemResponse, ReorderPersonaItemsData, ReorderPersonaItemsError, ReorderPersonaItemsResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, ListActiveUmosError, ListActiveUmosResponse, ListSessionRulesData, ListSessionRulesError, ListSessionRulesResponse, UpsertSessionRuleData, UpsertSessionRuleError, UpsertSessionRuleResponse, DeleteSessionRulesData, DeleteSessionRulesError, DeleteSessionRulesResponse, BatchUpdateSessionProviderData, BatchUpdateSessionProviderError, BatchUpdateSessionProviderResponse, BatchUpdateSessionServiceData, BatchUpdateSessionServiceError, BatchUpdateSessionServiceResponse, ListSessionGroupsError, ListSessionGroupsResponse, CreateSessionGroupData, CreateSessionGroupError, CreateSessionGroupResponse, UpdateSessionGroupData, UpdateSessionGroupError, UpdateSessionGroupResponse, DeleteSessionGroupData, DeleteSessionGroupError, DeleteSessionGroupResponse, ListConversationsData, ListConversationsError, ListConversationsResponse, BatchDeleteConversationsData, BatchDeleteConversationsError, BatchDeleteConversationsResponse, GetConversationData, GetConversationError, GetConversationResponse, UpdateConversationData, UpdateConversationError, UpdateConversationResponse, DeleteConversationData, DeleteConversationError, DeleteConversationResponse, ReplaceConversationMessagesData, ReplaceConversationMessagesError, ReplaceConversationMessagesResponse, ExportConversationsData, ExportConversationsError, ExportConversationsResponse, GetStatsData, GetStatsError, GetStatsResponse, GetProviderTokenStatsData, GetProviderTokenStatsError, GetProviderTokenStatsResponse, GetVersionError, GetVersionResponse, GetPublicVersionsError, GetPublicVersionsResponse, GetFirstNoticeData, GetFirstNoticeError, GetFirstNoticeResponse, TestGhproxyConnectionData, TestGhproxyConnectionError, TestGhproxyConnectionResponse, ListChangelogVersionsError, ListChangelogVersionsResponse, GetChangelogData, GetChangelogError, GetChangelogResponse, GetStartTimeError, GetStartTimeResponse, GetStorageStatusError, GetStorageStatusResponse, CleanupStorageData, CleanupStorageError, CleanupStorageResponse, RestartCoreError, RestartCoreResponse, ListBackupsData, ListBackupsError, ListBackupsResponse, CreateBackupData, CreateBackupError, CreateBackupResponse, UploadBackupData, UploadBackupError, UploadBackupResponse, InitBackupUploadData, InitBackupUploadError, InitBackupUploadResponse, UploadBackupChunkData, UploadBackupChunkError, UploadBackupChunkResponse, CompleteBackupUploadData, CompleteBackupUploadError, CompleteBackupUploadResponse, AbortBackupUploadData, AbortBackupUploadError, AbortBackupUploadResponse, GetBackupProgressData, GetBackupProgressError, GetBackupProgressResponse, DownloadBackupData, DownloadBackupError, DownloadBackupResponse, RenameBackupData, RenameBackupError, RenameBackupResponse, DeleteBackupData, DeleteBackupError, DeleteBackupResponse, CheckBackupData, CheckBackupError, CheckBackupResponse, ImportBackupData, ImportBackupError, ImportBackupResponse, CheckUpdateError, CheckUpdateResponse, ListReleasesData, ListReleasesError, ListReleasesResponse, UpdateCoreData, UpdateCoreError, UpdateCoreResponse, UpdateDashboardData, UpdateDashboardError, UpdateDashboardResponse, GetUpdateProgressData, GetUpdateProgressError, GetUpdateProgressResponse, InstallPipPackageData, InstallPipPackageError, InstallPipPackageResponse, ListCronJobsData, ListCronJobsError, ListCronJobsResponse, CreateCronJobData, CreateCronJobError, CreateCronJobResponse, UpdateCronJobData, UpdateCronJobError, UpdateCronJobResponse, DeleteCronJobData, DeleteCronJobError, DeleteCronJobResponse, RunCronJobData, RunCronJobError, RunCronJobResponse, StreamLiveLogsError, StreamLiveLogsResponse, GetLogHistoryError, GetLogHistoryResponse, GetTraceSettingsError, GetTraceSettingsResponse, UpdateTraceSettingsData, UpdateTraceSettingsError, UpdateTraceSettingsResponse, ListT2iTemplatesError, ListT2iTemplatesResponse, CreateT2iTemplateData, CreateT2iTemplateError, CreateT2iTemplateResponse, GetActiveT2iTemplateError, GetActiveT2iTemplateResponse, SetActiveT2iTemplateData, SetActiveT2iTemplateError, SetActiveT2iTemplateResponse, ResetDefaultT2iTemplateError, ResetDefaultT2iTemplateResponse, GetT2iTemplateData, GetT2iTemplateError, GetT2iTemplateResponse, UpdateT2iTemplateData, UpdateT2iTemplateError, UpdateT2iTemplateResponse, DeleteT2iTemplateData, DeleteT2iTemplateError, DeleteT2iTemplateResponse, GetSubagentConfigError, GetSubagentConfigResponse, UpdateSubagentConfigData, UpdateSubagentConfigError, UpdateSubagentConfigResponse, ListSubagentAvailableToolsError, ListSubagentAvailableToolsResponse, VerifyPlatformWebhookData, VerifyPlatformWebhookError, VerifyPlatformWebhookResponse, ReceivePlatformWebhookData, ReceivePlatformWebhookError, ReceivePlatformWebhookResponse } from './types.gen'; export const client = createClient(createConfig()); @@ -1405,6 +1405,16 @@ export const checkPluginVersionSupport = ( }); }; +/** + * Validate a GitHub plugin repository before installation + */ +export const validatePluginRepo = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/plugins/validate/repo' + }); +}; + /** * List failed plugins and errors */ diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index b865e3437..bf8d0188f 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -462,9 +462,13 @@ export type PluginGithubInstallRequest = { download_url?: string; proxy?: string; ignore_version_check?: boolean; + install_method?: string; + registry_url?: (string) | null; + market_plugin_id?: string; }; export type PluginSourceBindRequest = { + install_method?: string; registry_url?: (string) | null; market_plugin_id?: string; }; @@ -491,6 +495,15 @@ export type PluginUrlInstallRequest = { download_url?: string; proxy?: string; ignore_version_check?: boolean; + install_method?: string; + registry_url?: (string) | null; + market_plugin_id?: string; +}; + +export type PluginValidateRepoRequest = { + repository?: string; + url?: string; + proxy?: string; }; export type PluginVersionSupportRequest = { @@ -1933,6 +1946,14 @@ export type CheckPluginVersionSupportResponse = (SuccessEnvelope); export type CheckPluginVersionSupportError = unknown; +export type ValidatePluginRepoData = { + body: PluginValidateRepoRequest; +}; + +export type ValidatePluginRepoResponse = (SuccessEnvelope); + +export type ValidatePluginRepoError = unknown; + export type ListFailedPluginsResponse = (SuccessEnvelope); export type ListFailedPluginsError = unknown; diff --git a/dashboard/src/api/v1.ts b/dashboard/src/api/v1.ts index cf505abd1..19eacdc70 100644 --- a/dashboard/src/api/v1.ts +++ b/dashboard/src/api/v1.ts @@ -38,6 +38,7 @@ import { type ModelScopeSyncRequest, type PipInstallRequest, type PluginVersionSupportRequest, + type PluginValidateRepoRequest, type PluginConfigFileDeleteRequest, type ProviderConfigRequest, type BatchSessionProviderRequest, @@ -1294,6 +1295,11 @@ export const pluginApi = { openApiV1.installPluginFromUrl({ body: body as any }), ); }, + validateRepo(body: PluginValidateRepoRequest) { + return typed( + openApiV1.validatePluginRepo({ body }), + ); + }, bindSource(pluginId: string, body: OpenConfig) { return typed( openApiV1.bindPluginSource({ diff --git a/dashboard/src/components/shared/ExtensionCard.vue b/dashboard/src/components/shared/ExtensionCard.vue index 99d0e4129..52d9e57f9 100644 --- a/dashboard/src/components/shared/ExtensionCard.vue +++ b/dashboard/src/components/shared/ExtensionCard.vue @@ -60,8 +60,33 @@ const updateDisabledReason = computed(() => { ); }); +const hasKnownInstallSource = computed(() => { + const source = props.extension?.install_source; + const installMethod = String(source?.install_method || "") + .trim() + .toLowerCase(); + return Boolean( + source && + source.implicit !== true && + ["market", "github"].includes(installMethod), + ); +}); + +const hasUsableRepo = computed(() => { + const source = props.extension?.install_source; + return Boolean(String(props.extension?.repo || source?.repo || "").trim()); +}); + const canUpdateExtension = computed(() => { - return props.marketMode || props.extension?.updates_enabled !== false; + return ( + props.marketMode || + (!props.extension?.reserved && + (hasKnownInstallSource.value || hasUsableRepo.value)) + ); +}); + +const canChangePluginSource = computed(() => { + return hasUsableRepo.value; }); const showUninstallDialog = ref(false); @@ -119,6 +144,7 @@ const reloadExtension = () => { }; const changePluginSource = () => { + if (!canChangePluginSource.value) return; emit("change-source", props.extension); }; @@ -440,15 +466,26 @@ const openWebui = () => { - - {{ - tm("card.actions.changeSource") - }} - + + { + + {{ installUrlValidation.message }} + +
+ + + + {{ installUrlValidation.message }} + + · {{ tm("table.headers.version") }}: + {{ installUrlValidation.version }} + + +
+ { {{ tm("buttons.install") }} @@ -1191,7 +1231,11 @@ const updateDialogPluginLogo = computed(() => { {{ tm("buttons.cancel") }} - + {{ tm("dialogs.update.confirm") }} diff --git a/dashboard/src/views/extension/InstalledPluginsTab.vue b/dashboard/src/views/extension/InstalledPluginsTab.vue index 02402e8f5..945fef297 100644 --- a/dashboard/src/views/extension/InstalledPluginsTab.vue +++ b/dashboard/src/views/extension/InstalledPluginsTab.vue @@ -64,6 +64,7 @@ const { editingSource, originalSourceUrl, sourceBindingDialog, + selectedSourceBindingCandidate, extension_url, dialog, upload_file, @@ -371,8 +372,11 @@ const togglePinnedExtension = (extension) => { - - {{ tm("dialogs.sourceBinding.title") }} + {{ + sourceBindingDialog.pendingUpdate + ? tm("dialogs.sourceBinding.selectTitle") + : tm("dialogs.sourceBinding.title") + }}
{
{{ candidate.registry_name }}
-
- {{ candidate.market_plugin_id }} -
-
- {{ candidate.repo }} -
-
- {{ tm("table.headers.version") }}: {{ candidate.version }} -
+
+
+ {{ tm("dialogs.sourceBinding.installDestination") }}: + {{ + selectedSourceBindingCandidate.download_url || + selectedSourceBindingCandidate.repo + }} +
+
+ {{ tm("table.headers.version") }}: + + + + + {{ + selectedSourceBindingCandidate.version || + tm("status.unknown") + }} + +
+
+ + {{ selectedSourceBindingCandidate.validation_message }} +
@@ -442,11 +483,17 @@ const togglePinnedExtension = (extension) => { :disabled=" sourceBindingDialog.loading || sourceBindingDialog.candidates.length === 0 || - !sourceBindingDialog.selectedKey + !sourceBindingDialog.selectedKey || + (selectedSourceBindingCandidate?.install_method === 'github' && + selectedSourceBindingCandidate?.validation_status !== 'valid') " @click="confirmPluginSourceBinding" > - {{ tm("dialogs.sourceBinding.confirm") }} + {{ + sourceBindingDialog.pendingUpdate + ? tm("dialogs.sourceBinding.confirmAndContinue") + : tm("dialogs.sourceBinding.confirm") + }}
diff --git a/dashboard/src/views/extension/useExtensionPage.js b/dashboard/src/views/extension/useExtensionPage.js index 19d611c30..a358cebb9 100644 --- a/dashboard/src/views/extension/useExtensionPage.js +++ b/dashboard/src/views/extension/useExtensionPage.js @@ -151,6 +151,13 @@ export const useExtensionPage = () => { supported: true, message: "", }); + const installUrlValidation = reactive({ + validating: false, + status: "idle", + message: "", + version: "", + metadata: null, + }); // AstrBot 版本范围不兼容警告对话框 const versionSupportDialog = reactive({ @@ -184,6 +191,8 @@ export const useExtensionPage = () => { extension: null, candidates: [], selectedKey: "", + pendingUpdate: null, + validationSerial: 0, }); // 插件市场相关 @@ -651,6 +660,10 @@ export const useExtensionPage = () => { const findMarketPluginForExtension = (extension) => { if (!extension) return null; + const source = extension.install_source || {}; + if (source.implicit === true || source.install_method !== "market") { + return null; + } if (extension.update_market_plugin) { return extension.update_market_plugin; } @@ -705,6 +718,7 @@ export const useExtensionPage = () => { if ( !extension.updates_enabled || !source || + source.implicit === true || source.install_method !== "market" ) { return; @@ -817,9 +831,28 @@ export const useExtensionPage = () => { updateConfirmDialog.forceUpdate = false; }; - const updateExtension = async (extension_name, forceUpdate = false) => { - const ext = getInstalledExtensionByName(extension_name); - if (ext && ext.updates_enabled === false) { + const buildUpdateContext = (ext) => { + const source = ext?.install_source || null; + const installMethod = String(source?.install_method || "") + .trim() + .toLowerCase(); + const repoUrl = normalizeInstallUrl(ext?.repo || source?.repo); + const needsSourceSelection = + Boolean(ext) && + (!source || + source.implicit === true || + !["market", "github"].includes(installMethod)); + + return { source, installMethod, repoUrl, needsSourceSelection }; + }; + + const performUpdateWithoutSourceSelection = ( + extensionName, + ext, + forceUpdate, + repoUrl, + ) => { + if (ext && ext.updates_enabled === false && !repoUrl) { toast( ext.update_disabled_reason || tm("messages.updateDisabled"), "info", @@ -829,12 +862,44 @@ export const useExtensionPage = () => { // 如果没有检测到更新且不是强制更新,则弹窗确认 if (!ext?.has_update && !forceUpdate) { - forceUpdateDialog.extensionName = extension_name; + forceUpdateDialog.extensionName = extensionName; forceUpdateDialog.show = true; return; } - openUpdateConfirmDialog(extension_name, forceUpdate); + openUpdateConfirmDialog(extensionName, forceUpdate); + }; + + const updateExtension = async ( + extension_name, + forceUpdate = false, + options = {}, + ) => { + const ext = getInstalledExtensionByName(extension_name); + const { repoUrl, needsSourceSelection } = buildUpdateContext(ext); + + if ( + needsSourceSelection && + !options.skipSourceCheck && + !repoUrl + ) { + toast(tm("messages.updateDisabled"), "info"); + return; + } + + if (needsSourceSelection && !options.skipSourceCheck) { + await openPluginSourceBindingDialog(ext, { + pendingUpdate: { extensionName: extension_name, forceUpdate }, + }); + return; + } + + performUpdateWithoutSourceSelection( + extension_name, + ext, + forceUpdate, + repoUrl, + ); }; const confirmUpdatePlugin = async () => { @@ -845,6 +910,8 @@ export const useExtensionPage = () => { return; } + const hasDownloadUrl = Boolean(getUpdateDownloadUrl(ext)); + closeUpdateConfirmDialog(); loadingDialog.title = tm("status.loading"); loadingDialog.statusCode = 0; @@ -852,7 +919,7 @@ export const useExtensionPage = () => { loadingDialog.show = true; try { const res = await pluginApi.update(extensionName, { - proxy: getUpdateDownloadUrl(ext) ? "" : getSelectedGitHubProxy(), + proxy: hasDownloadUrl ? "" : getSelectedGitHubProxy(), }); if (res.data.status === "error") { @@ -905,11 +972,11 @@ export const useExtensionPage = () => { updateAllConfirmDialog.show = false; }; - const confirmForceUpdate = () => { + const confirmForceUpdate = async () => { const name = forceUpdateDialog.extensionName; forceUpdateDialog.show = false; forceUpdateDialog.extensionName = ""; - openUpdateConfirmDialog(name, true); + await updateExtension(name, true); }; const updateAllExtensions = async () => { @@ -1121,6 +1188,36 @@ export const useExtensionPage = () => { !selectedInstallDownloadUrl.value && isGithubRepoUrl(extension_url.value), ); + const resetInstallUrlValidation = () => { + installUrlValidation.validating = false; + installUrlValidation.status = "idle"; + installUrlValidation.message = ""; + installUrlValidation.version = ""; + installUrlValidation.metadata = null; + }; + + const validatePluginRepo = async (payload) => { + const res = await pluginApi.validateRepo(payload); + if (res.data.status === "error") { + throw new Error(res.data.message || tm("messages.pluginValidateFailed")); + } + return { + data: res.data.data || {}, + message: res.data.message || tm("messages.pluginValidateSuccess"), + }; + }; + + const buildInstallRepoValidationPayload = () => { + const url = String(extension_url.value || "").trim(); + if (!url) { + return null; + } + return { + url, + proxy: getSelectedGitHubProxy(), + }; + }; + // 为表格视图创建一个处理安装插件的函数 const handleInstallPlugin = async (plugin) => { if (plugin.tags && plugin.tags.includes("danger")) { @@ -1222,7 +1319,157 @@ export const useExtensionPage = () => { normalizeRegistryUrl(sourceUrl.value), ); - const openPluginSourceBindingDialog = async (extension) => { + const validateSourceBindingRepoCandidate = async (candidate) => { + if (!candidate || candidate.install_method !== "github") { + return; + } + if (candidate.validation_status === "loading") { + return; + } + + const serial = ++sourceBindingDialog.validationSerial; + candidate.validation_status = "loading"; + candidate.validation_message = ""; + candidate.version = ""; + try { + const { data, message } = await validatePluginRepo({ + url: candidate.repo, + proxy: getSelectedGitHubProxy(), + }); + if (serial !== sourceBindingDialog.validationSerial) { + return; + } + candidate.version = data.version || tm("status.unknown"); + candidate.validation_status = "valid"; + candidate.validation_message = message; + candidate.metadata = data; + } catch (error) { + if (serial !== sourceBindingDialog.validationSerial) { + return; + } + candidate.version = tm("status.unknown"); + candidate.validation_status = "error"; + candidate.validation_message = resolveErrorMessage( + error, + tm("messages.pluginValidateFailed"), + ); + } + }; + + const resolvePluginSourceCandidates = async (extension) => { + const extensionRepo = normalizeInstallUrl( + extension.repo || extension.install_source?.repo, + ).toLowerCase(); + if (!extensionRepo) { + return { candidates: [], selectedKey: "" }; + } + + await loadCustomSources(); + const sources = [ + { name: tm("market.defaultSource"), url: null }, + ...customSources.value.map((source) => ({ + name: source.name, + url: source.url, + })), + ]; + const currentMarketPluginId = String( + extension.install_source?.market_plugin_id || "", + ).trim(); + const currentRegistryUrl = normalizeRegistryUrl( + extension.install_source?.registry_url, + ); + const currentInstallMethod = String( + extension.install_source?.install_method || "", + ) + .trim() + .toLowerCase(); + const seen = new Set(); + const candidates = []; + + for (const source of sources) { + let marketPlugins = []; + try { + marketPlugins = await commonStore.getPluginCollections( + false, + source.url || null, + ); + } catch (error) { + console.warn("Failed to load plugin source for binding:", error); + continue; + } + + marketPlugins.forEach((plugin) => { + const marketPluginId = getMarketPluginId(plugin); + const pluginRepo = normalizeInstallUrl(plugin.repo).toLowerCase(); + if (!marketPluginId || !pluginRepo || pluginRepo !== extensionRepo) { + return; + } + if ( + currentMarketPluginId && + marketPluginId !== currentMarketPluginId + ) { + return; + } + + const registryUrl = normalizeRegistryUrl(source.url); + const key = `${registryUrl}|${marketPluginId}|${pluginRepo}`; + if (seen.has(key)) { + return; + } + seen.add(key); + candidates.push({ + key, + install_method: "market", + registry_url: registryUrl || null, + registry_name: source.name, + market_plugin_id: marketPluginId, + repo: plugin.repo, + download_url: plugin.download_url || "", + version: plugin.version || tm("status.unknown"), + validation_status: "valid", + validation_message: "", + }); + }); + } + + candidates.push({ + key: `github||${extensionRepo}`, + install_method: "github", + registry_url: null, + registry_name: tm("dialogs.sourceBinding.repoOption"), + market_plugin_id: "", + repo: extension.repo || extension.install_source?.repo, + download_url: "", + version: "", + validation_status: "idle", + validation_message: "", + }); + + const currentCandidate = + currentInstallMethod === "github" + ? candidates.find( + (candidate) => + candidate.install_method === "github" && + normalizeInstallUrl(candidate.repo).toLowerCase() === extensionRepo, + ) + : candidates.find((candidate) => { + return ( + candidate.install_method === "market" && + normalizeRegistryUrl(candidate.registry_url) === + currentRegistryUrl && + (!currentMarketPluginId || + candidate.market_plugin_id === currentMarketPluginId) && + normalizeInstallUrl(candidate.repo).toLowerCase() === extensionRepo + ); + }); + + return { + candidates, + selectedKey: currentCandidate?.key || candidates[0]?.key || "", + }; + }; + + const openPluginSourceBindingDialog = async (extension, options = {}) => { if (!extension) return; sourceBindingDialog.show = true; sourceBindingDialog.loading = true; @@ -1230,99 +1477,43 @@ export const useExtensionPage = () => { sourceBindingDialog.extension = extension; sourceBindingDialog.candidates = []; sourceBindingDialog.selectedKey = ""; - - const extensionRepo = normalizeInstallUrl( - extension.repo || extension.install_source?.repo, - ).toLowerCase(); - if (!extensionRepo) { - sourceBindingDialog.loading = false; - return; - } + sourceBindingDialog.pendingUpdate = options.pendingUpdate || null; try { - await loadCustomSources(); - const sources = [ - { name: tm("market.defaultSource"), url: null }, - ...customSources.value.map((source) => ({ - name: source.name, - url: source.url, - })), - ]; - const currentMarketPluginId = String( - extension.install_source?.market_plugin_id || "", - ).trim(); - const currentRegistryUrl = normalizeRegistryUrl( - extension.install_source?.registry_url, - ); - const seen = new Set(); - const candidates = []; - - for (const source of sources) { - let marketPlugins = []; - try { - marketPlugins = await commonStore.getPluginCollections( - false, - source.url || null, - ); - } catch (error) { - console.warn("Failed to load plugin source for binding:", error); - continue; - } - - marketPlugins.forEach((plugin) => { - const marketPluginId = getMarketPluginId(plugin); - const pluginRepo = normalizeInstallUrl(plugin.repo).toLowerCase(); - if (!marketPluginId || !pluginRepo || pluginRepo !== extensionRepo) { - return; - } - if ( - currentMarketPluginId && - marketPluginId !== currentMarketPluginId - ) { - return; - } - - const registryUrl = normalizeRegistryUrl(source.url); - const key = `${registryUrl}|${marketPluginId}|${pluginRepo}`; - if (seen.has(key)) { - return; - } - seen.add(key); - candidates.push({ - key, - registry_url: registryUrl || null, - registry_name: source.name, - market_plugin_id: marketPluginId, - repo: plugin.repo, - download_url: plugin.download_url || "", - version: plugin.version || tm("status.unknown"), - }); - }); - } - - const currentCandidate = candidates.find((candidate) => { - return ( - normalizeRegistryUrl(candidate.registry_url) === currentRegistryUrl && - (!currentMarketPluginId || - candidate.market_plugin_id === currentMarketPluginId) && - normalizeInstallUrl(candidate.repo).toLowerCase() === extensionRepo - ); - }); + const { candidates, selectedKey } = + await resolvePluginSourceCandidates(extension); sourceBindingDialog.candidates = candidates; - sourceBindingDialog.selectedKey = - currentCandidate?.key || candidates[0]?.key || ""; + sourceBindingDialog.selectedKey = selectedKey; } finally { sourceBindingDialog.loading = false; } + const selectedCandidate = sourceBindingDialog.candidates.find( + (item) => item.key === sourceBindingDialog.selectedKey, + ); + if (selectedCandidate?.install_method === "github") { + void validateSourceBindingRepoCandidate(selectedCandidate); + } }; const closePluginSourceBindingDialog = () => { + sourceBindingDialog.validationSerial += 1; sourceBindingDialog.show = false; sourceBindingDialog.loading = false; sourceBindingDialog.saving = false; sourceBindingDialog.extension = null; sourceBindingDialog.candidates = []; sourceBindingDialog.selectedKey = ""; + sourceBindingDialog.pendingUpdate = null; + }; + + const continuePendingUpdateAfterBinding = async (pendingUpdate) => { + if (!pendingUpdate) { + return; + } + + await updateExtension(pendingUpdate.extensionName, pendingUpdate.forceUpdate, { + skipSourceCheck: true, + }); }; const confirmPluginSourceBinding = async () => { @@ -1336,10 +1527,16 @@ export const useExtensionPage = () => { sourceBindingDialog.saving = true; try { - const res = await pluginApi.bindSource(extension.name, { - registry_url: candidate.registry_url, - market_plugin_id: candidate.market_plugin_id, - }); + const pendingUpdate = sourceBindingDialog.pendingUpdate; + const payload = + candidate.install_method === "github" + ? { install_method: "github" } + : { + install_method: "market", + registry_url: candidate.registry_url, + market_plugin_id: candidate.market_plugin_id, + }; + const res = await pluginApi.bindSource(extension.name, payload); if (res.data.status === "error") { toast(res.data.message, "error"); return; @@ -1350,6 +1547,7 @@ export const useExtensionPage = () => { await getExtensions(); checkAlreadyInstalled(); await checkUpdate(); + await continuePendingUpdateAfterBinding(pendingUpdate); } catch (error) { const errorMsg = error.response?.data?.message || error.message || String(error); @@ -1846,6 +2044,45 @@ export const useExtensionPage = () => { loading_.value = true; try { + if (source === "url" && !selectedInstallDownloadUrl.value) { + if (!installUsesGithubSource.value) { + toast(tm("messages.invalidGithubRepo"), "error"); + loading_.value = false; + return; + } + + const validationPayload = buildInstallRepoValidationPayload(); + if (!validationPayload) { + toast(tm("messages.fillUrlOrFile"), "error"); + loading_.value = false; + return; + } + installUrlValidation.validating = true; + installUrlValidation.status = "loading"; + installUrlValidation.message = tm("messages.validatingPlugin"); + installUrlValidation.version = ""; + installUrlValidation.metadata = null; + try { + const { data, message } = await validatePluginRepo(validationPayload); + installUrlValidation.status = "valid"; + installUrlValidation.message = message; + installUrlValidation.version = data.version || ""; + installUrlValidation.metadata = data; + } catch (error) { + const message = resolveErrorMessage( + error, + tm("messages.pluginValidateFailed"), + ); + installUrlValidation.status = "error"; + installUrlValidation.message = message; + toast(message, "error"); + loading_.value = false; + return; + } finally { + installUrlValidation.validating = false; + } + } + const res = await performInstallRequest({ source, ignoreVersionCheck: shouldIgnoreVersionCheck, @@ -1899,6 +2136,13 @@ export const useExtensionPage = () => { findMarketPluginForExtension(selectedUpdateExtension.value), ); + const selectedSourceBindingCandidate = computed( + () => + sourceBindingDialog.candidates.find( + (item) => item.key === sourceBindingDialog.selectedKey, + ) || null, + ); + const selectedUpdateDownloadUrl = computed(() => String(selectedUpdateMarketPlugin.value?.download_url || "").trim(), ); @@ -2035,6 +2279,7 @@ export const useExtensionPage = () => { watch( [() => dialog.value, () => extension_url.value, () => uploadTab.value], async ([dialogOpen, _, currentUploadTab]) => { + resetInstallUrlValidation(); if (!dialogOpen || currentUploadTab !== "url") { installSupport.checked = false; installSupport.supported = true; @@ -2048,6 +2293,24 @@ export const useExtensionPage = () => { }, ); + watch( + [() => sourceBindingDialog.show, () => sourceBindingDialog.selectedKey], + ([dialogOpen]) => { + if (!dialogOpen || sourceBindingDialog.loading) { + return; + } + const candidate = sourceBindingDialog.candidates.find( + (item) => item.key === sourceBindingDialog.selectedKey, + ); + if ( + candidate?.install_method === "github" && + candidate.validation_status !== "valid" + ) { + void validateSourceBindingRepoCandidate(candidate); + } + }, + ); + watch( () => route.hash, (newHash) => { @@ -2123,6 +2386,7 @@ export const useExtensionPage = () => { selectedDangerPlugin, selectedMarketInstallPlugin, installSupport, + installUrlValidation, versionSupportDialog, showUninstallDialog, uninstallTarget, @@ -2141,6 +2405,7 @@ export const useExtensionPage = () => { editingSource, originalSourceUrl, sourceBindingDialog, + selectedSourceBindingCandidate, extension_url, dialog, upload_file, diff --git a/openspec/openapi-v1.yaml b/openspec/openapi-v1.yaml index 0c87c7e86..7bcfde31b 100644 --- a/openspec/openapi-v1.yaml +++ b/openspec/openapi-v1.yaml @@ -2252,6 +2252,22 @@ paths: "200": $ref: "#/components/responses/Ok" + /api/v1/plugins/validate/repo: + post: + tags: [Plugins] + summary: Validate a GitHub plugin repository before installation + operationId: validatePluginRepo + x-astrbot-scope: plugin + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginValidateRepoRequest" + responses: + "200": + $ref: "#/components/responses/Ok" + /api/v1/plugins/failed: get: tags: [Plugins] @@ -5465,6 +5481,8 @@ components: PluginSourceBindRequest: type: object properties: + install_method: + type: string registry_url: type: string nullable: true @@ -5513,6 +5531,13 @@ components: type: string ignore_version_check: type: boolean + install_method: + type: string + registry_url: + type: string + nullable: true + market_plugin_id: + type: string additionalProperties: false PluginUrlInstallRequest: @@ -5530,6 +5555,24 @@ components: type: string ignore_version_check: type: boolean + install_method: + type: string + registry_url: + type: string + nullable: true + market_plugin_id: + type: string + additionalProperties: false + + PluginValidateRepoRequest: + type: object + properties: + repository: + type: string + url: + type: string + proxy: + type: string additionalProperties: false PluginUploadInstallRequest: diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 30d5ca62a..2385c0d6d 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -43,10 +43,7 @@ from astrbot.dashboard.password_state import ( from astrbot.dashboard.server import AstrBotDashboard from astrbot.dashboard.services.auth_service import DASHBOARD_JWT_COOKIE_NAME from astrbot.dashboard.services.plugin_page_service import PluginPageService -from astrbot.dashboard.services.plugin_service import ( - PLUGIN_UPDATE_DISABLED_MESSAGE, - PluginService, -) +from astrbot.dashboard.services.plugin_service import PluginService from tests.fixtures.helpers import ( MockPluginBuilder, create_mock_updater_install, @@ -2305,8 +2302,8 @@ async def test_plugins( datetime.fromisoformat(installed_at) assert target["install_source"]["install_method"] == "github" assert target["install_source"]["repo"] == test_repo_url - assert target["updates_enabled"] is False - assert target["update_disabled_reason"] == PLUGIN_UPDATE_DISABLED_MESSAGE + assert target["updates_enabled"] is True + assert target["update_disabled_reason"] == "" response = await test_client.get( f"/api/plugin/detail?name={test_plugin_name}", @@ -2323,7 +2320,7 @@ async def test_plugins( exists = any(md.name == test_plugin_name for md in star_registry) assert exists is True, f"插件 {test_plugin_name} 未成功载入" - # Git URL installs are intentionally not updatable from the marketplace. + # Git URL installs can be explicitly reinstalled from their repository. response = await test_client.post( "/api/plugin/update", json={"name": test_plugin_name}, @@ -2331,11 +2328,10 @@ async def test_plugins( ) assert response.status_code == 200 data = await response.get_json() - assert data["status"] == "error" - assert data["message"] == PLUGIN_UPDATE_DISABLED_MESSAGE + assert data["status"] == "ok" plugin_dir = builder.get_plugin_path(test_plugin_name) - assert not (plugin_dir / ".updated").exists() + assert (plugin_dir / ".updated").read_text(encoding="utf-8") == "ok" # 插件卸载 response = await test_client.post( diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index 61ca5002e..3436ae22f 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -2,6 +2,7 @@ import copy from dataclasses import dataclass from pathlib import Path from types import SimpleNamespace +from typing import cast import httpx import jwt @@ -23,6 +24,10 @@ from astrbot.dashboard.asgi_runtime import ( from astrbot.dashboard.responses import ok from astrbot.dashboard.services.api_key_service import ApiKeyService from astrbot.dashboard.services.auth_service import DASHBOARD_JWT_COOKIE_NAME +from astrbot.dashboard.services.plugin_service import ( + PLUGIN_UPDATE_SOURCE_REQUIRED_MESSAGE, + PluginServiceError, +) from astrbot.dashboard.services.skills_service import SkillArchive JWT_SECRET = "fastapi-v1-test-secret-with-32-bytes" @@ -1732,6 +1737,49 @@ async def test_v1_plugin_version_support_check_uses_service( } +@pytest.mark.asyncio +async def test_v1_plugin_validate_repo_uses_service( + asgi_app: FastAPI, + asgi_client: httpx.AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + plugin_service = asgi_app.state.services.plugins + captured = {} + + async def fake_validate_plugin_repo(payload): + captured["payload"] = payload + return { + "valid": True, + "name": "astrbot_plugin_demo", + "version": "1.2.3", + }, "插件校验通过。" + + monkeypatch.setattr( + plugin_service, + "validate_plugin_repo", + fake_validate_plugin_repo, + ) + + response = await asgi_client.post( + "/api/v1/plugins/validate/repo", + json={ + "url": "https://github.com/AstrBotDevs/astrbot-plugin-demo", + "proxy": "https://proxy.example", + }, + headers=_jwt_headers(), + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["message"] == "插件校验通过。" + assert data["data"]["version"] == "1.2.3" + assert captured["payload"] == { + "url": "https://github.com/AstrBotDevs/astrbot-plugin-demo", + "proxy": "https://proxy.example", + } + + @pytest.mark.asyncio async def test_v1_plugin_url_install_accepts_download_url_and_missing_body( asgi_app: FastAPI, @@ -1892,6 +1940,219 @@ async def test_plugin_service_market_install_uses_registry_entry( assert captured["synced"] is True +@pytest.mark.asyncio +async def test_plugin_service_validate_plugin_repo_fetches_metadata_file( + asgi_app: FastAPI, + monkeypatch: pytest.MonkeyPatch, +): + import astrbot.dashboard.services.plugin_service as plugin_service_module + from astrbot.core.star.updator import PluginUpdator + + plugin_service = asgi_app.state.services.plugins + captured: dict[str, object] = {"urls": []} + updater = PluginUpdator.__new__(PluginUpdator) + + async def fake_resolve_github_source_branch(repo_url: str): + assert repo_url == "https://github.com/AstrBotDevs/astrbot-plugin-demo" + return "AstrBotDevs", "astrbot-plugin-demo", "trunk" + + plugin_service.plugin_manager.updator = SimpleNamespace( + parse_github_url=updater.parse_github_url, + resolve_github_source_branch=fake_resolve_github_source_branch, + validate_plugin_metadata=PluginUpdator.validate_plugin_metadata, + ) + + class FakeContent: + def __init__(self, text: str): + self._text = text + + async def read(self, size: int) -> bytes: + return self._text.encode("utf-8")[:size] + + class FakeResponse: + def __init__(self, status: int, *, text: str = "", payload=None): + self.status = status + self._text = text + self._payload = payload or {} + self.headers = {} + self.content = FakeContent(text) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + async def json(self): + return self._payload + + async def text(self): + return self._text + + class FakeClientSession: + def __init__(self, **kwargs): + captured["session_kwargs"] = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + def get(self, url: str): + cast(list[str], captured["urls"]).append(url) + if url.endswith("/metadata.yaml"): + return FakeResponse(404) + if url.endswith("/metadata.yml"): + return FakeResponse( + 200, + text="\n".join( + [ + "name: astrbot_plugin_demo", + "description: Demo plugin", + "version: 2.0.0", + "author: AstrBotDevs", + "repo: https://github.com/AstrBotDevs/astrbot-plugin-demo", + ] + ), + ) + return FakeResponse(404) + + monkeypatch.setattr( + plugin_service_module.aiohttp, + "ClientSession", + FakeClientSession, + ) + + result, message = await plugin_service.validate_plugin_repo( + { + "url": "AstrBotDevs/astrbot-plugin-demo", + "proxy": "https://proxy.example/", + } + ) + + assert message == "插件校验通过。" + assert result["metadata_entry"] == "metadata.yml" + assert result["metadata_branch"] == "trunk" + assert result["desc"] == "Demo plugin" + assert result["version"] == "2.0.0" + assert ( + "https://proxy.example/https://raw.githubusercontent.com/" + "AstrBotDevs/astrbot-plugin-demo/trunk/metadata.yml" + in cast(list[str], captured["urls"]) + ) + session_kwargs = cast(dict[str, object], captured["session_kwargs"]) + assert "timeout" in session_kwargs + + +@pytest.mark.asyncio +async def test_plugin_service_validate_plugin_repo_rejects_large_metadata_file( + asgi_app: FastAPI, + monkeypatch: pytest.MonkeyPatch, +): + import astrbot.dashboard.services.plugin_service as plugin_service_module + from astrbot.core.star.updator import PluginUpdator + + plugin_service = asgi_app.state.services.plugins + + async def fake_resolve_github_source_branch(repo_url: str): + assert repo_url == "https://github.com/AstrBotDevs/astrbot-plugin-demo" + return "AstrBotDevs", "astrbot-plugin-demo", "main" + + plugin_service.plugin_manager.updator = SimpleNamespace( + resolve_github_source_branch=fake_resolve_github_source_branch, + validate_plugin_metadata=PluginUpdator.validate_plugin_metadata, + ) + + class FakeContent: + async def read(self, size: int) -> bytes: # noqa: ARG002 + raise AssertionError("metadata body should not be read when too large") + + class FakeResponse: + status = 200 + headers = { + "Content-Length": str(plugin_service_module.PLUGIN_METADATA_MAX_BYTES + 1) + } + content = FakeContent() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + class FakeClientSession: + def __init__(self, **kwargs): # noqa: ARG002 + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + def get(self, url: str): # noqa: ARG002 + return FakeResponse() + + monkeypatch.setattr( + plugin_service_module.aiohttp, + "ClientSession", + FakeClientSession, + ) + + with pytest.raises(PluginServiceError, match="超过 1MB"): + await plugin_service.validate_plugin_repo( + {"url": "https://github.com/AstrBotDevs/astrbot-plugin-demo"} + ) + + +@pytest.mark.asyncio +async def test_plugin_service_validate_plugin_repo_hides_internal_errors( + asgi_app: FastAPI, + monkeypatch: pytest.MonkeyPatch, +): + import astrbot.dashboard.services.plugin_service as plugin_service_module + from astrbot.core.star.updator import PluginUpdator + + plugin_service = asgi_app.state.services.plugins + + async def fake_resolve_github_source_branch(repo_url: str): + assert repo_url == "https://github.com/AstrBotDevs/astrbot-plugin-demo" + return "AstrBotDevs", "astrbot-plugin-demo", "main" + + plugin_service.plugin_manager.updator = SimpleNamespace( + resolve_github_source_branch=fake_resolve_github_source_branch, + validate_plugin_metadata=PluginUpdator.validate_plugin_metadata, + ) + + class FakeClientSession: + def __init__(self, **kwargs): # noqa: ARG002 + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + def get(self, url: str): # noqa: ARG002 + raise RuntimeError("secret stack trace") + + monkeypatch.setattr( + plugin_service_module.aiohttp, + "ClientSession", + FakeClientSession, + ) + + with pytest.raises(PluginServiceError) as exc_info: + await plugin_service.validate_plugin_repo( + {"url": "https://github.com/AstrBotDevs/astrbot-plugin-demo"} + ) + + assert exc_info.value.public_message == "插件校验失败,请查看服务端日志。" + assert "secret stack trace" not in exc_info.value.public_message + + @pytest.mark.asyncio async def test_plugin_service_bind_market_source_validates_and_persists( asgi_app: FastAPI, @@ -1960,6 +2221,53 @@ async def test_plugin_service_bind_market_source_validates_and_persists( assert captured["records"]["astrbot_plugin_demo"] == record +@pytest.mark.asyncio +async def test_plugin_service_bind_repo_source_persists_github_method( + asgi_app: FastAPI, + monkeypatch: pytest.MonkeyPatch, +): + plugin_service = asgi_app.state.services.plugins + plugin = SimpleNamespace( + name="astrbot_plugin_demo", + root_dir_name="astrbot_plugin_demo", + repo="https://github.com/AstrBotDevs/astrbot-plugin-demo", + ) + captured = {} + + async def fake_get_plugin_install_sources(): + return {"astrbot_plugin_demo": {"installed_at": "2026-06-26T00:00:00+00:00"}} + + async def fake_save_plugin_install_sources(records): + captured["records"] = records + + monkeypatch.setattr(plugin_service, "find_plugin_by_name", lambda name: plugin) + monkeypatch.setattr( + plugin_service, + "get_plugin_install_sources", + fake_get_plugin_install_sources, + ) + monkeypatch.setattr( + plugin_service, + "save_plugin_install_sources", + fake_save_plugin_install_sources, + ) + + record, message = await plugin_service.bind_plugin_market_source( + { + "name": "astrbot_plugin_demo", + "install_method": "github", + } + ) + + assert message == "插件源已更新。" + assert record["install_method"] == "github" + assert record["registry_url"] is None + assert record["registry_name"] == "Repository" + assert record["repo"] == "https://github.com/AstrBotDevs/astrbot-plugin-demo" + assert record["installed_at"] == "2026-06-26T00:00:00+00:00" + assert captured["records"]["astrbot_plugin_demo"] == record + + @pytest.mark.asyncio async def test_plugin_service_bind_market_source_rejects_repo_mismatch( asgi_app: FastAPI, @@ -2097,7 +2405,7 @@ async def test_plugin_service_persist_install_source_resolves_registry_before_re assert record["registry_name"] == "Custom" -def test_plugin_service_missing_install_source_defaults_to_official_market( +def test_plugin_service_missing_install_source_is_implicit_for_display( asgi_app: FastAPI, ): plugin_service = asgi_app.state.services.plugins @@ -2114,12 +2422,13 @@ def test_plugin_service_missing_install_source_defaults_to_official_market( assert record["registry_url"] is None assert record["registry_name"] == "Default" assert record["repo"] == "https://github.com/AstrBotDevs/astrbot-plugin-demo" + assert record["implicit"] is True assert record["name"] == "astrbot_plugin_demo" assert record["marketplace_name"] == "astrbot-plugin-demo" @pytest.mark.asyncio -async def test_plugin_service_update_missing_source_uses_default_registry( +async def test_plugin_service_update_missing_source_requires_selection( asgi_app: FastAPI, monkeypatch: pytest.MonkeyPatch, ): @@ -2130,59 +2439,56 @@ async def test_plugin_service_update_missing_source_uses_default_registry( repo="https://github.com/AstrBotDevs/astrbot-plugin-demo", reserved=False, ) - captured = {} async def fake_get_plugin_install_sources(): return {} - async def fake_save_plugin_install_sources(records): - captured["records"] = records - - async def fake_get_online_plugins(*, custom_registry, force_refresh): - captured["registry_url"] = custom_registry - captured["force_refresh"] = force_refresh - return { - "$meta": { - "schema_version": 1, - "name": "Test Market", - "version": "2026.06.27", - }, - "astrbot-plugin-demo": { - "author": "AstrBotDevs", - "repo": "https://github.com/AstrBotDevs/astrbot-plugin-demo", - "download_url": "https://cdn.example/plugin.zip", - }, - }, None - monkeypatch.setattr(plugin_service, "find_plugin_by_name", lambda name: plugin) monkeypatch.setattr( plugin_service, "get_plugin_install_sources", fake_get_plugin_install_sources, ) + + with pytest.raises(PluginServiceError) as exc_info: + await plugin_service.resolve_market_update_info("astrbot_plugin_demo") + + assert exc_info.value.public_message == PLUGIN_UPDATE_SOURCE_REQUIRED_MESSAGE + + +@pytest.mark.asyncio +async def test_plugin_service_update_github_source_uses_plugin_repo( + asgi_app: FastAPI, + monkeypatch: pytest.MonkeyPatch, +): + plugin_service = asgi_app.state.services.plugins + plugin = SimpleNamespace( + name="astrbot_plugin_demo", + root_dir_name="astrbot_plugin_demo", + repo="https://github.com/AstrBotDevs/astrbot-plugin-demo", + reserved=False, + ) + + async def fake_get_plugin_install_sources(): + return { + "astrbot_plugin_demo": { + "install_method": "github", + "repo": "https://github.com/AstrBotDevs/astrbot-plugin-demo", + } + } + + monkeypatch.setattr(plugin_service, "find_plugin_by_name", lambda name: plugin) monkeypatch.setattr( plugin_service, - "save_plugin_install_sources", - fake_save_plugin_install_sources, + "get_plugin_install_sources", + fake_get_plugin_install_sources, ) - monkeypatch.setattr(plugin_service, "get_online_plugins", fake_get_online_plugins) update_info = await plugin_service.resolve_market_update_info("astrbot_plugin_demo") - await plugin_service.refresh_plugin_install_source_after_update( - "astrbot_plugin_demo", - update_info, - ) - assert captured["registry_url"] is None - assert captured["force_refresh"] is False assert update_info["repo"] == "https://github.com/AstrBotDevs/astrbot-plugin-demo" - assert update_info["download_url"] == "https://cdn.example/plugin.zip" - assert update_info["record"]["install_method"] == "market" - record = captured["records"]["astrbot_plugin_demo"] - assert record["registry_url"] is None - assert record["registry_name"] == "Default" - assert record["market_plugin_id"] == "AstrBotDevs/astrbot-plugin-demo" - assert record["download_url"] == "https://cdn.example/plugin.zip" + assert update_info["download_url"] == "" + assert update_info["record"]["install_method"] == "github" @pytest.mark.asyncio diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index a150ac459..2ab6cbddb 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -124,6 +124,65 @@ def test_load_plugin_metadata_includes_pages(tmp_path: Path): assert loaded_metadata.pages == [{"name": "dashboard", "title": "Dashboard"}] +def test_load_plugin_metadata_accepts_yml_suffix(tmp_path: Path): + plugin_path = tmp_path / "helloworld" + _write_local_test_plugin(plugin_path, TEST_PLUGIN_REPO) + metadata_path = plugin_path / "metadata.yaml" + yml_metadata_path = plugin_path / "metadata.yml" + yml_metadata_path.write_text(metadata_path.read_text(encoding="utf-8")) + metadata_path.unlink() + + loaded_metadata = PluginManager._load_plugin_metadata(str(plugin_path)) + + assert loaded_metadata is not None + assert loaded_metadata.name == TEST_PLUGIN_NAME + assert PluginManager._get_plugin_dir_name_from_metadata(str(plugin_path)) == ( + TEST_PLUGIN_NAME + ) + + +def test_load_plugin_metadata_does_not_fallback_to_legacy_info( + tmp_path: Path, +) -> None: + plugin_path = tmp_path / "helloworld" + plugin_path.mkdir() + info_called = False + + class LegacyPlugin: + def info(self): + nonlocal info_called + info_called = True + return { + "name": TEST_PLUGIN_NAME, + "repo": TEST_PLUGIN_REPO, + "version": "1.0.0", + "author": "AstrBot Team", + "desc": "Legacy plugin", + } + + loaded_metadata = PluginManager._load_plugin_metadata( + str(plugin_path), + plugin_obj=LegacyPlugin(), + ) + + assert loaded_metadata is None + assert info_called is False + + +def test_load_plugin_metadata_preserves_validation_error( + tmp_path: Path, +) -> None: + plugin_path = tmp_path / "helloworld" + _write_local_test_plugin(plugin_path, TEST_PLUGIN_REPO) + metadata_path = plugin_path / "metadata.yaml" + metadata = yaml.safe_load(metadata_path.read_text(encoding="utf-8")) + metadata["version"] = "" + metadata_path.write_text(yaml.dump(metadata), encoding="utf-8") + + with pytest.raises(Exception, match="version.*非空字符串"): + PluginManager._load_plugin_metadata(str(plugin_path)) + + def test_loaded_metadata_can_copy_i18n_into_existing_star_metadata(tmp_path: Path): plugin_path = tmp_path / "helloworld" _write_local_test_plugin(plugin_path, TEST_PLUGIN_REPO) @@ -1705,9 +1764,7 @@ async def test_cleanup_plugin_optional_artifacts_clears_kv_when_plugin_id_presen async def clear_preferences(self, scope, scope_id): cleared.append((scope, scope_id)) - monkeypatch.setattr( - plugin_manager_pm.context, "get_db", MockDB, raising=False - ) + monkeypatch.setattr(plugin_manager_pm.context, "get_db", MockDB, raising=False) await plugin_manager_pm._cleanup_plugin_optional_artifacts( root_dir_name="test_plugin", @@ -1730,9 +1787,7 @@ async def test_cleanup_plugin_optional_artifacts_skips_kv_when_plugin_id_none( async def clear_preferences(self, scope, scope_id): cleared.append((scope, scope_id)) - monkeypatch.setattr( - plugin_manager_pm.context, "get_db", MockDB, raising=False - ) + monkeypatch.setattr(plugin_manager_pm.context, "get_db", MockDB, raising=False) await plugin_manager_pm._cleanup_plugin_optional_artifacts( root_dir_name="test_plugin", diff --git a/tests/test_updator_socks.py b/tests/test_updator_socks.py index 7e3dc844f..4cb30430a 100644 --- a/tests/test_updator_socks.py +++ b/tests/test_updator_socks.py @@ -82,7 +82,7 @@ class _FakeStatusErrorResponse: @dataclass class _FakeAsyncClientState: - json_payload: list[dict] = field(default_factory=list) + json_payload: object = field(default_factory=list) stream_payload: bytes = b"" init_kwargs: dict | None = None requested_urls: list[str] = field(default_factory=list) @@ -127,12 +127,23 @@ class _FakeZipArchive: def namelist(self) -> list[str]: return self._names + def read(self, name: str) -> bytes: + if name.endswith(("metadata.yaml", "metadata.yml")): + return ( + b"name: demo\ndesc: Demo plugin\nversion: 1.0.0\nauthor: AstrBot Team\n" + ) + return b"" + def extractall(self, target_dir: str) -> None: # noqa: ARG002 return None def _build_fake_archive_entries(archive_root: str) -> list[str]: - return [archive_root, posixpath.join(archive_root, ".dockerignore")] + return [ + archive_root, + posixpath.join(archive_root, ".dockerignore"), + posixpath.join(archive_root, "metadata.yml"), + ] def _build_fake_archive_entries_with_first_file(root_dir: str) -> list[str]: @@ -290,6 +301,123 @@ async def test_plugin_updator_install_prefers_download_url( assert calls["unzip"] == (str(expected_path) + ".zip", str(expected_path)) +def test_plugin_unzip_file_accepts_metadata_yml(tmp_path: Path) -> None: + zip_path = tmp_path / "plugin.zip" + target_dir = tmp_path / "plugin" + with zipfile.ZipFile(zip_path, "w") as archive: + archive.writestr( + "demo-plugin/metadata.yml", + "\n".join( + [ + "name: demo_plugin", + "desc: Demo plugin", + "version: 1.0.0", + "author: AstrBot Team", + ] + ), + ) + archive.writestr("demo-plugin/main.py", "VALUE = 1\n") + + updater = PluginUpdator.__new__(PluginUpdator) + updater.unzip_file(str(zip_path), str(target_dir)) + + assert (target_dir / "metadata.yml").is_file() + assert (target_dir / "main.py").is_file() + assert not zip_path.exists() + + +def test_plugin_unzip_file_rejects_archive_without_metadata(tmp_path: Path) -> None: + zip_path = tmp_path / "plugin.zip" + target_dir = tmp_path / "plugin" + with zipfile.ZipFile(zip_path, "w") as archive: + archive.writestr("demo-plugin/main.py", "VALUE = 1\n") + + updater = PluginUpdator.__new__(PluginUpdator) + with pytest.raises(ValueError, match="未找到 metadata.yaml 或 metadata.yml"): + updater.unzip_file(str(zip_path), str(target_dir)) + + assert not target_dir.exists() + + +def test_plugin_validate_archive_rejects_incomplete_metadata(tmp_path: Path) -> None: + zip_path = tmp_path / "plugin.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + archive.writestr( + "demo-plugin/metadata.yaml", + "\n".join( + [ + "name: demo_plugin", + "desc: Demo plugin", + "author: AstrBot Team", + ] + ), + ) + archive.writestr("demo-plugin/main.py", "VALUE = 1\n") + + with pytest.raises(ValueError, match="version"): + PluginUpdator.validate_plugin_archive(str(zip_path)) + + +def test_plugin_validate_archive_rejects_empty_metadata_fields( + tmp_path: Path, +) -> None: + zip_path = tmp_path / "plugin.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + archive.writestr( + "demo-plugin/metadata.yaml", + "\n".join( + [ + "name: demo_plugin", + "desc: Demo plugin", + "version: ''", + "author: AstrBot Team", + ] + ), + ) + archive.writestr("demo-plugin/main.py", "VALUE = 1\n") + + with pytest.raises(ValueError, match="version.*非空字符串"): + PluginUpdator.validate_plugin_archive(str(zip_path)) + + +@pytest.mark.asyncio +async def test_plugin_update_validates_archive_before_removing_existing_plugin( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + updater = PluginUpdator.__new__(PluginUpdator) + updater.plugin_store_path = str(tmp_path) + plugin_dir = tmp_path / "demo_plugin" + plugin_dir.mkdir() + marker_path = plugin_dir / "main.py" + marker_path.write_text("VALUE = 'old'\n", encoding="utf-8") + plugin = SimpleNamespace( + name="demo_plugin", + repo="https://github.com/Owner/demo-plugin", + root_dir_name="demo_plugin", + ) + + async def fake_download_from_repo_url( + plugin_path: str, + repo_url: str, + proxy: str = "", + ) -> None: + del repo_url, proxy + with zipfile.ZipFile(plugin_path + ".zip", "w") as archive: + archive.writestr("demo-plugin/main.py", "VALUE = 'new'\n") + + monkeypatch.setattr( + updater, + "download_from_repo_url", + fake_download_from_repo_url, + ) + + with pytest.raises(ValueError, match="未找到 metadata.yaml 或 metadata.yml"): + await updater.update(plugin) + + assert marker_path.read_text(encoding="utf-8") == "VALUE = 'old'\n" + + @pytest.mark.asyncio async def test_astrbot_updator_prefers_hosted_core_package( monkeypatch: pytest.MonkeyPatch, @@ -537,20 +665,8 @@ async def test_download_from_repo_url_uses_httpx_stream_for_zip_download( ) -> None: import astrbot.core.zip_updator as zip_updator_module + fake_async_client_state.json_payload = {"default_branch": "trunk"} fake_async_client_state.stream_payload = b"zip-data" - - async def fake_fetch_release_info(self, url: str, latest: bool = True): # noqa: ARG001 - return [ - { - "version": "AstrBot v4.23.2", - "published_at": "2026-04-16T00:00:00Z", - "body": "fix updater socks proxy support", - "tag_name": "v4.23.2", - "zipball_url": "https://example.com/archive.zip", - } - ] - - monkeypatch.setattr(RepoZipUpdator, "fetch_release_info", fake_fetch_release_info) monkeypatch.setattr( zip_updator_module, "download_file", @@ -575,7 +691,12 @@ async def test_download_from_repo_url_uses_httpx_stream_for_zip_download( ) assert (tmp_path / "AstrBot.zip").read_bytes() == b"zip-data" - assert fake_async_client_state.stream_urls == ["https://example.com/archive.zip"] + assert fake_async_client_state.requested_urls == [ + "https://api.github.com/repos/AstrBotDevs/AstrBot" + ] + assert fake_async_client_state.stream_urls == [ + "https://github.com/AstrBotDevs/AstrBot/archive/refs/heads/trunk.zip" + ] assert fake_async_client_state.init_kwargs is not None assert fake_async_client_state.init_kwargs["follow_redirects"] is True assert fake_async_client_state.init_kwargs["timeout"] == 1800.0 @@ -583,6 +704,39 @@ async def test_download_from_repo_url_uses_httpx_stream_for_zip_download( assert fake_async_client_state.init_kwargs["verify"] == certifi.where() +@pytest.mark.asyncio +async def test_download_from_repo_url_uses_explicit_branch_without_default_branch_lookup( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + updator = RepoZipUpdator() + calls: list[str] = [] + + async def fail_fetch_github_default_branch(author: str, repo: str): # noqa: ARG001 + raise AssertionError("explicit branch should not fetch GitHub default branch") + + async def fake_download_file(url: str, path: str): + calls.append(url) + Path(path).write_bytes(b"zip-data") + + monkeypatch.setattr( + updator, + "fetch_github_default_branch", + fail_fetch_github_default_branch, + ) + monkeypatch.setattr(updator, "_download_file", fake_download_file) + + await updator.download_from_repo_url( + str(tmp_path / "AstrBot"), + "https://github.com/AstrBotDevs/AstrBot/tree/dev", + proxy="https://proxy.example/", + ) + + assert calls == [ + "https://proxy.example/https://github.com/AstrBotDevs/AstrBot/archive/refs/heads/dev.zip" + ] + + def test_create_httpx_client_uses_custom_verify_setting( monkeypatch: pytest.MonkeyPatch, fake_async_client_state: _FakeAsyncClientState,