fix(config): 插件 def 查询不再越界 —— ListDefs 作用域 + 新增 ListCoreDefs

两个方向相反的越界,合起来把 WebUI 设置接口的 meta 撑成 5208 条(96% 重复):

1) PluginSettings.ListDefs(prefix) 把 prefix 直接透传给全局 ListDefs,
   等于「返回全仓所有 def」——调用方以为在问某个插件,实际拿到全部。
   修复:限定到 plugin.<name>. 命名空间,并把 Key 剥回插件内局部键
   (调用方看到的键必须与 Set/Get/ListPlugin 的局部键一致)。

2) DefsCore(prefix) → reg.ListDefs(prefix) 会连插件 def 一起返回,
   于是 meta 里出现 plugin.<name>.<key> 的「核心侧副本」。
   修复:新增 ConfigRegistry.ListCoreDefs,显式排除 plugin.* 命名空间。

生产实例实测(旧代码):GET /api/v1/settings 的 meta = 5208 条,
其中 core.agent.* 等每个 def 都被复制 28 份(每个插件命名空间一份),
并派生出 plugin.<a>.plugin.<b>.<key> 这类幻影键。

⚠️ 幻影键不只是脏数据:设置接口的 PUT 走 SplitN(key, ".", 3),
对 plugin.<a>.plugin.<b>.<key> 会解出 (a, "plugin.<b>.<key>"),
即按 UI 上的幻影条目保存会**写进错误插件的配置表**。

新增 TestPluginDefsAreNamespaced 钉住两条作用域。
This commit is contained in:
HomeAgent Agent
2026-09-14 06:53:58 +08:00
parent 3edab0fe68
commit 9e6627f0c3
3 changed files with 108 additions and 2 deletions

View File

