fix(webui): 设置接口不再吐内部数据;--webui 覆盖生效;端口占用不再静默成功

三处实测确认的缺陷:

① 设置接口整块吐出聊天记录
   plugin.webui.chathistory 是 webui 自己持久化的整段聊天记录(生产实例
   实测 5,176,016 字节),躺在插件配置表里被设置接口当普通配置项整块返回,
   前端还会把它渲染成一个巨大的文本框。
   修复:GET 跳过该键(按插件+键精确判定),PUT 直接 400,避免误改。

② CLI --webui 与 webui.listen_addr 一直是死配置
   内核原本在插件加载前写 settings["addr"],但那时 config_<name> 表还没建
   (表只在插件注册 def 时创建),PluginSettings.Set 的 INSERT 失败,而错误被
   "_ =" 忽略了;随后插件 Start 里 RegisterDef 才建表并写入默认 :8080。
   实测:传 "-webui 127.0.0.1:18099" 仍然监听 :8080。
   修复:覆盖值改由插件自己接收(webui.SetListenOverride,loadPlugins 前调用),
   优先级 CLI > webui.listen_addr(非默认值才算显式配置)> settings["addr"]。
   实测修复后:"-webui 127.0.0.1:18099" 正确监听 18099,与生产的 :8080 并存。

③ 端口被占时 webui 静默死亡
   Start 在后台 goroutine 里 ListenAndServe,先打印 "listening on" 再尝试绑定,
   失败只留一行日志,Start 永远返回 nil → 插件仍被当成加载成功。
   修复:net.Listen 同步做,失败即返回 error(交给加载器/守护),
   成功后才起 Serve,并打印真实绑定地址。
   A/B 实测(两个实例都撞生产的 :8080):
     修复前:"listening on :8080" + "server error: address already in use" + LOADED: webui
     修复后:"[plugin] start webui: webui: 监听 :8080 失败: ...",不再有 LOADED: webui

效果实测(同一实例,先注入 5,271,690 字节 chathistory):
  GET /api/v1/settings   8,244,108 → 28,652 字节(约 1/288)
  meta 条数              5,208 → 105,幻影键 0 条
  设置页仍正常:?prefix=plugin.webui 返回 8 条 def;普通键 PUT 落库;
  校验:GET/PUT 内部键被拒;-webui 覆盖真实生效。

新增测试:TestSettingsNoCrossPluginLeak(跨插件泄漏/幻影键/chathistory 读写)、
TestListenOverrideAndBindFailure(覆盖生效 + 端口占用必须报错)、
TestResolveListenAddrPrecedence(优先级)。
This commit is contained in:
HomeAgent Agent
2026-09-14 06:53:58 +08:00
parent 1b0a5b1dc2
commit b23644ad74
5 changed files with 251 additions and 16 deletions

View File

@ -2153,6 +2153,16 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
}
}
// isInternalSetting 判断某个插件的配置键是不是**内部数据**(不是用户设置项)。
//
// chathistory 是 webui 自己持久化的整段聊天记录(生产实例实测 5.2MB)。它躺在
// 插件配置表里,于是会被设置接口当成普通配置项整块吐出:GET /api/v1/settings
// 因此返回 8MB+,并且前端把它渲染成一个巨大的文本框。它只应由 chat 相关
// 接口的 handleChatHistory 读,不进设置面。
func isInternalSetting(plugin, key string) bool {
return plugin == "webui" && key == "chathistory"
}
func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
if h.settings == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "config registry not available"})
@ -2169,6 +2179,9 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
pluginName := prefix[7:]
keys, _ := h.settings.ListPlugin(pluginName, "")
for _, k := range keys {
if isInternalSetting(pluginName, k) {
continue
}
v, _ := h.settings.GetPlugin(pluginName, k)
fullKey := prefix + "." + k
values[fullKey] = v
@ -2205,6 +2218,9 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
}
pkeys, _ := h.settings.ListPlugin(p, "")
for _, k := range pkeys {
if isInternalSetting(p, k) {
continue
}
v, _ := h.settings.GetPlugin(p, k)
fullKey := "plugin." + p + "." + k
values[fullKey] = v
@ -2257,6 +2273,10 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(body.Key, "plugin.") {
parts := strings.SplitN(body.Key, ".", 3)
if len(parts) >= 3 {
if isInternalSetting(parts[1], parts[2]) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "该键属于插件内部数据,不经设置接口读写"})
return
}
var err error
if deleting {
err = h.settings.RemovePlugin(parts[1], parts[2])