From da77f94f0ffdb72d1e54e7afda9126d5c7f8af89 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 8 Oct 2026 15:02:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(4.0-P7):=20=E8=AF=84=E5=AE=A1=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=EF=BC=9A=E6=8F=92=E4=BB=B6=E6=89=AB=E6=8F=8F=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=E5=B7=A5=E5=85=B7=E9=93=BE=E7=9B=AE=E5=BD=95=E3=80=81?= =?UTF-8?q?v3=20=E8=BF=81=E7=A7=BB=E5=BF=BD=E7=95=A5=E7=A9=BA=E6=97=A7?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E3=80=81=E9=80=9A=E7=94=A8=E4=B8=8E=20LLM=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E4=BA=92=E4=B8=8D=E5=B9=B2=E6=89=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 ++ backend/src/ab_sdk/cli.py | 6 ++-- backend/src/ab_sdk/manifest.py | 28 +++++++++++++++---- .../parser/analyser/providers/registry.py | 3 ++ backend/src/module/plugin/installer.py | 13 ++++++--- backend/src/module/plugin/manager.py | 3 +- backend/src/module/update/v4.py | 3 +- .../src/test/test_llm_providers_registry.py | 15 +++++++++- backend/src/test/test_plugin_catalog.py | 12 ++++++++ backend/src/test/test_plugin_cli.py | 21 ++++++++++++++ backend/src/test/test_update_v4.py | 20 +++++++++++++ ...26-10-08-plugin-architecture-4.0-design.md | 7 +++++ 12 files changed, 118 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc4f449f..3d834b27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ - **破坏性变更(通知渠道)**:Bark 不再读取旧字段 `token`(改用 `device_key`),WeCom 不再读取 `chat_id`(改用 `webhook_url`)。升级后第一次启动时自动把旧字段迁移到新字段,原文件备份为 `config.json.v3.bak` - **新增(插件文档与示例)**:插件开发文档拆为总览、核心概念、配置表单、事件、前端挂载点、命令行、签名与分发、内置插件、示例和每个扩展点一页(中 / 英 / 日)。`examples/plugins/` 新增 `webhook-on-event`、`custom-rss-site`、`template-rename`、`nfo-writer`、`ntfy-notifier`,CI 逐个校验并运行它们的测试。新增插件作者 skill(`skills/autobangumi-plugin/`) - **新增(发布)**:beta 与稳定版的 GitHub Release 附带 `autobangumi-sdk` 轮子和 `autobangumi-plugin-skill-<版本>.zip` +- **修复(插件开发工具)**:插件目录里的 `.venv`、`node_modules` 等工具链目录不再被当作原生扩展(`ab-plugin validate` / `pack` / `dev` 和宿主加载此前会报「含原生扩展」),`dev_mode` 也不再监听它们 +- **修复(配置迁移)**:配置里有 Bark 或 WeCom 渠道时,每次启动都会新增一份 `config.json.v3.bak.N` 并改写配置;现在只迁移带值的旧字段 +- **修复(插件安装)**:`DELETE /api/v1/plugins/{id}` 不再能卸载 LLM 提供商插件;安装通用插件后,LLM 插件列举不再反复输出 `Skipping broken plugin` 警告 - **破坏性变更(升级路径)**:只支持从 3.3.x 升级。检测到更早版本的数据(`config/version.info` 低于 3.3,或残留 2.x 的 `data/data.json`)时拒绝启动,并提示先升级到最新 3.3.x 启动一次完成迁移 - **破坏性变更(API)**:移除 3.2 兼容的 GET 控制端点(`/api/v1/restart`、`/start`、`/stop`、`/shutdown`)和 `GET /api/v1/auth/refresh_token`,请改用 POST - **破坏性变更(配置)**:移除旧版 `experimental_openai` 配置节和通知的单 provider 旧字段(`type` / `token` / `chat_id`),3.3 已把它们迁移到 `llm` 与 `notification.providers`;移除废弃的 `normal` 重命名方式,已有配置自动改为语义相同的 `none` diff --git a/backend/src/ab_sdk/cli.py b/backend/src/ab_sdk/cli.py index d7e57ee2..9a19e663 100644 --- a/backend/src/ab_sdk/cli.py +++ b/backend/src/ab_sdk/cli.py @@ -13,12 +13,10 @@ from pathlib import Path from string import Template from . import SDK_VERSION, points -from .manifest import MANIFEST_NAME, PluginManifest, check +from .manifest import MANIFEST_NAME, TOOLING_DIRS, PluginManifest, check # 打包时排除的目录名与文件后缀:开发产物、测试与作者侧的工程文件 -_PACK_EXCLUDE_DIRS = frozenset( - {"__pycache__", ".git", ".venv", "dist", "tests", ".pytest_cache", ".ruff_cache"} -) +_PACK_EXCLUDE_DIRS = TOOLING_DIRS | {"tests"} _PACK_EXCLUDE_FILES = frozenset({".DS_Store", "pyproject.toml", "uv.lock"}) _ZIP_EPOCH = (1980, 1, 1, 0, 0, 0) # 固定时间戳,同样的内容打出同样的 sha256 diff --git a/backend/src/ab_sdk/manifest.py b/backend/src/ab_sdk/manifest.py index bf63c32e..2ba7bc42 100644 --- a/backend/src/ab_sdk/manifest.py +++ b/backend/src/ab_sdk/manifest.py @@ -3,6 +3,7 @@ 宿主加载器与 ``ab-plugin`` 命令行共用,所以放在 SDK 里:插件作者不装宿主也能校验。 """ +import os import re import tomllib from pathlib import Path, PurePosixPath @@ -17,6 +18,19 @@ from . import SDK_VERSION MANIFEST_NAME = "plugin.toml" # 只允许纯 Python 代码(设计文档第 2.4 节):带这些后缀的文件一律拒绝 NATIVE_SUFFIXES = (".so", ".pyd", ".dylib", ".dll") +# 工具链产生的目录(虚拟环境、缓存、构建产物):不属于插件代码,扫描与打包都跳过 +TOOLING_DIRS = frozenset( + { + "__pycache__", + ".git", + ".venv", + "dist", + "node_modules", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", + } +) ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") _ENTRY_RE = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_]\w*$") # 合法的 custom element 名(小写、含连字符),统一以 ab-plugin- 开头避免与宿主冲突 @@ -179,11 +193,15 @@ def load_manifest(plugin_dir: Path) -> PluginManifest: def native_files(plugin_dir: Path) -> list[Path]: """插件目录中的原生扩展文件(相对路径)。""" - return [ - p.relative_to(plugin_dir) - for p in sorted(plugin_dir.rglob("*")) - if p.suffix in NATIVE_SUFFIXES - ] + found = [] + for root, dirs, names in os.walk(plugin_dir): + dirs[:] = sorted(d for d in dirs if d not in TOOLING_DIRS) + found += [ + (Path(root) / n).relative_to(plugin_dir) + for n in names + if n.endswith(NATIVE_SUFFIXES) + ] + return sorted(found) def check(plugin_dir: Path) -> tuple[PluginManifest | None, list[str]]: diff --git a/backend/src/module/parser/analyser/providers/registry.py b/backend/src/module/parser/analyser/providers/registry.py index 9f55d6f3..61b6c614 100644 --- a/backend/src/module/parser/analyser/providers/registry.py +++ b/backend/src/module/parser/analyser/providers/registry.py @@ -50,6 +50,9 @@ class ProviderRegistry: try: version = json.loads(pointer.read_text(encoding="utf-8"))["version"] version_dir = plugin_dir / version + # 通用插件(plugin.toml)与 LLM 插件共用此目录布局,不属于这里 + if (version_dir / "plugin.toml").exists(): + continue manifest = json.loads( (version_dir / "plugin.json").read_text(encoding="utf-8") ) diff --git a/backend/src/module/plugin/installer.py b/backend/src/module/plugin/installer.py index 2492af2f..4b165aad 100644 --- a/backend/src/module/plugin/installer.py +++ b/backend/src/module/plugin/installer.py @@ -28,8 +28,8 @@ from typing import Any, ClassVar, Optional import httpx import semver -from ab_sdk.manifest import ID_RE, RESERVED_IDS, check -from module.plugin.loader import BUILTIN_ROOT, INSTALLED_FILE +from ab_sdk.manifest import ID_RE, MANIFEST_NAME, RESERVED_IDS, check +from module.plugin.loader import BUILTIN_ROOT, INSTALLED_FILE, installed_version from module.update.signing import DEFAULT_PUBKEY_PATH, verify_bundle_signature logger = logging.getLogger(__name__) @@ -258,8 +258,13 @@ class PluginInstaller(SignedCatalogInstaller): return None def _uninstall_reject_reason(self, plugin_id: str) -> Optional[str]: - # 只删经安装器装入的目录,绝不碰 config/plugins/ 下的其它内容 - if not (self.root / plugin_id / INSTALLED_FILE).is_file(): + # 只删经本安装器装入的目录(installed.json 指向含 plugin.toml 的版本目录), + # 绝不碰 config/plugins/ 下的其它内容,包括同样带 installed.json 的 LLM 插件 + version = installed_version(self.root, plugin_id) + if ( + not version + or not (self.root / plugin_id / version / MANIFEST_NAME).is_file() + ): return f"Plugin not installed: {plugin_id}" return None diff --git a/backend/src/module/plugin/manager.py b/backend/src/module/plugin/manager.py index dc10df53..73054a00 100644 --- a/backend/src/module/plugin/manager.py +++ b/backend/src/module/plugin/manager.py @@ -14,6 +14,7 @@ from pydantic import BaseModel from ab_sdk import SDK_VERSION, Plugin, PluginDisabled, PluginLoaded from ab_sdk.hooks import HOOK_ATTR, PROVIDER_ATTR, SUBSCRIBE_ATTR +from ab_sdk.manifest import TOOLING_DIRS from .bus import EventBus from .context import PLUGIN_DATA_ROOT, HostPluginContext @@ -37,7 +38,7 @@ def _fingerprint(root: Path) -> Fingerprint: """目录下所有文件的(相对路径、修改时间、大小),用于判断插件源码是否变过。""" stamps = [] for dirpath, dirs, names in os.walk(root): - dirs[:] = [d for d in dirs if d != "__pycache__"] + dirs[:] = [d for d in dirs if d not in TOOLING_DIRS] for name in names: path = Path(dirpath, name) try: diff --git a/backend/src/module/update/v4.py b/backend/src/module/update/v4.py index 1cda4003..527864f3 100644 --- a/backend/src/module/update/v4.py +++ b/backend/src/module/update/v4.py @@ -71,7 +71,8 @@ def _migrate_provider_aliases(config: dict[str, Any]) -> list[str]: old, new = _PROVIDER_ALIASES.get( str(provider.get("type", "")).lower(), ("", "") ) - if old in provider: + # Settings.save() 总是把旧字段以 null 写回,只有带值的才是 3.3 遗留 + if provider.get(old): legacy = provider.pop(old) provider[new] = provider.get(new) or legacy moved.append(f"notification.providers[{i}].{old}") diff --git a/backend/src/test/test_llm_providers_registry.py b/backend/src/test/test_llm_providers_registry.py index 12afbc14..dadc8df8 100644 --- a/backend/src/test/test_llm_providers_registry.py +++ b/backend/src/test/test_llm_providers_registry.py @@ -2,7 +2,7 @@ import pytest -from module.parser.analyser.providers import registry +from module.parser.analyser.providers import ProviderRegistry, registry from module.parser.analyser.providers.base import LLMProviderAdapter @@ -30,3 +30,16 @@ class TestListInfos: infos = {info.id: info for info in registry.list_infos()} assert infos["openai"].needs_base_url is True assert infos["anthropic"].needs_base_url is False + + +def test_installed_manifests_skips_generic_plugin_dirs_silently(tmp_path, caplog): + # 通用插件(plugin.toml)与 LLM 插件共用 config/plugins// 布局 + version_dir = tmp_path / "generic" / "1.0.0" + version_dir.mkdir(parents=True) + (version_dir / "plugin.toml").write_text("[plugin]\n") + (tmp_path / "generic" / "installed.json").write_text('{"version": "1.0.0"}') + + with caplog.at_level("WARNING"): + found = ProviderRegistry(plugins_root=tmp_path)._installed_manifests() + + assert found == {} and caplog.records == [] diff --git a/backend/src/test/test_plugin_catalog.py b/backend/src/test/test_plugin_catalog.py index 7c696c25..ccb4a2a2 100644 --- a/backend/src/test/test_plugin_catalog.py +++ b/backend/src/test/test_plugin_catalog.py @@ -227,6 +227,18 @@ class TestInstall: assert not result.success and unmanaged.is_dir() + async def test_uninstall_leaves_llm_provider_plugins_alone(self, tmp_path, keypair): + installer = make_installer(tmp_path, keypair, build_zip(tmp_path)) + # LLM 插件:有 installed.json 与 plugin.json,没有 plugin.toml + llm_dir = tmp_path / "plugins" / "github-copilot" + (llm_dir / "1.0.0").mkdir(parents=True) + (llm_dir / "1.0.0" / "plugin.json").write_text("{}") + (llm_dir / "installed.json").write_text('{"version": "1.0.0"}') + + result = await installer.uninstall("github-copilot") + + assert not result.success and (llm_dir / "installed.json").is_file() + class TestReleaseScript: async def test_catalog_built_by_release_script_installs(self, tmp_path, keypair): diff --git a/backend/src/test/test_plugin_cli.py b/backend/src/test/test_plugin_cli.py index 44daf23c..46c13c60 100644 --- a/backend/src/test/test_plugin_cli.py +++ b/backend/src/test/test_plugin_cli.py @@ -69,6 +69,27 @@ def test_validate_broken_plugin_fails(tmp_path, capsys, problem): assert "错误" in capsys.readouterr().err +@pytest.mark.parametrize("tooling_dir", [".venv", "node_modules", ".mypy_cache"]) +def test_validate_ignores_native_files_in_tooling_dirs(tmp_path, tooling_dir): + plugin_dir = _new(tmp_path) + native = plugin_dir / tooling_dir / "lib" / "_core.so" + native.parent.mkdir(parents=True) + native.write_bytes(b"") + + assert main(["validate", str(plugin_dir)]) == 0 + + +def test_fingerprint_ignores_tooling_dirs(tmp_path): + from module.plugin.manager import _fingerprint + + plugin_dir = _new(tmp_path) + before = _fingerprint(plugin_dir) + (plugin_dir / ".venv").mkdir() + (plugin_dir / ".venv" / "x.py").write_text("x") + + assert _fingerprint(plugin_dir) == before + + def test_pack_puts_contents_at_zip_root_and_is_reproducible(tmp_path): plugin_dir = _new(tmp_path) (plugin_dir / "my_plugin" / "__pycache__").mkdir() diff --git a/backend/src/test/test_update_v4.py b/backend/src/test/test_update_v4.py index 950e7267..845773c7 100644 --- a/backend/src/test/test_update_v4.py +++ b/backend/src/test/test_update_v4.py @@ -197,3 +197,23 @@ def test_migrate_v3_config_rewrites_file_with_legacy_bark_token(tmp_path): saved = json.loads(path.read_text()) assert saved["notification"]["providers"] == [{"type": "bark", "device_key": "k"}] assert path.with_name("config.json.v3.bak").exists() + + +@pytest.mark.parametrize( + "provider", + [ + # Settings.save() 总是把渠道的旧字段以 null 写回,不能当作 3.3 配置 + {"type": "bark", "token": None, "device_key": "k"}, + {"type": "wecom", "chat_id": None, "webhook_url": "https://hook"}, + {"type": "bark", "token": "", "device_key": ""}, + ], +) +def test_migrate_v3_config_empty_legacy_alias_is_noop(tmp_path, provider): + path = tmp_path / "config.json" + path.write_text(json.dumps({"notification": {"providers": [provider]}})) + before = path.read_bytes() + + assert migrate_v3_config(path) is False + + assert path.read_bytes() == before + assert list(tmp_path.glob("config.json.v3.bak*")) == [] diff --git a/docs/plans/2026-10-08-plugin-architecture-4.0-design.md b/docs/plans/2026-10-08-plugin-architecture-4.0-design.md index e8517315..6065b478 100644 --- a/docs/plans/2026-10-08-plugin-architecture-4.0-design.md +++ b/docs/plans/2026-10-08-plugin-architecture-4.0-design.md @@ -260,6 +260,13 @@ bark / wecom 旧字段别名: - 校验:`uvx check-jsonschema --builtin-schema vendor.github-workflows` 通过;`actionlint` 只报告已有步骤的 SC2086 提示(本次改动之前就存在)。 - **未验证**:轮子构建与 release 上传只在真实 tag 推送时才会被 CI 跑到;本地只验证了 `uv build --wheel backend/sdk`(第一部分)、YAML 语法与上面的测试。 +### 实施中的调整(P7 评审修复) + +- **原生扩展扫描与文件监听跳过工具链目录**:`native_files()` 原先遍历整个插件目录,`uv run pytest` 在插件目录里建出的 `.venv`(含 `pydantic_core` 的 `.so`)会让 `validate` / `pack` / `dev` 和宿主加载器都报「含原生扩展」,`dev_mode` 的指纹轮询也每秒遍历 `.venv`。`ab_sdk.manifest.TOOLING_DIRS`(`__pycache__`、`.git`、`.venv`、`dist`、`node_modules`、各类缓存)现在由原生扩展扫描、`pack` 与文件指纹共用;`pack` 仍额外排除 `tests`。 +- **v3 迁移不再把 `null` 当作旧字段**:`Settings.save()` 总是把 Bark 的 `token`、WeCom 的 `chat_id` 以 `null` 写回,`old in provider` 因此每次启动都成立,每次启动都新增 `config.json.v3.bak.N` 并改写配置。现在只在旧字段有值时迁移。 +- **通用插件与 LLM 插件共用 `config/plugins//` 的两处隔离**:LLM 注册表扫描跳过版本目录里是 `plugin.toml` 的目录(原先每次列举都对它打 `Skipping broken plugin` 警告);通用安装器卸载时要求 `installed.json` 指向的版本目录含 `plugin.toml`,不能再通过 `DELETE /plugins/{id}` 删掉 LLM 插件(其凭据清理在 LLM 安装器里,不会被跳过)。 +- **未修:在线更新 bundle 不含 `ab_sdk`**。`boot_overlay.py` 是镜像自带的稳定脚本,只把 bundle 里的 `backend/src/module` 换进 `/app/module`;只在 `build.yml` 里多拷一份 `ab_sdk` 不会被旧镜像应用,要让 bundle 能更新 SDK 必须改覆盖层的应用方式。选项见 PR 说明,留作后续决定。 + 未做(第一部分已列出,本部分也没有做): - **插件管理页**:设置 → 插件页没有目录浏览、安装 / 卸载按钮,也没有 `dev_mode` 提示;`GET /plugins` 仍不返回 `dev_mode`。任务清单不含它,推迟到发布阶段前补。