fix(examples): 全插件安全审查修复(qq/a2a/memo/calendar/rss/browser/bili/recoverydiag)

审查发现并修复 7 项问题:
- P1 qq: downloadURL 裸 http.Get 无超时 → 120s client
- P2 a2a: inbound http.Server 零超时 → Read 30s/Write 120s/Idle 60s
- P3 bili: output_dir 配置项零校验 → 系统目录黑名单(/、/etc、/usr、/var 等)
- P4 recoverydiag: db_path LLM 可控任意 sqlite → 强制限制 data 目录内
- P5 memo/calendar/rss: os.WriteFile 直写 → atomicWriteJSON (temp+rename)
- P6 qq: 3 处后台 goroutine(已读/rcon转发/下载)加 panic recover
- P7 browser: dump-dom failback Kill 后补 wait 回收僵尸进程

recoverydiag 此前被 .gitignore 排除,但其 db_path 安全修复
属生产代码,故取消忽略并入库。

全部经 plugindev 重打包升版安装验证 config_kept=true。
This commit is contained in:
JianFeeeee
2026-08-26 17:04:41 +08:00
parent 16b4a56ee8
commit d57c5eaf3e
18 changed files with 1685 additions and 90 deletions

1
.gitignore vendored
View File

@ -30,4 +30,3 @@ z_entry.c
# plugindev binary in tools/ # plugindev binary in tools/
tools/plugindev/plugindev tools/plugindev/plugindev
example/recoverydiag/

View File

@ -2,14 +2,18 @@
"name": "a2a", "name": "a2a",
"name_zh": "A2A 代理通信", "name_zh": "A2A 代理通信",
"name_en": "A2A Agent Communication", "name_en": "A2A Agent Communication",
"version": "1.0.0", "version": "1.1.0",
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。", "description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",
"tags": ["a2a", "agent", "interop"], "tags": [
"a2a",
"agent",
"interop"
],
"targets": "linux/amd64", "targets": "linux/amd64",
"outdir": "dist", "outdir": "dist",
"bundle": true, "bundle": true,
"replaces": {}, "replaces": {},
"source_dirs": [] "source_dirs": []
} }

View File

@ -137,7 +137,12 @@ func (p *Plugin) startServer(addr string) error {
return fmt.Errorf("listen %s: %v", addr, err) return fmt.Errorf("listen %s: %v", addr, err)
} }
srv := &http.Server{Handler: mux} srv := &http.Server{
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 60 * time.Second,
}
addrStr := listener.Addr().String() addrStr := listener.Addr().String()
p.srvMu.Lock() p.srvMu.Lock()

View File

@ -2,14 +2,18 @@
"name": "bili", "name": "bili",
"name_zh": "B站视频下载", "name_zh": "B站视频下载",
"name_en": "Bilibili Video Downloader", "name_en": "Bilibili Video Downloader",
"version": "1.1.0", "version": "1.2.0",
"description": "B站视频下载工具基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。", "description": "B站视频下载工具基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",
"tags": ["bili", "video", "download"], "tags": [
"bili",
"video",
"download"
],
"targets": "linux/amd64", "targets": "linux/amd64",
"outdir": "dist", "outdir": "dist",
"bundle": true, "bundle": true,
"replaces": {}, "replaces": {},
"source_dirs": [] "source_dirs": []
} }

View File

@ -107,6 +107,14 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
} }
} }
} }
// 安全校验output_dir 是配置项,但避免被配成系统目录导致 yt-dlp 任意位置写。
// 禁止根/家目录本身,且规范化后必须落在明确子目录内。
outputDir = filepath.Clean(outputDir)
for _, forbidden := range []string{"/", "/etc", "/usr", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys", "/var"} {
if outputDir == forbidden {
return nil, fmt.Errorf("output_dir 不能是系统目录 %s", forbidden)
}
}
os.MkdirAll(outputDir, 0755) os.MkdirAll(outputDir, 0755)
var out bytes.Buffer var out bytes.Buffer

View File

@ -2,14 +2,20 @@
"name": "browser", "name": "browser",
"name_zh": "浏览器", "name_zh": "浏览器",
"name_en": "Browser", "name_en": "Browser",
"version": "2.0.0", "version": "2.3.0",
"description": "统一浏览器插件搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)", "description": "统一浏览器插件搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",
"tags": ["web", "search", "fetch", "browser", "cdp"], "tags": [
"web",
"search",
"fetch",
"browser",
"cdp"
],
"targets": "linux/amd64", "targets": "linux/amd64",
"outdir": "dist", "outdir": "dist",
"bundle": true, "bundle": true,
"replaces": {}, "replaces": {},
"source_dirs": [] "source_dirs": []
} }

View File

