From 9e6627f0c35bf9876df7d24b3272a0dafc6be5c5 Mon Sep 17 00:00:00 2001 From: HomeAgent Agent Date: Mon, 14 Sep 2026 06:53:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(config):=20=E6=8F=92=E4=BB=B6=20def=20?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E4=B8=8D=E5=86=8D=E8=B6=8A=E7=95=8C=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20ListDefs=20=E4=BD=9C=E7=94=A8=E5=9F=9F=20+?= =?UTF-8?q?=20=E6=96=B0=E5=A2=9E=20ListCoreDefs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个方向相反的越界,合起来把 WebUI 设置接口的 meta 撑成 5208 条(96% 重复): 1) PluginSettings.ListDefs(prefix) 把 prefix 直接透传给全局 ListDefs, 等于「返回全仓所有 def」——调用方以为在问某个插件,实际拿到全部。 修复:限定到 plugin.. 命名空间,并把 Key 剥回插件内局部键 (调用方看到的键必须与 Set/Get/ListPlugin 的局部键一致)。 2) DefsCore(prefix) → reg.ListDefs(prefix) 会连插件 def 一起返回, 于是 meta 里出现 plugin.. 的「核心侧副本」。 修复:新增 ConfigRegistry.ListCoreDefs,显式排除 plugin.* 命名空间。 生产实例实测(旧代码):GET /api/v1/settings 的 meta = 5208 条, 其中 core.agent.* 等每个 def 都被复制 28 份(每个插件命名空间一份), 并派生出 plugin..plugin.. 这类幻影键。 ⚠️ 幻影键不只是脏数据:设置接口的 PUT 走 SplitN(key, ".", 3), 对 plugin..plugin.. 会解出 (a, "plugin.."), 即按 UI 上的幻影条目保存会**写进错误插件的配置表**。 新增 TestPluginDefsAreNamespaced 钉住两条作用域。 --- internal/config/registry.go | 48 +++++++++++++++++++++++++- internal/config/registry_test.go | 59 ++++++++++++++++++++++++++++++++ internal/sdk/settings_impl.go | 3 +- 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/internal/config/registry.go b/internal/config/registry.go index 2487ed3..81dbd64 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -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..",所以这里必须显式排除; +// 否则 DefsCore("") 会把插件 def 一并当成核心 def 返回,WebUI 设置页的 meta +// 里就会出现 plugin.. 的「核心侧副本」。 +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.." 以保证全局唯一; + // 而调用方(插件自身、WebUI 设置页)看到的键必须与 Set/Get/ListPlugin + // 使用的局部键一致,所以这里必须反向剥掉命名空间。 + // + // 修复前这里把 prefix 直接透传给全局 ListDefs,等价于「返回全仓所有 def」: + // WebUI 设置页于是把每个 def 复制进每个插件命名空间 + // (28 插件 × ~186 def = 5208 条,96% 是重复),并派生出 + // plugin..plugin.. 这类幻影键——按幻影键写回会落到**错误插件** + // 的配置表里。 + 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 } diff --git a/internal/config/registry_test.go b/internal/config/registry_test.go index 29b745e..be393d0 100644 --- a/internal/config/registry_test.go +++ b/internal/config/registry_test.go @@ -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.." 命名空间,否则核心列表里会混进 +// 插件 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 +} diff --git a/internal/sdk/settings_impl.go b/internal/sdk/settings_impl.go index 42c8269..0f2918e 100644 --- a/internal/sdk/settings_impl.go +++ b/internal/sdk/settings_impl.go @@ -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..",不能当核心 def 返回。 + return mapDefs(s.reg.ListCoreDefs(prefix)) } func (s *settingsImpl) DefsPlugin(plugin, prefix string) []*ConfigDef { if s.reg == nil {