From b23644ad74bb14ab80b59d2ef07d79f3ba60df77 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(webui):=20=E8=AE=BE=E7=BD=AE=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E4=B8=8D=E5=86=8D=E5=90=90=E5=86=85=E9=83=A8=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=EF=BC=9B--webui=20=E8=A6=86=E7=9B=96=E7=94=9F?= =?UTF-8?q?=E6=95=88=EF=BC=9B=E7=AB=AF=E5=8F=A3=E5=8D=A0=E7=94=A8=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=9D=99=E9=BB=98=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处实测确认的缺陷: ① 设置接口整块吐出聊天记录 plugin.webui.chathistory 是 webui 自己持久化的整段聊天记录(生产实例 实测 5,176,016 字节),躺在插件配置表里被设置接口当普通配置项整块返回, 前端还会把它渲染成一个巨大的文本框。 修复:GET 跳过该键(按插件+键精确判定),PUT 直接 400,避免误改。 ② CLI --webui 与 webui.listen_addr 一直是死配置 内核原本在插件加载前写 settings["addr"],但那时 config_ 表还没建 (表只在插件注册 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(优先级)。 --- cmd/homed/bootstrap.go | 28 +++-- cmd/homed/main.go | 5 +- internal/plugins/webui/handler.go | 20 +++ internal/plugins/webui/handler_test.go | 168 +++++++++++++++++++++++++ internal/plugins/webui/plugin.go | 46 ++++++- 5 files changed, 251 insertions(+), 16 deletions(-) diff --git a/cmd/homed/bootstrap.go b/cmd/homed/bootstrap.go index 3d59503..9b4c000 100644 --- a/cmd/homed/bootstrap.go +++ b/cmd/homed/bootstrap.go @@ -802,20 +802,26 @@ func wirePluginSDK(pluginReg *plugin.Registry, luaVM *luapkg.VM, baseAPIKey stri pluginReg.SetStatusProvider(agent) } -// configureWebUIAddr 把 CLI --webui 落到 webui 插件的 settings["addr"]。 +// resolveWebUIOverride 解析 webui 监听地址的覆盖值,空串表示不覆盖。 // -// webui 插件作为内置插件经 Registry 启动,读取自身 settings["addr"](默认 :8080); -// CLI 与 webui.listen_addr 配置只在这条键还空着时覆盖它。 -func configureWebUIAddr(cfgReg *internalConfig.ConfigRegistry, httpAddr string) { - webuiListenAddr := httpAddr - if webuiListenAddr == "" { - webuiListenAddr = cfgReg.GetString("webui.listen_addr", ":8080") +// 优先级:CLI --webui > 核心配置 webui.listen_addr(仅当它被改成非内置默认值)。 +// 两者都不给时由 webui 插件自己的 settings["addr"] 决定。 +// +// 为什么不写成“内核在插件加载前 Set 插件 settings['addr']”:那时 +// config_webui 表还没建(表只在插件注册 def 时创建),PluginSettings.Set 的 +// INSERT 会失败而错误被忽略,随后插件 Start 里 RegisterDef 才建表并写入默认 +// :8080 —— 于是 CLI --webui 与 webui.listen_addr **一直是死配置**, +// 无论怎么传都监听 :8080。覆盖值改由插件自己接收(webui.SetListenOverride)。 +func resolveWebUIOverride(cfgReg *internalConfig.ConfigRegistry, httpAddr string) string { + if strings.TrimSpace(httpAddr) != "" { + return strings.TrimSpace(httpAddr) } - if ps := cfgReg.PluginConfig("webui"); ps != nil { - if v, _ := ps.Get("addr"); v == nil { - _ = ps.Set("addr", webuiListenAddr) - } + // webui.listen_addr 的播种默认值就是 ":8080";与默认值相同视为“未配置”, + // 否则会把用户在设置页里改过的插件 addr 顶掉。 + if v := strings.TrimSpace(cfgReg.GetString("webui.listen_addr", ":8080")); v != "" && v != ":8080" { + return v } + return "" } // options 是 worker 的命令行参数。 diff --git a/cmd/homed/main.go b/cmd/homed/main.go index bfd09ef..a5db61b 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -14,7 +14,7 @@ import ( cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr" - _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" + webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" // 空白导入内置 provider:它们各自在 init 里注册到 pkg/embedding。 // 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main)。 @@ -153,7 +153,8 @@ func main() { // 为内置插件注入内核依赖(各插件通过 init() 自注册工厂) cli.DefaultSocket = opt.cliSocket - configureWebUIAddr(cfgReg, opt.httpAddr) + // webui 监听地址覆盖:必须在 loadPlugins 之前设置,插件 Start 时会读它。 + webui.SetListenOverride(resolveWebUIOverride(cfgReg, opt.httpAddr)) // ---- 依存句法分析器(内嵌 ONNX 模型 / 规则引擎) ---- diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index b55c7d9..b5b48f5 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -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]) diff --git a/internal/plugins/webui/handler_test.go b/internal/plugins/webui/handler_test.go index 727d413..3aa2fc4 100644 --- a/internal/plugins/webui/handler_test.go +++ b/internal/plugins/webui/handler_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "net" "net/http" "net/http/httptest" "strings" @@ -985,3 +986,170 @@ func TestAgentDuplicateInputDedup(t *testing.T) { t.Fatal("different source should not be duplicate") } } + +// TestSettingsNoCrossPluginLeak 锁住设置接口的两类泄漏: +// +// 1. meta 不得把别家插件的 def 复制进来(曾经 28 插件 × ~186 def = 5208 条, +// 96% 重复),也不得出现 plugin..plugin.. 这种幻影键—— +// 按幻影键写回会落到错误插件的配置表里。 +// 2. chathistory 是内部数据(生产实测 5.2MB),不得出现在设置响应里, +// 也不得经设置接口写入。 +func TestSettingsNoCrossPluginLeak(t *testing.T) { + cfgReg := internalConfig.NewConfigRegistry("") + cfgReg.RegisterDef(internalConfig.ConfigDef{Key: "core.agent.max_tool_turns", Default: "10"}) + + webuiCfg := cfgReg.PluginConfig("webui") + webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "addr", Default: ":8080"}) + webuiCfg.Set("addr", ":8080") + webuiCfg.Set("chathistory", `[{"role":"assistant","content":"secret blob"}]`) + + qqCfg := cfgReg.PluginConfig("qq") + qqCfg.RegisterDef(internalConfig.ConfigDef{Key: "access_token", Default: ""}) + qqCfg.Set("access_token", "qq-token") + + sup := supervisor.New(&types.Config{Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, HeartbeatInterval: 30 * time.Second, + }}) + sup.Start() + defer sup.Shutdown() + + s := testSDK(sdk.SDKConfig{ + Supervisor: supervisor.NewSDKAdapter(sup), + Settings: sdk.NewSettings("webui", cfgReg), + Config: sdk.NewConfig(&types.Config{}), + }) + h := NewHandler(s) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil) + w := httptest.NewRecorder() + h.handleSettings(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp struct { + Settings map[string]interface{} `json:"settings"` + Meta map[string]map[string]interface{} `json:"meta"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // 1) meta 里每个键只能出现一次 "plugin." 前缀,且内容与键同源 + for k, v := range resp.Meta { + if strings.Count(k, "plugin.") > 1 { + t.Fatalf("meta 出现幻影键:%q", k) + } + if inner, ok := v["key"].(string); ok && strings.HasPrefix(inner, "plugin.") { + t.Fatalf("meta[%q].key 带命名空间前缀(会造成双重前缀):%q", k, inner) + } + } + // 2) webui 不能看到 qq 的 def,反之亦然 + if _, ok := resp.Meta["plugin.webui.addr"]; !ok { + t.Fatalf("meta 缺少 plugin.webui.addr:%v", resp.Meta) + } + if _, ok := resp.Meta["plugin.webui.access_token"]; ok { + t.Fatal("meta 里出现了别家插件的 def:plugin.webui.access_token") + } + if _, ok := resp.Meta["plugin.qq.access_token"]; !ok { + t.Fatal("meta 缺少 plugin.qq.access_token") + } + if _, ok := resp.Meta["plugin.qq.addr"]; ok { + t.Fatal("meta 里出现了别家插件的 def:plugin.qq.addr") + } + // 3) 内部数据不进设置面 + if _, ok := resp.Settings["plugin.webui.chathistory"]; ok { + t.Fatal("settings 泄露了 chathistory 内部数据") + } + if _, ok := resp.Meta["plugin.webui.chathistory"]; ok { + t.Fatal("meta 泄露了 chathistory") + } + if _, ok := resp.Settings["plugin.qq.access_token"]; !ok { + t.Fatal("普通插件配置项应照常返回") + } + + // 4) 内部数据也不可经设置接口写入 + body := `{"key":"plugin.webui.chathistory","value":"tampered"}` + preq := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(body)) + preq.Header.Set("Content-Type", "application/json") + pw := httptest.NewRecorder() + h.handleSettings(pw, preq) + if pw.Code != http.StatusBadRequest { + t.Fatalf("写内部键应被拒(400),实际 %d", pw.Code) + } + got, _ := webuiCfg.Get("chathistory") + if got != `[{"role":"assistant","content":"secret blob"}]` { + t.Fatalf("内部数据被改写:%v", got) + } +} + +// TestListenOverrideAndBindFailure 钉住两个曾经静默的缺陷: +// +// 1. CLI --webui / webui.listen_addr 的覆盖必须真的生效(优先级高于插件 settings["addr"])。 +// 修复前内核只在插件设置键为空时才写,而 REGISTERDEF 建表时写的是默认 :8080, +// 覆盖因此永远是死配置。 +// 2. 端口被占时 Start 必须返回错误。修复前监听在后台 goroutine 里做, +// Start 永远返回 nil,WebUI 静默死亡而插件仍被当成加载成功。 +func TestListenOverrideAndBindFailure(t *testing.T) { + // 取一个确定空闲的地址 + probe, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("probe listen: %v", err) + } + addr := probe.Addr().String() + probe.Close() + + cfgReg := internalConfig.NewConfigRegistry("") + seedWebUIConfig(cfgReg) + // 插件设置里故意放一个别的地址,用来证明覆盖的优先级 + cfgReg.PluginConfig("webui").Set("addr", "127.0.0.1:1") + + s := testSDK(sdk.SDKConfig{ + Settings: sdk.NewSettings("webui", cfgReg), + Config: sdk.NewConfig(&types.Config{}), + EventBus: events.NewBus(), + IOManager: agentIO.NewIOManager(), + }) + + SetListenOverride(addr) + defer SetListenOverride("") + + p1 := New("webui") + if err := p1.Start(s); err != nil { + t.Fatalf("Start with override: %v", err) + } + defer p1.Stop() + + // 覆盖值必须真的在监听 + cli := &http.Client{Timeout: 2 * time.Second} + resp, err := cli.Get("http://" + addr + "/login") + if err != nil { + t.Fatalf("覆盖地址未监听(%s):%v", addr, err) + } + resp.Body.Close() + + // 同一地址再来一个插件实例 → 必须同步报错 + p2 := New("webui") + err = p2.Start(s) + if err == nil { + p2.Stop() + t.Fatal("端口被占用时 Start 应返回错误,而不是静默成功") + } + if !strings.Contains(err.Error(), "监听") { + t.Fatalf("错误信息应说明监听失败,实际:%v", err) + } +} + +func TestResolveListenAddrPrecedence(t *testing.T) { + SetListenOverride("") + defer SetListenOverride("") + if got := resolveListenAddr(""); got != ":8080" { + t.Fatalf("默认应为 :8080,得到 %q", got) + } + if got := resolveListenAddr(":9001"); got != ":9001" { + t.Fatalf("插件设置应生效,得到 %q", got) + } + SetListenOverride("127.0.0.1:9002") + if got := resolveListenAddr(":9001"); got != "127.0.0.1:9002" { + t.Fatalf("覆盖值应优先,得到 %q", got) + } +} diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go index 2ef7642..9e11712 100644 --- a/internal/plugins/webui/plugin.go +++ b/internal/plugins/webui/plugin.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log" + "net" "net/http" "os" "path/filepath" @@ -52,6 +53,35 @@ var webFilesDir string // handleChatFile 落盘、handleUploads 下载共用;参考 qq 插件 files_dir 收文件设计。 var uploadsDir string +// listenOverride 是内核在插件加载前给出的监听地址覆盖(CLI --webui, +// 或核心配置 webui.listen_addr 被显式改成非默认值)。 +// +// 为什么需要这个旁路:内核曾在插件加载前写 settings["addr"],但那时 +// config_ 表还没建,PluginSettings.Set 的 INSERT 会失败且错误被忽略; +// 随后 plugin Start 里 RegisterDef 才建表并写入默认值 :8080。结果是 +// CLI --webui 与 webui.listen_addr **一直是死配置**。这里改为插件自己 +// 接受一个显式覆盖值,优先级高于 settings["addr"](后者是 Web 设置页的持久值)。 +var listenOverride string + +// SetListenOverride 设置监听地址覆盖(空值表示不覆盖)。 +// 由 cmd/homed 在插件加载前调用,见 resolveWebUIOverride。 +func SetListenOverride(addr string) { + listenOverride = strings.TrimSpace(addr) +} + +// resolveListenAddr 决定最终监听地址:覆盖值 > 插件设置 > 内置默认。 +// 抽成纯函数是为了能被单测直接钉住优先级。 +func resolveListenAddr(setting string) string { + addr := ":8080" + if setting != "" { + addr = setting + } + if listenOverride != "" { + addr = listenOverride + } + return addr +} + // stageWebFile 把 agent 要发送的本地文件拷贝到 webui_files 中转目录, // 返回可下载 URL 路径与字节数。image/file 的 payload 支持本地路径或 http(s) URL // (URL 直接透传给前端,不落盘)。文件名用随机 UUID 防路径猜测,扩展名保留自源文件。 @@ -146,6 +176,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { addr = s2 } } + addr = resolveListenAddr(addr) // 能力位 7 = CapText|CapFile|CapImage;旧值 1 仅文本,agent 无法向 webui 发文件/图片 // 入站通道:webui(控制台对话)与 http(外部 HTTP 注入),都由本插件注入输入。 @@ -238,13 +269,22 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { // 最外层套 logged 中间件:记录每个请求的来源 IP / 方法 / 路径 / 认证方式 / 状态码。 // 用于排查“谁调用了什么接口”(如插件禁用等变更操作)。 - p.server = &http.Server{Addr: addr, Handler: p.handler.logged(p.mux)} + // + // 同步 Listen:端口被占时必须**在这里**失败并把错误交回加载器, + // 而不是“后台 goroutine 里报一行日志、插件仍被当成加载成功”。 + // 修复前 Start 总是返回 nil,于是 :8080 被占时 WebUI 静默死亡, + // 调用方看不到任何失败信号。 + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("webui: 监听 %s 失败: %w", addr, err) + } + p.server = &http.Server{Handler: p.handler.logged(p.mux)} go func() { - log.Printf("[webui] HTTP server listening on %s", addr) - if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + if err := p.server.Serve(ln); err != nil && err != http.ErrServerClosed { log.Printf("[webui] server error: %v", err) } }() + log.Printf("[webui] HTTP server listening on %s", ln.Addr()) return nil }