@ -13,6 +13,7 @@ import (
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -33,23 +34,49 @@ type Plugin struct {
proxy string proxy string
client *http.Client client *http.Client
sessions map[string]*BrowserSession sessions map[string]*BrowserSession
nextID int nextID int
wg sync.WaitGroup wg sync.WaitGroup
stopCh chan struct{} stopCh chan struct{}
stopOnce sync.Once stopOnce sync.Once
profilesDir string // 持久化 profile 根目录(<data>/browser_profiles空则禁用
// 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留每个 start 创建一个
// 新标签页CDP Target。同 source 复用自己的标签页。浏览器进程在
// 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。
sharedAllocCtx context.Context
sharedAllocCancel context.CancelFunc
sharedMu sync.Mutex
} }
type BrowserSession struct { type BrowserSession struct {
id string id string
allocCtx context.Context allocCtx context.Context // 共享浏览器进程上下文shared=true 时指向全局单例)
cancel context.CancelFunc cancel context.CancelFunc
ctx context.Context ctx context.Context // 本会话的 Target 上下文(一个标签页)
createdAt time.Time createdAt time.Time
timeout time.Duration timeout time.Duration
closed bool closed bool
mu sync.Mutex mu sync.Mutex
currentURL string currentURL string
shared bool // true=共享浏览器的一个标签页false=独占浏览器实例
profileDir string // 非空表示使用持久化 profile关闭时不删目录
sessionKey string // 共享模式下的复用键agent 来源标识,同 key 复用同一标签页)
}
// sanitizeProfileName 消毒 profile 名:仅保留字母数字-_防路径穿越。
func sanitizeProfileName(name string) string {
var b []byte
for _, c := range []byte(name) {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
b = append(b, c)
}
}
if len(b) == 0 || string(b) == "." || string(b) == ".." {
return ""
}
return string(b)
} }
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -187,6 +214,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.proxy = readCfg(s.Settings(), "proxy", "") p.proxy = readCfg(s.Settings(), "proxy", "")
p.client = newHTTPClient(p.timeout, p.proxy) p.client = newHTTPClient(p.timeout, p.proxy)
// 持久化 profile 根目录:<data>/browser_profiles
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
if s2, ok := dd.(string); ok && s2 != "" {
p.profilesDir = filepath.Join(s2, "browser_profiles")
}
}
tp := p.name + "_" tp := p.name + "_"
cleaner := func(output string) string { cleaner := func(output string) string {
@ -242,12 +276,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"start", sdk.ToolDef{ s.RegisterTool(tp+"start", sdk.ToolDef{
Name: tp + "start", Name: tp + "start",
Description: "启动交互式浏览器会话(interactive 模式)。通过 CDP 连接 Chromium支持导航、截图、点击、输入等操作。返回会话 ID。", Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install无法安装时自动降级本地临时模式。同来源复用已有标签页。",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL可选"}, "url": map[string]interface{}{"type": "string", "description": "初始导航 URL可选"},
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m默认 10m)"}, "timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m默认 10m)"},
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main。同名档案共享登录态与浏览历史不指定则为一次性临时会话"},
}, },
}, },
}, p.handleBrowserStart) }, p.handleBrowserStart)
@ -337,6 +372,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}, },
}, p.handleScroll) }, p.handleScroll)
s.RegisterTool(tp+"install", sdk.ToolDef{
Name: tp + "install",
Description: "安装并启动共享浏览器后端homeagent-browser.servicesystemd 托管)。前提:本机已有 chromium 二进制无则先提示用户安装apt install chromium 或等价命令)。安装后所有 agent 共享同一浏览器实例与登录态。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleBrowserInstall)
s.RegisterTool(tp+"close", sdk.ToolDef{ s.RegisterTool(tp+"close", sdk.ToolDef{
Name: tp + "close", Name: tp + "close",
Description: "关闭交互式浏览器会话,释放资源。", Description: "关闭交互式浏览器会话,释放资源。",
@ -663,6 +707,9 @@ func (p *Plugin) fetchWithChromium(rawURL string, maxChars int) (interface{}, er
}, nil }, nil
} }
// handleRender 无头渲染 JS 页面并提取文本normal 模式)。
// 主路径走共享浏览器后端:开临时标签页(带全机登录态)→ 渲染 → 取 text → 关标签页;
// 后端不可用时 failback 到独立 chromium --dump-dom无登录态仅保功能
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
rawURL := readArg(args, "url", "") rawURL := readArg(args, "url", "")
if rawURL == "" { if rawURL == "" {
@ -672,32 +719,70 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
return errResult(err.Error()), nil return errResult(err.Error()), nil
} }
waitSec := int64(readArg(args, "wait", float64(0))) waitSec := int64(readArg(args, "wait", float64(0)))
if waitSec > 0 {
time.Sleep(time.Duration(waitSec) * time.Second) var title, html string
rendered := false
ok, needInstall, _ := p.ensureBackend()
if ok {
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
defer remoteCancel()
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
defer tabCancel()
actions := []chromedp.Action{
chromedp.Navigate(rawURL),
chromedp.WaitReady("body"),
}
if waitSec > 0 {
actions = append(actions, chromedp.Sleep(time.Duration(waitSec)*time.Second))
}
actions = append(actions,
chromedp.Title(&title),
chromedp.OuterHTML("html", &html),
)
// 整体限时 30s防慢页拖死工具
rctx, rcancel := context.WithTimeout(tabCtx, 30*time.Second)
defer rcancel()
if err := chromedp.Run(rctx, actions...); err == nil {
rendered = true
} else {
log.Printf("[%s] render via backend failed (%v), fallback to dump-dom", p.name, err)
}
} else if needInstall {
return map[string]interface{}{
"error": "browser backend not installed",
"need_install": true,
"guide": "调用 browser_install 安装共享后端;或重试本工具自动降级为独立 chromium 渲染(不带登录态)",
}, nil
} }
var html string
chromiumPath := "/usr/local/bin/chromium" if !rendered {
if _, err := os.Stat(chromiumPath); err == nil { chromiumPath := "/usr/local/bin/chromium"
if _, err := os.Stat(chromiumPath); err != nil {
if _, e2 := exec.LookPath("chromium"); e2 == nil {
chromiumPath = "chromium"
} else {
return errResult("no chromium available"), nil
}
}
var out bytes.Buffer var out bytes.Buffer
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL) cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
cmd.Stdout = &out cmd.Stdout = &out
if err := cmd.Run(); err != nil { done := make(chan error, 1)
return errResult("chromium: " + err.Error()), nil go func() { done <- cmd.Run() }()
select {
case err := <-done:
if err != nil {
return errResult("chromium: " + err.Error()), nil
}
case <-time.After(30 * time.Second):
cmd.Process.Kill()
<-done // 回收子进程避免僵尸
return errResult("chromium dump-dom timeout (30s)"), nil
} }
html = out.String() html = out.String()
} else {
resp, err := http.Get(rawURL)
if err != nil {
return errResult("http get: " + err.Error()), nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
html = string(body)
}
title := ""
if m := regexp.MustCompile(`<title>([^<]+)</title>`).FindStringSubmatch(html); len(m) > 1 {
title = m[1]
} }
text := htmlToText(html) text := htmlToText(html)
origLen := len(text) origLen := len(text)
truncated := origLen > 5000 truncated := origLen > 5000
@ -712,18 +797,71 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
if truncated { if truncated {
result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen) result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen)
} }
return map[string]interface{}{"content": result, "title": title}, nil mode := "backend-tab"
if !rendered {
mode = "local-dump-dom"
}
return map[string]interface{}{"content": result, "title": title, "mode": mode}, nil
} }
// ── Interactive Browser Session (CDP) ───────────────────── func cdpReachable(endpoint string) bool {
client := &http.Client{Timeout: 2 * time.Second}
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) { resp, err := client.Get(endpoint + "/json/version")
timeoutStr := readArg(args, "timeout", "10m")
timeout, err := time.ParseDuration(timeoutStr)
if err != nil { if err != nil {
timeout = 10 * time.Minute return false
} }
resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// systemdUnitActive 检查 homeagent-browser.service 是否已安装。
func systemdUnitInstalled() bool {
out, err := exec.Command("systemctl", "cat", "homeagent-browser.service").CombinedOutput()
return err == nil && len(out) > 0
}
// startSystemdUnit 尝试 systemctl start单元已安装但未运行时用
func startSystemdUnit() error {
return exec.Command("systemctl", "start", "homeagent-browser.service").Run()
}
// cdpEndpoint 是共享 Chromium 后端的 CDP 地址homeagent-browser.service
const cdpEndpoint = "http://127.0.0.1:9222"
// ensureBackend 确保共享浏览器后端可用:探测 → 拉起已装服务 → 报告未装。
// 返回 (ok, needInstall, err)。
func (p *Plugin) ensureBackend() (bool, bool, error) {
if cdpReachable(cdpEndpoint) {
return true, false, nil
}
if systemdUnitInstalled() {
if err := startSystemdUnit(); err == nil {
// 等待 CDP 就绪chromium 启动 ~1-3s
for i := 0; i < 10; i++ {
time.Sleep(500 * time.Millisecond)
if cdpReachable(cdpEndpoint) {
return true, false, nil
}
}
}
return false, false, fmt.Errorf("browser backend service installed but failed to start")
}
return false, true, nil // 未安装
}
// sharedTab 在共享后端上开一个新标签页RemoteAllocator + NewContext
func sharedTab(allocCtx context.Context) (context.Context, context.CancelFunc, error) {
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
if err := chromedp.Run(tabCtx); err != nil {
tabCancel()
return nil, nil, err
}
return tabCtx, tabCancel, nil
}
// localSpawnFailback 本地拉起一次性 Chromium离线机器无法装 systemd 服务的兜底)。
// 用临时 profile登录态不跨会话保留——仅保证功能可用。
func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, context.CancelFunc, error) {
opts := append(chromedp.DefaultExecAllocatorOptions[:], opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true), chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true), chromedp.Flag("disable-gpu", true),
@ -733,23 +871,83 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
if p.proxy != "" { if p.proxy != "" {
opts = append(opts, chromedp.Flag("proxy-server", p.proxy)) opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
} }
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, _ := chromedp.NewContext(allocCtx) ctx, _ := chromedp.NewContext(allocCtx)
// 立即分配浏览器和 Target确保后续 Run 的 timeout context 不会杀死浏览器进程
// chromedp 官方警告:首调用带 timeout 的 Run 会杀死整个浏览器
if err := chromedp.Run(ctx); err != nil { if err := chromedp.Run(ctx); err != nil {
cancel() cancelAlloc()
return errResult("browser init failed: " + err.Error()), nil return nil, nil, nil, err
}
return allocCtx, cancelAlloc, nil, nil
}
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
timeoutStr := readArg(args, "timeout", "10m")
timeout, err := time.ParseDuration(timeoutStr)
if err != nil {
timeout = 10 * time.Minute
} }
session := &BrowserSession{ source := readArg(args, "source", "")
allocCtx: allocCtx, if source == "" {
cancel: cancel, source = "default"
ctx: ctx, }
createdAt: time.Now(),
timeout: timeout, // 同 source 复用已有标签页
p.mu.Lock()
for _, s := range p.sessions {
if s.shared && s.sessionKey == source && !s.closed {
s.mu.Lock()
id := s.id
cur := s.currentURL
s.mu.Unlock()
p.mu.Unlock()
return map[string]interface{}{
"id": id,
"status": "reused",
"url": cur,
"note": "已复用本来源的现有标签页(登录态全机共享)",
}, nil
}
}
p.mu.Unlock()
var session *BrowserSession
// 路径一systemd 托管的共享后端(主路径)
ok, needInstall, berr := p.ensureBackend()
if ok {
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
probe, _ := chromedp.NewContext(remoteCtx)
if err := chromedp.Run(probe); err != nil {
remoteCancel()
return errResult("connect to browser backend failed: " + err.Error()), nil
}
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
if err := chromedp.Run(tabCtx); err != nil {
remoteCancel()
return errResult("open tab failed: " + err.Error()), nil
}
session = &BrowserSession{
allocCtx: remoteCtx,
cancel: tabCancel,
ctx: tabCtx,
createdAt: time.Now(),
timeout: timeout,
shared: true,
sessionKey: source,
}
} else if needInstall {
guide := "浏览器后端未安装。请确认后调用 browser_install 工具完成安装:" +
"需要本机有 chromium 二进制apt install chromium 或等价命令)," +
"插件会注册 homeagent-browser.service 并启动。" +
"若本机无法联网安装 chromium可继续用本地临时模式重试 browser_start 即自动降级)。"
return map[string]interface{}{
"error": "backend not installed",
"need_install": true,
"guide": guide,
}, nil
} else {
return errResult("browser backend error: " + berr.Error()), nil
} }
p.mu.Lock() p.mu.Lock()
@ -761,7 +959,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
initURL := readArg(args, "url", "") initURL := readArg(args, "url", "")
if initURL != "" { if initURL != "" {
if err := chromedp.Run(ctx, if err := chromedp.Run(session.ctx,
chromedp.Navigate(initURL), chromedp.Navigate(initURL),
chromedp.WaitReady("body"), chromedp.WaitReady("body"),
); err != nil { ); err != nil {
@ -772,13 +970,13 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
return errResult("navigate failed: " + err.Error()), nil return errResult("navigate failed: " + err.Error()), nil
} }
session.currentURL = initURL session.currentURL = initURL
p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已打开 %s]", id, initURL))
} }
log.Printf("[%s] created browser session %s: url=%s timeout=%v", p.name, id, initURL, timeout) log.Printf("[%s] created browser session %s: url=%s timeout=%v source=%s", p.name, id, initURL, timeout, source)
return map[string]interface{}{ return map[string]interface{}{
"id": id, "id": id,
"status": "created", "status": "created",
"mode": "shared-backend",
"url": initURL, "url": initURL,
"timeout": timeout.String(), "timeout": timeout.String(),
}, nil }, nil
@ -1019,3 +1217,104 @@ func (p *Plugin) cleanupLoop() {
} }
} }
} }
// ── browser_install安装 systemd 托管的共享浏览器后端 ──────────
// handleBrowserInstall 注册 homeagent-browser.service 并启动,验证 CDP 可达。
// 返回给 agent 的结果含全机共享使用指南(由 agent 转述给用户)。
func (p *Plugin) handleBrowserInstall(args map[string]interface{}) (interface{}, error) {
if cdpReachable(cdpEndpoint) {
return map[string]interface{}{"status": "already_running", "endpoint": cdpEndpoint}, nil
}
// 探测 chromium 二进制
chromePath := ""
for _, c := range []string{
"/usr/bin/chromium", "/usr/bin/chromium-browser",
"/usr/local/bin/chromium", "/usr/bin/google-chrome",
} {
if _, err := os.Stat(c); err == nil {
chromePath = c
break
}
}
if out, err := exec.LookPath("chromium"); err == nil && chromePath == "" {
chromePath = out
} else if out, err := exec.LookPath("google-chrome"); err == nil && chromePath == "" {
chromePath = out
}
if chromePath == "" {
return map[string]interface{}{
"error": "chromium binary not found",
"hint": "请先安装 chromiumapt install chromium 或等价命令,然后重试 browser_install",
}, nil
}
profileDir := ""
if p.profilesDir != "" {
profileDir = filepath.Join(p.profilesDir, "shared")
os.MkdirAll(profileDir, 0755)
} else {
// profilesDir 未注入(无 data_dir退到 /var/lib/homeagent-browser
profileDir = "/var/lib/homeagent-browser"
os.MkdirAll(profileDir, 0755)
}
unit := fmt.Sprintf(`[Unit]
Description=HomeAgent Shared Browser Backend (headless chromium, CDP :9222)
After=network.target
[Service]
Type=simple
ExecStart=%s --headless --no-sandbox --disable-gpu --disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir=%s --window-size=1280,800 about:blank
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
`, chromePath, profileDir)
unitPath := "/etc/systemd/system/homeagent-browser.service"
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
return map[string]interface{}{
"error": "write unit failed (need root): " + err.Error(),
"hint": "插件进程无权限写 /etc/systemd/system 时,请让用户手动执行安装命令(见 manual_cmds",
"manual_cmds": []string{
"sudo tee /etc/systemd/system/homeagent-browser.service <<'EOF'\n" + unit + "EOF",
"sudo systemctl daemon-reload",
"sudo systemctl enable --now homeagent-browser.service",
},
}, nil
}
for _, cmd := range [][]string{
{"systemctl", "daemon-reload"},
{"systemctl", "enable", "--now", "homeagent-browser.service"},
} {
if out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil {
return map[string]interface{}{
"error": fmt.Sprintf("%v: %s", cmd, string(out)),
}, nil
}
}
// 等待 CDP 就绪
for i := 0; i < 20; i++ {
time.Sleep(500 * time.Millisecond)
if cdpReachable(cdpEndpoint) {
guide := "共享浏览器后端已就绪CDP " + cdpEndpoint + ")。\n" +
"全机共享说明:本机所有 agentHomeAgent、pi、opencode、deepseekharness 等)都可连接此实例:" +
"登录一次全机可用;各 agent 各自占用独立标签页互不干扰;\n" +
"- HomeAgent 内部browser_start 即自动连接本后端\n" +
"- 其他 agent让其浏览器工具/MCP 连接 CDP 端点 " + cdpEndpoint + "(如 playwright connectOverCDP / puppeteer connect\n" +
"- 服务由 systemd 托管:崩溃自动重启,登录态持久保存在 " + profileDir
log.Printf("[%s] browser backend installed and running (chrome=%s profile=%s)", p.name, chromePath, profileDir)
return map[string]interface{}{
"status": "installed",
"endpoint": cdpEndpoint,
"chrome": chromePath,
"profile": profileDir,
"guide": guide,
}, nil
}
}
return map[string]interface{}{"error": "service started but CDP not reachable after 10s"}, nil
}

