fix(4.0-P7): 评审修复:插件扫描跳过工具链目录、v3 迁移忽略空旧字段、通用与 LLM 插件互不干扰

This commit is contained in:
Estrella Pan
2026-10-08 15:02:56 +02:00
parent fba05db43e
commit da77f94f0f
12 changed files with 118 additions and 16 deletions

View File

@@ -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`

View File

@@ -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

View File

@@ -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]]:

View File

@@ -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")
)

View File

@@ -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

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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/<id>/ 布局
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 == []

View File

@@ -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):

View File

@@ -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()

View File

@@ -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*")) == []

View File

@@ -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/<id>/` 的两处隔离**: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`。任务清单不含它,推迟到发布阶段前补。