@ -246,6 +246,12 @@ func (r *ConfigRegistry) defsLockedRegisterSource(name string) {
}
}
// pluginDefPrefix 是插件级配置定义在全局 def 表里的命名空间前缀。
const pluginDefPrefix = "plugin."
// ListDefs 返回全局 def 表中匹配前缀的定义(含插件命名空间)。
// 需要「只看核心」时用 ListCoreDefs需要「只看某个插件」时用
// PluginConfig(name).ListDefs。
func (r *ConfigRegistry) ListDefs(prefix string) []*ConfigDef {
r.mu.RLock()
defer r.mu.RUnlock()
@ -259,6 +265,27 @@ func (r *ConfigRegistry) ListDefs(prefix string) []*ConfigDef {
return result
}
// ListCoreDefs 返回**核心命名空间**(非 plugin.*)下匹配前缀的定义。
//
// 插件 def 注册时被限定到 "plugin.<name>.",所以这里必须显式排除;
// 否则 DefsCore("") 会把插件 def 一并当成核心 def 返回WebUI 设置页的 meta
// 里就会出现 plugin.<name>.<key> 的「核心侧副本」。
func (r *ConfigRegistry) ListCoreDefs(prefix string) []*ConfigDef {
r.mu.RLock()
defer r.mu.RUnlock()
var result []*ConfigDef
for _, def := range r.defs {
if strings.HasPrefix(def.Key, pluginDefPrefix) {
continue
}
if strings.HasPrefix(def.Key, prefix) {
result = append(result, def)
}
}
sort.Slice(result, func(i, j int) bool { return result[i].Key < result[j].Key })
return result
}
func (r *ConfigRegistry) Get(key string) (interface{}, error) {
r.mu.RLock()
defer r.mu.RUnlock()
@ -1114,5 +1141,24 @@ func (p *PluginSettings) RegisterDef(def ConfigDef) {
}
func (p *PluginSettings) ListDefs(prefix string) []*ConfigDef {
return p.registry.ListDefs(prefix)
// 只返回**本插件命名空间下**的定义,并把 Key 剥回插件内局部键。
//
// 注册时 def.Key 被限定成 "plugin.<name>.<key>" 以保证全局唯一;
// 而调用方插件自身、WebUI 设置页)看到的键必须与 Set/Get/ListPlugin
// 使用的局部键一致,所以这里必须反向剥掉命名空间。
//
// 修复前这里把 prefix 直接透传给全局 ListDefs等价于「返回全仓所有 def」
// WebUI 设置页于是把每个 def 复制进每个插件命名空间
// 28 插件 × ~186 def = 5208 条96% 是重复),并派生出
// plugin.<a>.plugin.<b>.<key> 这类幻影键——按幻影键写回会落到**错误插件**
// 的配置表里。
qualified := "plugin." + p.name + "."
defs := p.registry.ListDefs(qualified + prefix)
out := make([]*ConfigDef, 0, len(defs))
for _, d := range defs {
clone := *d
clone.Key = strings.TrimPrefix(d.Key, qualified)
out = append(out, &clone)
}
return out
}

View File

@ -2,6 +2,7 @@ package config
import (
"path/filepath"
"strings"
"testing"
"time"
)
@ -393,3 +394,61 @@ func TestSetLLMSnapshotFile(t *testing.T) {
t.Fatalf("snapshot missing untouched key model: %v", got)
}
}
// TestPluginDefsAreNamespaced 锁住两件曾经一起坏掉的事:
//
// 1. PluginConfig(name).ListDefs 必须只返回**该插件**的 def且 Key 是插件内
// 局部键。修复前它把 prefix 直接透传给全局 ListDefs于是返回全仓所有 def
// WebUI 设置页因此把每个 def 复制进每个插件命名空间5208 条里 96% 是重复)。
// 2. ListCoreDefs 必须排除 "plugin.<name>." 命名空间,否则核心列表里会混进
// 插件 def 的副本。
func TestPluginDefsAreNamespaced(t *testing.T) {
r := NewConfigRegistry("")
r.RegisterDef(ConfigDef{Key: "core.agent.max_tool_turns", Default: "10"})
r.RegisterDef(ConfigDef{Key: "core.llm.model", Default: "m"})
a := r.PluginConfig("a")
a.RegisterDef(ConfigDef{Key: "addr", Default: ":1"})
a.RegisterDef(ConfigDef{Key: "token", Default: ""})
b := r.PluginConfig("b")
b.RegisterDef(ConfigDef{Key: "secret", Default: ""})
da := a.ListDefs("")
if len(da) != 2 {
t.Fatalf("插件 a 应只有 2 个自己的 def实际 %d%v", len(da), keysOf(da))
}
for _, d := range da {
if d.Key != "addr" && d.Key != "token" {
t.Fatalf("插件 a 看到了不属于自己的 def%q", d.Key)
}
}
// 局部键前缀过滤("a" 只匹配插件内以 a 开头的键 → addr
if got := a.ListDefs("a"); len(got) != 1 || got[0].Key != "addr" {
t.Fatalf("插件内前缀过滤失效:%v", keysOf(got))
}
db := b.ListDefs("")
if len(db) != 1 || db[0].Key != "secret" {
t.Fatalf("插件 b 应只有 secret实际 %v", keysOf(db))
}
core := r.ListCoreDefs("")
if len(core) != 2 {
t.Fatalf("核心 def 应为 2 个,实际 %d%v", len(core), keysOf(core))
}
for _, d := range core {
if strings.HasPrefix(d.Key, "plugin.") {
t.Fatalf("核心列表混入了插件 def%q", d.Key)
}
}
if got := r.ListCoreDefs("core.llm"); len(got) != 1 || got[0].Key != "core.llm.model" {
t.Fatalf("核心前缀过滤失效:%v", keysOf(got))
}
}
func keysOf(defs []*ConfigDef) []string {
out := make([]string, len(defs))
for i, d := range defs {
out[i] = d.Key
}
return out
}

View File

@ -152,7 +152,8 @@ func (s *settingsImpl) DefsCore(prefix string) []*ConfigDef {
if s.reg == nil {
return nil
}
return mapDefs(s.reg.ListDefs(prefix))
// 只取核心命名空间:插件 def 已限定在 "plugin.<name>.",不能当核心 def 返回。
return mapDefs(s.reg.ListCoreDefs(prefix))
}
func (s *settingsImpl) DefsPlugin(plugin, prefix string) []*ConfigDef {
if s.reg == nil {