View File

@ -2,14 +2,19 @@
"name": "calendar", "name": "calendar",
"name_zh": "日历", "name_zh": "日历",
"name_en": "Calendar", "name_en": "Calendar",
"version": "1.0.0", "version": "1.1.0",
"description": "日历事件管理,支持提醒和重复事件", "description": "日历事件管理,支持提醒和重复事件",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",
"tags": ["calendar", "event", "reminder", "schedule"], "tags": [
"calendar",
"event",
"reminder",
"schedule"
],
"targets": "linux/amd64", "targets": "linux/amd64",
"outdir": "dist", "outdir": "dist",
"bundle": true, "bundle": true,
"replaces": {}, "replaces": {},
"source_dirs": [] "source_dirs": []
} }

View File

@ -656,7 +656,7 @@ func (p *Plugin) saveEventsLocked() {
NextEventID: p.nextEventID, NextEventID: p.nextEventID,
} }
b, _ := json.MarshalIndent(data, "", " ") b, _ := json.MarshalIndent(data, "", " ")
os.WriteFile(p.eventsFile(), b, 0644) atomicWriteJSON(p.eventsFile(), b)
} }
// --- Helper: parse remind_before --- // --- Helper: parse remind_before ---
@ -1177,3 +1177,12 @@ func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error)
} }
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
} }
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}

