mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-29 06:00:56 +00:00
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:
@ -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 的命令行参数。
|
||||
|
||||
@ -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 模型 / 规则引擎) ----
|
||||
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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.<a>.plugin.<b>.<key> 这种幻影键——
|
||||
// 按幻影键写回会落到错误插件的配置表里。
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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_<name> 表还没建,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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user