fix(4.0-P6): 嵌套秘密字段掩码、未启用插件 schema 刷新、custom element 归属校验

This commit is contained in:
Estrella Pan
2026-10-08 14:05:56 +02:00
parent 91e01f716f
commit 34d51dd711
10 changed files with 350 additions and 32 deletions

View File

@@ -158,8 +158,9 @@ async def update_config(config: Config, ctx: AppContext = Depends(get_context)):
current = settings.dict()
schemas = _plugin_schemas(ctx)
current_options = current.get("plugins", {}).get("options", {})
for plugin_id, plugin_options in config_dict["plugins"]["options"].items():
restore_options(
options = config_dict["plugins"]["options"]
for plugin_id, plugin_options in options.items():
options[plugin_id] = restore_options(
plugin_options,
current_options.get(plugin_id, {}),
schemas.get(plugin_id),

View File

@@ -17,7 +17,7 @@ from ab_sdk.hooks import HOOK_ATTR, PROVIDER_ATTR, SUBSCRIBE_ATTR
from .bus import EventBus
from .context import PLUGIN_DATA_ROOT, HostPluginContext
from .loader import DiscoveryError, PluginCandidate, PluginLoadError, discover
from .manifest import PluginUi
from .manifest import PluginUi, owns_element
from .registry import ExtensionRegistry, HookEntry, ProviderEntry
from .runner import CircuitBreaker, HookRunner
@@ -171,8 +171,17 @@ class PluginManager:
(plugin_id, ui)
for plugin_id, active in sorted(self._active.items())
for ui in active.candidate.manifest.ui
if self._element_owner(ui.element) == plugin_id
]
def _element_owner(self, element: str) -> str | None:
"""custom element 名的归属:命名空间匹配的插件里 id 最长者。
``foo`` 的命名空间前缀也匹配 ``ab-plugin-foo-bar``,该名字应归 ``foo-bar``。
按全部已发现的插件判定,启停插件不会改变归属。"""
owners = [i for i in self._candidates if owns_element(i, element)]
return max(owners, key=len, default=None)
def web_dir(self, plugin_id: str) -> Path | None:
"""已启用插件的 ``web/`` 目录;未启用或没有该目录时为 None。"""
active = self._active.get(plugin_id)
@@ -200,10 +209,11 @@ class PluginManager:
def _probe_schemas(self) -> None:
"""未加载过的可信插件:只导入代码取 config_model,让设置表单在首次启用
前就可用。未签名插件须先开启 allow_unsigned;导入失败记为无 schema,
真正启用时会再次加载并报告原因。"""
真正启用时会再次加载并报告原因。每次重新发现后都重新取,插件目录升级后
表单随之更新,首次失败也不会一直缓存。"""
conf = self._settings.plugins
for plugin_id, candidate in self._candidates.items():
if plugin_id in self._models or plugin_id in self._active:
if plugin_id in self._active:
continue
if not (candidate.signed or conf.allow_unsigned):
continue
@@ -221,6 +231,14 @@ class PluginManager:
# 清单错误已在 discover() 中记录日志
candidates, _ = self._discover()
self._candidates = {c.manifest.id: c for c in candidates}
for plugin_id, candidate in self._candidates.items():
for ui in candidate.manifest.ui:
if self._element_owner(ui.element) != plugin_id:
logger.warning(
"[Plugin:%s] 元素名 %s 属于更长 id 的插件,挂载点已忽略",
plugin_id,
ui.element,
)
for plugin_id in [p for p in self._failed if p not in self._candidates]:
del self._failed[plugin_id]

View File

@@ -7,7 +7,7 @@ from typing import Literal
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from ab_sdk import SDK_VERSION
@@ -29,6 +29,12 @@ UiSlot = Literal[
]
def owns_element(plugin_id: str, element: str) -> bool:
"""element 是否落在 ``ab-plugin-<id>`` 命名空间内。"""
prefix = f"ab-plugin-{plugin_id}"
return element == prefix or element.startswith(prefix + "-")
class ManifestError(ValueError):
pass
@@ -111,6 +117,18 @@ class PluginManifest(BaseModel):
raise ValueError("entry 须形如 'my_plugin:MyPlugin'")
return value
@model_validator(mode="after")
def _check_ui_elements(self) -> "PluginManifest":
# custom element 名是全局的:限定在 ab-plugin-<id> 命名空间内,
# 一个插件才不会占用别的插件的元素名
for ui in self.ui:
if not owns_element(self.id, ui.element):
raise ValueError(
f"element {ui.element!r} 须为 ab-plugin-{self.id} "
f"或以 ab-plugin-{self.id}- 开头"
)
return self
@property
def entry_module(self) -> str:
return self.entry.split(":", 1)[0]

View File

@@ -3,6 +3,9 @@
插件用 ``ab_sdk.secret_field()`` 声明秘密字段(JSON Schema 中带 ``secret``
标记,或 ``format: password`` / ``writeOnly``)。读接口返回掩码,写接口收到
掩码时还原为已保存的值,与 /config 对密码类字段的处理一致。
秘密字段可以出现在嵌套对象、对象数组和字典值里。数组行没有稳定 id,还原时
先按「掩码后内容相同」把回传行对应到已保存的行,因此删除、调换行不会串用密码。
"""
from typing import Any
@@ -10,39 +13,97 @@ from typing import Any
MASK = "********"
def secret_keys(schema: dict[str, Any] | None) -> set[str]:
props = (schema or {}).get("properties", {})
return {
key
for key, prop in props.items()
if prop.get("secret")
or prop.get("writeOnly")
or prop.get("format") == "password"
}
# 无 schema(插件代码本进程未加载过)时无法区分秘密字段:所有字符串都按秘密处理
_ALL: dict[str, Any] = {"secret": True}
_ALL["additionalProperties"] = _ALL["items"] = _ALL
_MISSING = object()
def _keys(options: dict[str, Any], schema: dict[str, Any] | None) -> set[str]:
# 插件代码本进程未加载过时没有 schema,无法区分秘密字段:全部按秘密处理
return set(options) if schema is None else secret_keys(schema)
def _branches(schema: dict[str, Any], root: dict[str, Any]) -> list[dict[str, Any]]:
"""展开 $ref 与 anyOf / oneOf(如 ``Server | None``)后的候选分支。"""
while "$ref" in schema:
schema = root["$defs"][schema["$ref"].rsplit("/", 1)[-1]]
subs = schema.get("anyOf", []) + schema.get("oneOf", [])
return [b for s in subs for b in _branches(s, root)] if subs else [schema]
def _is_secret(branches: list[dict[str, Any]]) -> bool:
return any(
b.get("secret") or b.get("writeOnly") or b.get("format") == "password"
for b in branches
)
def _child(branches: list[dict[str, Any]], key: str) -> dict[str, Any]:
subs = [
b.get("properties", {}).get(key, b.get("additionalProperties"))
for b in branches
]
return {"anyOf": [s for s in subs if isinstance(s, dict)]}
def _items(branches: list[dict[str, Any]]) -> dict[str, Any]:
subs = [b.get("items") for b in branches]
return {"anyOf": [s for s in subs if isinstance(s, dict)]}
def _mask(value: Any, schema: dict[str, Any], root: dict[str, Any]) -> Any:
branches = _branches(schema, root)
if isinstance(value, str):
return MASK if value and _is_secret(branches) else value
if isinstance(value, dict):
return {k: _mask(v, _child(branches, k), root) for k, v in value.items()}
if isinstance(value, list):
return [_mask(v, _items(branches), root) for v in value]
return value
def _restore(
value: Any, current: Any, schema: dict[str, Any], root: dict[str, Any]
) -> Any:
"""返回还原后的值;掩码值没有可还原的已保存值时返回 ``_MISSING``(丢弃)。"""
branches = _branches(schema, root)
if isinstance(value, str):
if value == MASK and _is_secret(branches):
return current if isinstance(current, str) else _MISSING
return value
if isinstance(value, dict):
saved = current if isinstance(current, dict) else {}
restored = {
k: _restore(v, saved.get(k), _child(branches, k), root)
for k, v in value.items()
}
return {k: v for k, v in restored.items() if v is not _MISSING}
if isinstance(value, list):
saved_rows = current if isinstance(current, list) else []
item = _items(branches)
masked = [_mask(c, item, root) for c in saved_rows]
rows: list[Any] = []
for i, row in enumerate(value):
if i < len(masked) and masked[i] == row:
j = i
elif row in masked:
j = masked.index(row)
elif len(value) == len(saved_rows):
j = i # 行内容也改过且行数未变:按位置对应
else:
j = -1 # 无法确定对应行:宁可丢弃掩码,不串用别行的密码
r = _restore(row, saved_rows[j] if j >= 0 else None, item, root)
if r is not _MISSING:
rows.append(r)
return rows
return value
def mask_options(
options: dict[str, Any], schema: dict[str, Any] | None
) -> dict[str, Any]:
masked = dict(options)
for key in _keys(options, schema):
if isinstance(masked.get(key), str) and masked[key]:
masked[key] = MASK
return masked
return _mask(options, _ALL if schema is None else schema, schema or _ALL)
def restore_options(
incoming: dict[str, Any], current: dict[str, Any], schema: dict[str, Any] | None
) -> dict[str, Any]:
for key in _keys(incoming, schema):
if incoming.get(key) == MASK:
if key in current:
incoming[key] = current[key]
else:
incoming.pop(key)
return incoming
return _restore(
incoming, current, _ALL if schema is None else schema, schema or _ALL
)

View File

@@ -454,6 +454,91 @@ class TestSecrets:
assert restored == {"site": "a", "cookie": "c=1", "n": 3}
class Server(BaseModel):
host: str = ""
password: str = secret_field()
class NestedOptions(BaseModel):
servers: list[Server] = []
primary: Server | None = None
by_name: dict[str, Server] = {}
NESTED = NestedOptions.model_json_schema()
class TestNestedSecrets:
def test_mask_options_nested_secrets_masked(self):
options = {
"servers": [{"host": "a", "password": "p1"}, {"host": "b", "password": ""}],
"primary": {"host": "c", "password": "p3"},
"by_name": {"x": {"host": "d", "password": "p4"}},
}
assert mask_options(options, NESTED) == {
"servers": [
{"host": "a", "password": MASK},
{"host": "b", "password": ""},
],
"primary": {"host": "c", "password": MASK},
"by_name": {"x": {"host": "d", "password": MASK}},
}
def test_mask_options_nested_without_schema_masks_all_strings(self):
options = {"servers": [{"host": "a", "n": 1}], "k": {"v": "s"}}
assert mask_options(options, None) == {
"servers": [{"host": MASK, "n": 1}],
"k": {"v": MASK},
}
@pytest.mark.parametrize(
"incoming, expected",
[
# 原样回传:每行各自还原
(
[("a", MASK), ("b", MASK)],
[("a", "p1"), ("b", "p2")],
),
# 删除第一行:第二行仍取回自己的密码
([("b", MASK)], [("b", "p2")]),
# 调换顺序
([("b", MASK), ("a", MASK)], [("b", "p2"), ("a", "p1")]),
# 新增行带明文;修改过的密码不被还原
([("a", MASK), ("c", "new")], [("a", "p1"), ("c", "new")]),
# 行数未变、第二行改了主机:按位置还原
([("a", MASK), ("c", MASK)], [("a", "p1"), ("c", "p2")]),
# 行数变了又改过内容:无法对应,丢弃该字段而不是取别行的密码
([("c", MASK)], [("c", None)]),
],
)
def test_restore_options_list_rows_restored_by_content(self, incoming, expected):
current = {
"servers": [
{"host": "a", "password": "p1"},
{"host": "b", "password": "p2"},
]
}
body = {"servers": [{"host": h, "password": p} for h, p in incoming]}
restored = restore_options(body, current, NESTED)
assert restored["servers"] == [
{"host": h} if p is None else {"host": h, "password": p}
for h, p in expected
]
def test_restore_options_nested_object_and_map_restored(self):
current = {
"primary": {"host": "c", "password": "p3"},
"by_name": {"x": {"host": "d", "password": "p4"}},
}
masked = mask_options(current, NESTED)
assert restore_options(masked, current, NESTED) == current
def test_restore_options_nested_without_schema_restored(self):
current = {"servers": [{"host": "a", "n": 1}]}
masked = mask_options(current, None)
assert restore_options(masked, current, None) == current
# ---------------------------------------------------------------- API

View File

@@ -105,7 +105,9 @@ class TestManifest:
parse_manifest(text)
def test_ui_entries_parsed(self):
m = parse_manifest(MANIFEST.format(id="ok", sdk=">=0.1", entry="a:B") + UI)
m = parse_manifest(
MANIFEST.format(id="manual-pick", sdk=">=0.1", entry="a:B") + UI
)
[ui] = m.ui
assert (ui.slot, ui.element, ui.entry) == (
"bangumi.detail.tab",
@@ -131,10 +133,34 @@ class TestManifest:
],
)
def test_invalid_ui_entry(self, old, new, message):
text = MANIFEST.format(id="ok", sdk=">=0.1", entry="a:B") + UI.replace(old, new)
text = MANIFEST.format(id="manual-pick", sdk=">=0.1", entry="a:B") + UI.replace(
old, new
)
with pytest.raises(ManifestError, match=message):
parse_manifest(text)
@pytest.mark.parametrize(
"plugin_id, element, valid",
[
("manual-pick", "ab-plugin-manual-pick", True),
("manual-pick", "ab-plugin-manual-pick-panel", True),
# 元素名须落在本插件 id 的命名空间内,不能占用别的插件的名字
("other", "ab-plugin-manual-pick", False),
("manual", "ab-plugin-manualpick", False),
],
)
def test_ui_element_must_be_namespaced_by_plugin_id(
self, plugin_id, element, valid
):
text = MANIFEST.format(id=plugin_id, sdk=">=0.1", entry="a:B") + UI.replace(
"ab-plugin-manual-pick", element
)
if valid:
assert parse_manifest(text).ui[0].element == element
else:
with pytest.raises(ManifestError, match=f"ab-plugin-{plugin_id}"):
parse_manifest(text)
# ---------------------------------------------------------------- registry
@@ -673,6 +699,33 @@ class TestManager:
with pytest.raises(ValidationError):
manager.validate_options("demo", {"min_size": "x"})
@pytest.mark.parametrize("first_probe_fails", [False, True])
async def test_disabled_plugin_schema_refreshed_on_reload(
self, tmp_path, first_probe_fails
):
from pydantic import BaseModel
class NewOptions(BaseModel):
level: int = 1
def broken():
raise RuntimeError("boom")
old = build_plugin(Recorder())
candidates = [candidate_for(old, default_enabled=False)]
if first_probe_fails:
candidates[0].load = broken # type: ignore[method-assign]
manager, _ = make_manager(*candidates, tmp_path=tmp_path)
await manager.start()
assert (manager.statuses()[0].config_schema is None) == first_probe_fails
# 升级插件目录后重新发现:换成 config_model 不同的新类
new = type("NewPlugin", (Plugin,), {"config_model": NewOptions})
manager._discover = lambda: ([candidate_for(new, default_enabled=False)], [])
await manager.apply_settings()
[status] = manager.statuses()
assert list(status.config_schema["properties"]) == ["level"] # type: ignore[index]
@pytest.mark.parametrize(
"conf, sdk, message",
[

View File

@@ -92,6 +92,22 @@ class TestManager:
assert manager.ui_slots() == []
assert manager.web_dir("demo") is None
async def test_ui_slots_element_owned_by_longest_matching_plugin_id(self, tmp_path):
# 「foo」的命名空间前缀也匹配 ab-plugin-foo-bar,但该名字属于更长的 id「foo-bar」
foo = candidate(tmp_path, "foo")
foo.manifest.ui[0].element = "ab-plugin-foo-bar"
foo_bar = candidate(tmp_path, "foo-bar")
mgr = PluginManager(
SimpleNamespace(plugins=Plugins()),
discover_fn=lambda: ([foo, foo_bar], []),
data_root=tmp_path / "data",
)
await mgr.start()
assert [(pid, ui.element) for pid, ui in mgr.ui_slots()] == [
("foo-bar", "ab-plugin-foo-bar")
]
await mgr.stop()
class TestApi:
def test_list_ui_slots(self, client):

View File

@@ -187,6 +187,14 @@ slots 解析:
- **验证**:除 vitest / pytest 外,用 `vite preview`(带 CSP)加真实后端、系统 Chrome 无头跑过:详情页标签列出种子并选用、选择经 SSE `bus` 帧刷新、设置页补链按钮返回计数、`page` / `bangumi.card.action` 挂载点、崩溃与未定义元素显示失败提示,页面未报 CSP 违例。
- 踩坑:UnoCSS 的 attributify 会把源码里的 `` `[plugin:${id}]` `` 当成样式规则并让构建失败;`plugin-slot.vue` 里的日志不要用这种写法。
### 实施中的调整(P6 评审修复)
- **秘密字段掩码覆盖嵌套结构**(`module/plugin/secrets.py`):`mask_options` / `restore_options` 沿 JSON Schema 递归(`$ref`、`anyOf` / `oneOf`、对象、数组 `items`、字典 `additionalProperties`),对象数组行里的 `secret_field()` 也会掩码。无 schema 时仍把全部字符串按秘密处理,现在同样递归。`secret_keys()` 无其它调用方,已删除;`/config/update` 改为使用返回值。
- **数组行没有稳定 id,还原按「掩码后内容相同」对应已保存的行**:删除、调换行不会串用密码。行内容也被改过时,行数未变则按位置对应;行数已变则无法确定,丢弃该掩码字段(密码留空,由插件的校验或用户重新输入处理),不取别行的密码。
- **未启用插件的 schema 每次重新发现后重新读取**(`_probe_schemas`):插件目录升级后表单随之更新,首次导入失败也不再一直缓存为「无 schema」。代价是每次 `apply_settings` 会重新导入未启用的可信插件一次。
- **custom element 名归插件所有**:清单要求 `element` 为 `ab-plugin-<id>` 或以 `ab-plugin-<id>-` 开头。id 含连字符时命名空间会重叠(`foo` 的前缀也匹配 `ab-plugin-foo-bar`),`PluginManager.ui_slots()` 按全部已发现插件取 id 最长者为归属,其余声明被忽略并在重新发现时写警告日志。
- **前端按定义者校验元素**(`services/plugin-loader.ts`):加载器包装 `customElements.define`,按调用栈里最近的 `/plugins/<id>/web/` 脚本地址记录每个名字由哪个插件定义;挂载点发现元素由其它插件定义时拒绝实例化并显示失败提示。这样插件即使在自己的模块里抢先定义别人的元素名,也拿不到别人挂载点的 `host`。归因依赖浏览器调用栈里带脚本地址(三大引擎都满足);不经插件脚本定义的元素(定义者未知)不拦截。
## 1. 背景与目标
AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。

View File

@@ -70,6 +70,39 @@ describe('loadPluginElement', () => {
});
});
/** 让 define 调用的调用栈带上插件脚本地址,模拟插件模块顶层代码 */
function defineFrom(pluginId: string, name: string, guarded = false) {
const body = `
if (!${guarded} || !customElements.get(name)) {
customElements.define(name, class extends HTMLElement {});
}
//# sourceURL=http://ab.local/api/v1/plugins/${pluginId}/web/index.js`;
// eslint-disable-next-line no-new-func
new Function('name', body)(name);
}
describe('element ownership', () => {
it('should reject an element that another plugin defined first', async () => {
const slot = makeSlot();
// 别的插件抢先定义了本插件声明的元素名,本插件自己的 define 被守卫跳过
const importer = async () => {
defineFrom('squatter', slot.element);
defineFrom(slot.plugin_id, slot.element, true);
};
await expect(loadPluginElement(slot, importer)).rejects.toThrow(
/defined by plugin squatter/
);
});
it('should accept an element that the plugin defined itself', async () => {
const slot = makeSlot();
await loadPluginElement(slot, async () =>
defineFrom(slot.plugin_id, slot.element)
);
});
});
describe('watchPluginErrors', () => {
const stops: Array<() => void> = [];
afterEach(() => stops.splice(0).forEach((stop) => stop()));

View File

@@ -12,6 +12,24 @@ export function moduleUrl(ui: Pick<PluginUiSlot, 'plugin_id' | 'entry'>) {
).href;
}
// custom element 名是文档全局的。记录每个名字由哪个插件的脚本定义,
// 挂载点只接受本插件自己定义的元素,其它插件抢先定义(或守卫跳过)的不用。
const owners = new Map<string, string>();
const PLUGIN_FRAME = /\/plugins\/([^/]+)\/web\//;
let tracking = false;
// 须在任何插件代码运行前安装;按调用栈里最近的插件脚本地址判定定义者
function trackDefinitions() {
if (tracking) return;
tracking = true;
const define = customElements.define.bind(customElements);
customElements.define = (name, ctor, options) => {
define(name, ctor, options);
const frame = new Error(name).stack?.match(PLUGIN_FRAME);
if (frame) owners.set(name, decodeURIComponent(frame[1]));
};
}
/**
* 导入挂载点所在的模块并确认 custom element 已定义。同一模块只导入一次,
* 失败不缓存(插件修复后重新打开页面即可重试)。
@@ -20,6 +38,7 @@ export async function loadPluginElement(
ui: PluginUiSlot,
importer: Importer = (url) => import(/* @vite-ignore */ url)
): Promise<void> {
trackDefinitions();
const url = moduleUrl(ui);
let pending = loading.get(url);
if (!pending) {
@@ -28,6 +47,12 @@ export async function loadPluginElement(
pending.catch(() => loading.delete(url));
}
await pending;
const owner = owners.get(ui.element);
if (owner && owner !== ui.plugin_id) {
throw new Error(
`custom element <${ui.element}> is defined by plugin ${owner}`
);
}
if (!customElements.get(ui.element)) {
throw new Error(`custom element <${ui.element}> is not defined`);
}