View File

@ -2,14 +2,18 @@
"name": "memo", "name": "memo",
"name_zh": "备忘录", "name_zh": "备忘录",
"name_en": "Memo", "name_en": "Memo",
"version": "1.0.0", "version": "1.1.0",
"description": "待办与备忘录插件。待办todo_add/todo_complete/todo_list会主动提醒备忘录memo_create/memo_list/memo_delete纯记事不提醒。", "description": "待办与备忘录插件。待办todo_add/todo_complete/todo_list会主动提醒备忘录memo_create/memo_list/memo_delete纯记事不提醒。",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",
"tags": ["memo", "todo", "notes"], "tags": [
"memo",
"todo",
"notes"
],
"targets": "linux/amd64", "targets": "linux/amd64",
"outdir": "dist", "outdir": "dist",
"bundle": true, "bundle": true,
"replaces": {}, "replaces": {},
"source_dirs": [] "source_dirs": []
} }

View File

@ -224,7 +224,7 @@ func (p *Plugin) saveTodos() {
"next_id": p.nextTID, "next_id": p.nextTID,
}, "", " ") }, "", " ")
p.mu.RUnlock() p.mu.RUnlock()
os.WriteFile(p.todoPath, data, 0644) atomicWriteJSON(p.todoPath, data)
} }
func (p *Plugin) saveMemos() { func (p *Plugin) saveMemos() {
@ -234,7 +234,7 @@ func (p *Plugin) saveMemos() {
"next_id": p.nextMID, "next_id": p.nextMID,
}, "", " ") }, "", " ")
p.mu.RUnlock() p.mu.RUnlock()
os.WriteFile(p.memoPath, data, 0644) atomicWriteJSON(p.memoPath, data)
} }
// ── 待办:未完成计数与提醒 ── // ── 待办:未完成计数与提醒 ──
@ -500,3 +500,12 @@ func (p *Plugin) cleanupData() {
os.Remove(p.memoPath) os.Remove(p.memoPath)
} }
} }
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}

View File

@ -2,14 +2,17 @@
"name": "qq", "name": "qq",
"name_zh": "QQ消息", "name_zh": "QQ消息",
"name_en": "qq", "name_en": "qq",
"version": "1.0.0", "version": "1.1.0",
"description": "QQ 消息收发插件,通过 NapCat 协议桥接", "description": "QQ 消息收发插件,通过 NapCat 协议桥接",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",
"tags": ["qq", "messaging"], "tags": [
"qq",
"messaging"
],
"targets": "linux/amd64", "targets": "linux/amd64",
"outdir": "dist", "outdir": "dist",
"bundle": false, "bundle": false,
"replaces": {}, "replaces": {},
"source_dirs": [] "source_dirs": []
} }

View File

@ -739,6 +739,11 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
// 群消息到达即记录(用于排查 napcat→webhook 链路漏报/丢弃)
if evt.MessageType == "group" {
log.Printf("[qq] webhook recv group msg id=%d from=%d in=%d raw=%.100s",
evt.MessageID, evt.UserID, evt.GroupID, evt.RawMessage)
}
rawCQ := evt.RawMessage rawCQ := evt.RawMessage
text := rawCQ text := rawCQ
@ -763,6 +768,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
} }
if evt.MessageType == "group" { if evt.MessageType == "group" {
if !p.isGroupAllowed(evt.GroupID) { if !p.isGroupAllowed(evt.GroupID) {
log.Printf("[qq] group msg from %d rejected: policy=%s", evt.GroupID, p.groupPolicy)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
@ -773,6 +779,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
return return
} }
if !p.isAtBot(evt.Message) { if !p.isAtBot(evt.Message) {
// 诊断:@ 解析失败时打印 at 段原文与 botID定位漏报问题
log.Printf("[qq] group msg from %d/%d not @bot (botID=%d, raw=%.120s)",
evt.GroupID, evt.UserID, p.botID, rawCQ)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
@ -810,6 +819,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
if evt.GroupID == rule.GroupID { if evt.GroupID == rule.GroupID {
mcMsg := fmt.Sprintf("%s 说 %s", nickname, text) mcMsg := fmt.Sprintf("%s 说 %s", nickname, text)
go func(r ForwardRule, msg string) { go func(r ForwardRule, msg string) {
defer func() { _ = recover() }()
if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil { if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil {
log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err) log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err)
} }
@ -980,6 +990,7 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
// 异步标记已读 // 异步标记已读
go func() { go func() {
defer func() { _ = recover() }() // 后台任务不允许 panic 冒泡带崩进程
if d.MessageType == "group" && d.GroupID > 0 { if d.MessageType == "group" && d.GroupID > 0 {
p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID}) p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID})
} else if d.UserID > 0 { } else if d.UserID > 0 {
@ -1070,20 +1081,32 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
} }
return p.napcat("send_private_msg", msg) return p.napcat("send_private_msg", msg)
case "image": case "image", "file":
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:image,file=%s]", payload)} // 收敛到 output 通道payload 支持本地路径或 http(s) URL。
if groupID != 0 { // 本地路径拷入 NapCat 共享目录转 file:// URI与 voice 分支同模式),
msg["group_id"] = groupID // 此后 agent 发本地文件不再需要单独的 upload_group_file 工具。
} else { uri := payload
msg["user_id"] = userID if !strings.HasPrefix(payload, "http://") && !strings.HasPrefix(payload, "https://") &&
!strings.HasPrefix(payload, "file://") {
if _, err := os.Stat(payload); err != nil {
return nil, fmt.Errorf("%s 文件不存在: %s", rawType, payload)
}
os.MkdirAll(p.remoteDir, 0755)
dest := filepath.Join(p.remoteDir, sanitizeFilename(filepath.Base(payload)))
data, err := os.ReadFile(payload)
if err != nil {
return nil, fmt.Errorf("读取文件失败: %w", err)
}
if err := os.WriteFile(dest, data, 0644); err != nil {
return nil, fmt.Errorf("写入共享目录失败: %w", err)
}
uri = "file:///app/files/" + filepath.Base(dest)
} }
if groupID != 0 { cqTag := "file"
return p.napcat("send_group_msg", msg) if rawType == "image" {
cqTag = "image"
} }
return p.napcat("send_private_msg", msg) msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri)}
case "file":
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:file,file=%s]", payload)}
if groupID != 0 { if groupID != 0 {
msg["group_id"] = groupID msg["group_id"] = groupID
} else { } else {
@ -1638,7 +1661,8 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{},
return resp, nil return resp, nil
} }
dlURL := parsed.Data.URL dlURL := parsed.Data.URL
httpResp, err := http.Get(dlURL) client := &http.Client{Timeout: 120 * time.Second}
httpResp, err := client.Get(dlURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("download: %w", err) return nil, fmt.Errorf("download: %w", err)
} }
@ -1693,6 +1717,11 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
task := p.addDownloadTask(fileID, filename) task := p.addDownloadTask(fileID, filename)
go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) { go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) {
defer func() {
if r := recover(); r != nil {
log.Printf("[qq] download task %s panic: %v", fid, r)
}
}()
savePath := "" savePath := ""
errMsg := "" errMsg := ""
if furl != "" { if furl != "" {

View File

@ -0,0 +1,153 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
var realCfg = "/home/newqqagent/config.db"
var realLog = "/home/newqqagent/log"
func TestDiagTriage(t *testing.T) {
p := &Plugin{name: "recoverydiag"}
cases := []struct {
name string
args map[string]interface{}
want string
}{
{"signal", map[string]interface{}{"exit_code": 0, "signal": "SIGSEGV"}, "process_death"},
{"oom", map[string]interface{}{"exit_code": 0, "signal": "SIGKILL", "crash_reason": "oom-kill"}, "process_starvation"},
{"nonzero", map[string]interface{}{"exit_code": 1}, "process_death"},
{"healthy", map[string]interface{}{"exit_code": 0}, "normal_stop"},
{"alive", map[string]interface{}{"still_alive": true, "signal": "SIGKILL"}, "config_unreachable"},
}
for _, c := range cases {
r, _ := p.handleTriage(c.args)
m, ok := r.(map[string]interface{})
if !ok {
t.Fatalf("%s: not a map", c.name)
}
if got, _ := m["class"].(string); got != c.want {
t.Errorf("%s: class = %q, want %q", c.name, got, c.want)
}
}
}
func TestDiagDB(t *testing.T) {
if _, err := os.Stat(realCfg); err != nil {
t.Skip("config.db not present, skipping")
}
p := &Plugin{name: "recoverydiag"}
r, err := p.handleDB(map[string]interface{}{"db_path": realCfg})
if err != nil {
t.Fatalf("handleDB: %v", err)
}
m := r.(map[string]interface{})
t.Logf("integrity=%v sources=%v verdict=%v summary=%v", m["integrity"], m["source_count"], m["verdict"], m["summary"])
if m["integrity"] != "ok" {
t.Errorf("integrity = %v, want ok", m["integrity"])
}
if m["source_count"] == 0 {
t.Errorf("source_count == 0, expected LLM sources")
}
if got, _ := m["source_failed"].(int); got != 0 {
t.Errorf("source_failed = %d, want 0 (all sources OK): %v", got, m["missing_fields"])
}
}
func TestDiagLogScan(t *testing.T) {
if _, err := os.Stat(realLog); err != nil {
t.Skip("log dir not present, skipping")
}
p := &Plugin{name: "recoverydiag"}
r, err := p.handleLogScan(map[string]interface{}{
"log_dir": realLog,
"since_minutes": 60 * 24 * 3,
})
if err != nil {
t.Fatalf("handleLogScan: %v", err)
}
m := r.(map[string]interface{})
t.Logf("matched=%v counts=%v dominant=%v conclusion=%v", m["lines_matched"], m["counts"], m["dominant"], m["conclusion"])
}
func TestDiagDelta(t *testing.T) {
base := t.TempDir()
cur := t.TempDir()
sub := filepath.Join(base, "sub")
os.MkdirAll(sub, 0755)
// modified: same path, different content
os.WriteFile(filepath.Join(base, "a.txt"), []byte("hello"), 0644)
os.WriteFile(filepath.Join(cur, "a.txt"), []byte("world!"), 0644)
// created
os.WriteFile(filepath.Join(cur, "b.txt"), []byte("new"), 0644)
// deleted
os.WriteFile(filepath.Join(base, "gone.txt"), []byte("bye"), 0644)
// unchanged
os.WriteFile(filepath.Join(base, "same.txt"), []byte("x"), 0644)
os.WriteFile(filepath.Join(cur, "same.txt"), []byte("x"), 0644)
p := &Plugin{name: "recoverydiag"}
r, err := p.handleDelta(map[string]interface{}{"baseline_dir": base, "current_dir": cur})
if err != nil {
t.Fatalf("handleDelta: %v", err)
}
m := r.(map[string]interface{})
sum := m["summary"].(map[string]int)
t.Logf("summary=%v total=%v", sum, m["total_diff"])
if sum["created"] != 1 || sum["deleted"] != 1 || sum["modified"] != 1 {
t.Errorf("summary = %v, want modified=1 created=1 deleted=1", sum)
}
}
func TestDiagLoc(t *testing.T) {
p := &Plugin{name: "recoverydiag"}
r, _ := p.handleLoc(map[string]interface{}{
"triage": map[string]interface{}{"class": "process_death", "verdict": "down"},
"db": map[string]interface{}{"verdict": "ok"},
"log_scan": map[string]interface{}{"dominant": "panic"},
"delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}},
})
m := r.(map[string]interface{})
// 经 JSON 往返,模拟内核把子结论以 JSON 传给 diag_loc 的真实路径
raw, _ := json.Marshal(m)
var dec map[string]interface{}
json.Unmarshal(raw, &dec)
hs := dec["ranked_hypotheses"].([]interface{})
if len(hs) == 0 {
t.Fatal("no hypotheses")
}
top := hs[0].(map[string]interface{})
t.Logf("top cause=%v conf=%v rec=%v", top["cause"], top["confidence"], top["recommendation"])
if top["cause"] != "code_panic_loop" {
t.Errorf("expected code_panic_loop, got %v", top["cause"])
}
}
func TestDiagLocPersist(t *testing.T) {
kb := filepath.Join(t.TempDir(), "recovery_kb")
p := &Plugin{name: "recoverydiag", dataDir: filepath.Dir(kb)}
args := map[string]interface{}{
"persist": true,
"triage": map[string]interface{}{"class": "process_death", "verdict": "down"},
"db": map[string]interface{}{"verdict": "ok"},
"log_scan": map[string]interface{}{"dominant": "panic"},
"delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}},
}
if _, err := p.handleLoc(args); err != nil {
t.Fatalf("handleLoc: %v", err)
}
entries, err := os.ReadDir(kb)
if err != nil || len(entries) == 0 {
t.Fatalf("expected persisted diag json, got err=%v entries=%v", err, entries)
}
data, _ := os.ReadFile(filepath.Join(kb, entries[0].Name()))
if !strings.Contains(string(data), `"cause"`) {
t.Errorf("persisted file missing cause field: %s", data)
}
}

View File

@ -0,0 +1,21 @@
{
"name": "recoverydiag",
"name_zh": "恢复诊断",
"name_en": "Recovery Diagnostics",
"version": "0.2.0",
"description": "快速检查/崩溃取证工具集diag_triage退出码/信号/存活粗分、diag_dbconfig.db 完整性 + LLM 源解析校验、diag_log_scan日志签名命中、diag_deltalast-good 快照 vs 现状 diff、diag_loc正交综合定位。全部返回结论而非原文确定性、不消耗 LLM token供 guard / failback 恢复决策使用。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"diag",
"recovery",
"diagnostics",
"triage",
"failback"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

File diff suppressed because it is too large Load Diff

View File

@ -2,14 +2,19 @@
"name": "rss", "name": "rss",
"name_zh": "RSS订阅", "name_zh": "RSS订阅",
"name_en": "RSS", "name_en": "RSS",
"version": "1.0.0", "version": "1.1.0",
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知", "description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",
"tags": ["rss", "feed", "subscription", "monitor"], "tags": [
"rss",
"feed",
"subscription",
"monitor"
],
"targets": "linux/amd64", "targets": "linux/amd64",
"outdir": "dist", "outdir": "dist",
"bundle": true, "bundle": true,
"replaces": {}, "replaces": {},
"source_dirs": [] "source_dirs": []
} }

View File

@ -467,7 +467,7 @@ func (p *Plugin) saveData() {
SeenGUIDs: p.seenGUIDs, SeenGUIDs: p.seenGUIDs,
} }
b, _ := json.MarshalIndent(data, "", " ") b, _ := json.MarshalIndent(data, "", " ")
os.WriteFile(p.dataFile(), b, 0644) atomicWriteJSON(p.dataFile(), b)
} }
// cleanupData 卸载时清理订阅数据目录feeds.json 等) // cleanupData 卸载时清理订阅数据目录feeds.json 等)
@ -486,3 +486,12 @@ func (p *Plugin) cleanupData() {
} }
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}