diff --git a/third_party/homeagent-sdk/example/browser/plg.json b/third_party/homeagent-sdk/example/browser/plg.json new file mode 100644 index 0000000..d831afa --- /dev/null +++ b/third_party/homeagent-sdk/example/browser/plg.json @@ -0,0 +1,21 @@ +{ + "name": "browser", + "name_zh": "浏览器", + "name_en": "Browser", + "version": "2.2.0", + "description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": [ + "web", + "search", + "fetch", + "browser", + "cdp" + ], + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] +} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/browser/plugin.go b/third_party/homeagent-sdk/example/browser/plugin.go new file mode 100644 index 0000000..d55d96d --- /dev/null +++ b/third_party/homeagent-sdk/example/browser/plugin.go @@ -0,0 +1,1280 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + "unicode" + + sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" + + "github.com/chromedp/chromedp" +) + +type Plugin struct { + name string + sdk *sdk.PluginSDK + mu sync.RWMutex + timeout int + proxy string + client *http.Client + + sessions map[string]*BrowserSession + nextID int + wg sync.WaitGroup + stopCh chan struct{} + stopOnce sync.Once + profilesDir string // 持久化 profile 根目录(/browser_profiles),空则禁用 + + // 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir, + // 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个 + // 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在 + // 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。 + sharedAllocCtx context.Context + sharedAllocCancel context.CancelFunc + sharedMu sync.Mutex +} + +type BrowserSession struct { + id string + allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例) + cancel context.CancelFunc + ctx context.Context // 本会话的 Target 上下文(一个标签页) + createdAt time.Time + timeout time.Duration + closed bool + mu sync.Mutex + 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) { + return &Plugin{name: name, stopCh: make(chan struct{}), sessions: make(map[string]*BrowserSession)}, nil +} + +func (p *Plugin) Name() string { return p.name } + +func readCfg[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T { + v, err := s.Get(key) + if err == nil && v != nil { + if s, ok := v.(string); ok && s != "" { + switch any(fallback).(type) { + case string: + return any(s).(T) + case int64: + if n, err := strconv.ParseInt(s, 10, 64); err == nil { + return any(n).(T) + } + case float64: + if n, err := strconv.ParseFloat(s, 64); err == nil { + return any(n).(T) + } + } + } + } + v2, err2 := s.GetCore("plugin." + "browser" + "." + key) + if err2 == nil && v2 != nil { + if s, ok := v2.(string); ok && s != "" { + switch any(fallback).(type) { + case string: + return any(s).(T) + case int64: + if n, err := strconv.ParseInt(s, 10, 64); err == nil { + return any(n).(T) + } + case float64: + if n, err := strconv.ParseFloat(s, 64); err == nil { + return any(n).(T) + } + } + } + } + return fallback +} + +func readArg[T string | int64 | float64](args map[string]interface{}, key string, fallback T) T { + v, ok := args[key] + if !ok || v == nil { + return fallback + } + switch any(fallback).(type) { + case string: + if s, ok := v.(string); ok { + return any(s).(T) + } + case int64: + switch val := v.(type) { + case float64: + return any(int64(val)).(T) + case string: + if n, err := strconv.ParseInt(val, 10, 64); err == nil { + return any(n).(T) + } + } + case float64: + switch val := v.(type) { + case float64: + return any(val).(T) + case string: + if n, err := strconv.ParseFloat(val, 64); err == nil { + return any(n).(T) + } + } + } + return fallback +} + +func errResult(msg string) map[string]interface{} { + return map[string]interface{}{"isError": true, "content": msg} +} + +func newHTTPClient(timeout int, proxyURL string) *http.Client { + transport := &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: time.Duration(timeout) * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: time.Duration(timeout) * time.Second, + ResponseHeaderTimeout: time.Duration(timeout) * time.Second, + } + if proxyURL != "" { + if u, err := url.Parse(proxyURL); err == nil { + transport.Proxy = http.ProxyURL(u) + } + } + return &http.Client{ + Timeout: time.Duration(timeout) * time.Second, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("too many redirects") + } + return nil + }, + } + +} + +// ── Start / Stop ────────────────────────────────────────── + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.sdk = s + s.SetAutoRestart(true) + + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "timeout", Default: "30", Type: "int", + DisplayName: "HTTP 超时(秒)", Description: "HTTP 请求超时时间", + Category: "browser", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "proxy", Default: "", Type: "string", + DisplayName: "HTTP 代理", Description: "HTTP 代理地址,如 http://proxy:port", + Category: "browser", + }) + + t := readCfg(s.Settings(), "timeout", float64(30)) + p.timeout = int(t) + if p.timeout < 5 { + p.timeout = 5 + } + if p.timeout > 120 { + p.timeout = 120 + } + p.proxy = readCfg(s.Settings(), "proxy", "") + p.client = newHTTPClient(p.timeout, p.proxy) + + // 持久化 profile 根目录:/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 + "_" + + cleaner := func(output string) string { + var r struct{ Content string } + if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" { + return r.Content + } + return output + } + + s.RegisterTool(tp+"search", sdk.ToolDef{ + Name: tp + "search", + Description: "使用 Bing 搜索网页。返回标题、URL 和摘要。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{"type": "string", "description": "搜索关键词"}, + "count": map[string]interface{}{"type": "integer", "description": "结果数量(1-20,默认5)"}, + }, + "required": []string{"query"}, + }, + Cleaner: cleaner, + }, p.handleSearch) + + s.RegisterTool(tp+"fetch", sdk.ToolDef{ + Name: tp + "fetch", + Description: "抓取 URL 内容。mode=auto 时遇 403/429 自动降级用无头 Chromium 渲染;mode=render 强制用 Chromium;mode=quick 纯 HTTP 不降级。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL"}, + "max_chars": map[string]interface{}{"type": "integer", "description": "最大返回字符数(默认20000)"}, + "mode": map[string]interface{}{"type": "string", "description": "auto(默认)/render(强制Chromium)/quick(纯HTTP)"}, + }, + "required": []string{"url"}, + }, + Cleaner: cleaner, + }, p.handleFetch) + + s.RegisterTool(tp+"render", sdk.ToolDef{ + Name: tp + "render", + Description: "无头 Chromium 渲染网页并提取文本(normal 模式)。支持 JS 渲染的页面。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "url": map[string]interface{}{"type": "string", "description": "URL"}, + "wait": map[string]interface{}{"type": "integer", "description": "等待 JS 渲染的秒数(默认0)"}, + }, + "required": []string{"url"}, + }, + Cleaner: cleaner, + }, p.handleRender) + + s.RegisterTool(tp+"start", sdk.ToolDef{ + Name: tp + "start", + Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install);无法安装时自动降级本地临时模式。同来源复用已有标签页。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"}, + "timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"}, + "profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"}, + }, + }, + }, p.handleBrowserStart) + + s.RegisterTool(tp+"navigate", sdk.ToolDef{ + Name: tp + "navigate", + Description: "在交互式浏览器中导航到指定 URL。自动等待页面 body 加载完成。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + "url": map[string]interface{}{"type": "string", "description": "目标 URL"}, + "wait": map[string]interface{}{"type": "integer", "description": "页面加载后额外等待秒数(默认2,反爬页面建议5)"}, + }, + "required": []string{"id", "url"}, + }, + }, p.handleNavigate) + + s.RegisterTool(tp+"screenshot", sdk.ToolDef{ + Name: tp + "screenshot", + Description: "对交互式浏览器当前页面截图。返回 base64 编码的 PNG 图片。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + "full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false,仅视口)"}, + "format": map[string]interface{}{"type": "string", "description": "图片格式: 仅支持 png(默认 png)"}, + }, + "required": []string{"id"}, + }, + }, p.handleScreenshot) + + s.RegisterTool(tp+"html", sdk.ToolDef{ + Name: tp + "html", + Description: "获取交互式浏览器当前页面 JS 渲染后的完整 HTML。用于模型分析页面结构、定位元素。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + "max_chars": map[string]interface{}{"type": "integer", "description": "最大返回字符数(默认50000)"}, + }, + "required": []string{"id"}, + }, + }, p.handleHTML) + + s.RegisterTool(tp+"click", sdk.ToolDef{ + Name: tp + "click", + Description: "在交互式浏览器中点击元素。自动等待元素可见后再点击。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + "selector": map[string]interface{}{"type": "string", "description": "CSS 选择器"}, + "wait": map[string]interface{}{"type": "integer", "description": "等待元素出现的超时毫秒数(默认3000)"}, + }, + "required": []string{"id", "selector"}, + }, + }, p.handleClick) + + s.RegisterTool(tp+"type", sdk.ToolDef{ + Name: tp + "type", + Description: "在交互式浏览器中向输入框输入文字。自动等待元素可见、聚焦后清空再输入。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + "selector": map[string]interface{}{"type": "string", "description": "CSS 选择器"}, + "text": map[string]interface{}{"type": "string", "description": "要输入的文字"}, + "submit": map[string]interface{}{"type": "boolean", "description": "输入后按回车(默认 false)"}, + "wait": map[string]interface{}{"type": "integer", "description": "等待元素出现的超时毫秒数(默认3000)"}, + }, + "required": []string{"id", "selector", "text"}, + }, + }, p.handleType) + + s.RegisterTool(tp+"scroll", sdk.ToolDef{ + Name: tp + "scroll", + Description: "在交互式浏览器中滚动页面。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + "dir": map[string]interface{}{"type": "string", "description": "方向: up, down, left, right(默认 down)"}, + "amount": map[string]interface{}{"type": "integer", "description": "滚动像素数(默认 500)"}, + }, + "required": []string{"id"}, + }, + }, p.handleScroll) + + s.RegisterTool(tp+"install", sdk.ToolDef{ + Name: tp + "install", + Description: "安装并启动共享浏览器后端(homeagent-browser.service,systemd 托管)。前提:本机已有 chromium 二进制(无则先提示用户安装:apt install chromium 或等价命令)。安装后所有 agent 共享同一浏览器实例与登录态。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, p.handleBrowserInstall) + + s.RegisterTool(tp+"close", sdk.ToolDef{ + Name: tp + "close", + Description: "关闭交互式浏览器会话,释放资源。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + }, + "required": []string{"id"}, + }, + }, p.handleBrowserClose) + + p.wg.Add(1) + go p.cleanupLoop() + + log.Printf("[%s] started, timeout=%ds proxy=%q", p.name, p.timeout, p.proxy) + return nil +} + +func (p *Plugin) Stop() error { + p.stopOnce.Do(func() { + close(p.stopCh) + p.wg.Wait() + if p.client != nil { + p.client.CloseIdleConnections() + } + p.mu.Lock() + for _, s := range p.sessions { + s.Close() + } + p.sessions = nil + p.mu.Unlock() + log.Printf("[%s] stopped", p.name) + }) + return nil +} + +// ── SSRF ────────────────────────────────────────────────── + +var privateCIDRs []*net.IPNet + +func init() { + for _, c := range []string{ + "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", + "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16", + "::1/128", "fc00::/7", "fe80::/10", + } { + _, n, _ := net.ParseCIDR(c) + if n != nil { + privateCIDRs = append(privateCIDRs, n) + } + } +} + +func isPrivateIP(ip net.IP) bool { + for _, n := range privateCIDRs { + if n.Contains(ip) { + return true + } + } + return false +} + +func (p *Plugin) ssrfCheck(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("only http/https allowed, got: %s", u.Scheme) + } + ips, err := net.LookupHost(u.Hostname()) + if err != nil { + return fmt.Errorf("DNS lookup failed: %w", err) + } + for _, ip := range ips { + if parsed := net.ParseIP(ip); parsed != nil && isPrivateIP(parsed) { + return fmt.Errorf("blocked request to private IP: %s (%s)", u.Hostname(), ip) + } + } + return nil +} + +// ── Bing Search ─────────────────────────────────────────── + +type searchResult struct { + Title, URL, Snippet string +} + +func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) { + u := fmt.Sprintf("https://www.bing.com/search?q=%s&count=%d", url.QueryEscape(query), count) + req, _ := http.NewRequest("GET", u, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return parseBingResults(string(body), count), nil +} + +func parseBingResults(html string, count int) []searchResult { + var results []searchResult + re := regexp.MustCompile(`
  • `) + matches := re.FindAllStringSubmatch(html, -1) + for _, m := range matches { + if len(results) >= count { + break + } + block := m[1] + var r searchResult + hrefRe := regexp.MustCompile(`]+href="([^"]+)"[^>]*>`) + if hm := hrefRe.FindStringSubmatch(block); len(hm) > 1 { + r.URL = hm[1] + } + titleRe := regexp.MustCompile(`]+href="[^"]+"[^>]*>(.*?)`) + if tm := titleRe.FindStringSubmatch(block); len(tm) > 1 { + r.Title = stripTags(tm[1]) + } + snipRe := regexp.MustCompile(`
    .*?

    (.*?)

    `) + if sm := snipRe.FindStringSubmatch(block); len(sm) > 1 { + r.Snippet = stripTags(sm[1]) + } + if r.URL != "" && r.Title != "" { + results = append(results, r) + } + } + return results +} + +func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) { + query := readArg(args, "query", "") + if query == "" { + return errResult("query is required"), nil + } + count := 5 + if v, ok := args["count"].(float64); ok && v > 0 { + count = int(v) + } + if count < 1 { + count = 1 + } + if count > 20 { + count = 20 + } + results, err := p.bingSearch(query, count) + if err != nil { + return errResult("search failed: " + err.Error()), nil + } + if len(results) == 0 { + return map[string]interface{}{"content": "No results found."}, nil + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Search results for %q:\n\n", query)) + for i, r := range results { + sb.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet)) + } + return map[string]interface{}{"content": strings.TrimSpace(sb.String())}, nil +} + +// ── Web Fetch (quick) ────────────────────────────────────── + +func htmlToText(html string) string { + for _, tag := range []string{"" + for { + start := strings.Index(strings.ToLower(html), tag) + if start < 0 { + break + } + end := strings.Index(html[start:], closing) + if end < 0 { + break + } + html = html[:start] + html[start+end+len(closing):] + } + } + for _, tag := range []string{"

    ", "
    ", "", "", "", "", "", "", "
  • ", "", "", ""} { + html = strings.ReplaceAll(html, tag, "\n") + } + html = stripTags(html) + for _, pair := range [][2]string{ + {"&", "&"}, {"<", "<"}, {">", ">"}, + {""", "\""}, {"'", "'"}, {" ", " "}, + } { + html = strings.ReplaceAll(html, pair[0], pair[1]) + } + lines := strings.Split(html, "\n") + var cleaned []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + in := []rune(line) + var out []rune + space := false + for _, r := range in { + if unicode.IsSpace(r) { + if !space { + out = append(out, ' ') + space = true + } + } else { + out = append(out, r) + space = false + } + } + cleaned = append(cleaned, string(out)) + } + return strings.Join(cleaned, "\n") +} + +func stripTags(s string) string { + var out strings.Builder + inTag := false + for _, r := range s { + if r == '<' { + inTag = true + continue + } + if r == '>' { + inTag = false + continue + } + if !inTag { + out.WriteRune(r) + } + } + return out.String() +} + +func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) { + rawURL := readArg(args, "url", "") + if rawURL == "" { + return errResult("url is required"), nil + } + maxChars := 20000 + if v, ok := args["max_chars"].(float64); ok && v > 0 { + maxChars = int(v) + } + if maxChars > 500000 { + maxChars = 500000 + } + if err := p.ssrfCheck(rawURL); err != nil { + return errResult(err.Error()), nil + } + + mode := readArg(args, "mode", "auto") + if mode == "render" { + return p.fetchWithChromium(rawURL, maxChars) + } + + req, _ := http.NewRequest("GET", rawURL, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + resp, err := p.client.Do(req) + if err != nil { + return errResult("fetch failed: " + err.Error()), nil + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + resp.Body.Close() + if (resp.StatusCode == 403 || resp.StatusCode == 429) && mode == "auto" { + return p.fetchWithChromium(rawURL, maxChars) + } + return errResult(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)), nil + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, int64(maxChars)+50000)) + rawText := string(body) + ct := resp.Header.Get("Content-Type") + var extracted string + if strings.Contains(ct, "text/html") { + extracted = htmlToText(rawText) + } else if strings.Contains(ct, "application/json") { + var v interface{} + if json.Unmarshal(body, &v) == nil { + if pretty, err := json.MarshalIndent(v, "", " "); err == nil { + extracted = string(pretty) + } + } + if extracted == "" { + extracted = rawText + } + } else { + extracted = rawText + } + extracted = strings.TrimSpace(extracted) + if len(extracted) > maxChars { + extracted = extracted[:maxChars] + "\n\n[Content truncated]" + } + if extracted == "" { + extracted = "(empty content)" + } + return map[string]interface{}{ + "content": extracted, + "details": map[string]interface{}{"url": rawURL, "status": resp.StatusCode, "content_type": ct}, + }, nil +} + +// ── Chromium Render (normal) ────────────────────────────── + +func (p *Plugin) fetchWithChromium(rawURL string, maxChars int) (interface{}, error) { + chromiumPath := "/usr/local/bin/chromium" + if _, err := os.Stat(chromiumPath); err != nil { + return errResult("HTTP 403 且 Chromium 不可用,无法降级渲染"), nil + } + var out bytes.Buffer + cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL) + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return errResult("Chromium 渲染失败: " + err.Error()), nil + } + text := htmlToText(out.String()) + if len(text) > maxChars { + text = text[:maxChars] + "\n\n[Content truncated]" + } + return map[string]interface{}{ + "content": text, + "details": map[string]interface{}{"url": rawURL, "mode": "render_fallback", "content_type": "text/html"}, + }, nil +} + +func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) { + rawURL := readArg(args, "url", "") + if rawURL == "" { + return errResult("url is required"), nil + } + if err := p.ssrfCheck(rawURL); err != nil { + return errResult(err.Error()), nil + } + waitSec := int64(readArg(args, "wait", float64(0))) + if waitSec > 0 { + time.Sleep(time.Duration(waitSec) * time.Second) + } + var html string + chromiumPath := "/usr/local/bin/chromium" + if _, err := os.Stat(chromiumPath); err == nil { + var out bytes.Buffer + cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL) + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return errResult("chromium: " + err.Error()), nil + } + 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(`([^<]+)`).FindStringSubmatch(html); len(m) > 1 { + title = m[1] + } + text := htmlToText(html) + origLen := len(text) + truncated := origLen > 5000 + if truncated { + text = text[:5000] + } + result := "" + if title != "" { + result = fmt.Sprintf("标题: %s\nURL: %s\n\n", title, rawURL) + } + result += text + if truncated { + result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen) + } + return map[string]interface{}{"content": result, "title": title}, nil +} + +// ── Interactive Browser Session (CDP) ───────────────────── + +// ── 共享浏览器后端(systemd 托管)+ 本地 spawn failback ────────── + +// cdpEndpoint 是共享 Chromium 后端的 CDP 地址(homeagent-browser.service)。 +const cdpEndpoint = "http://127.0.0.1:9222" + +// cdpReachable 探测浏览器后端是否在线(GET /json/version)。 +func cdpReachable(endpoint string) bool { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(endpoint + "/json/version") + if err != nil { + 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() +} + +// 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[:], + chromedp.Flag("headless", true), + chromedp.Flag("disable-gpu", true), + chromedp.Flag("no-sandbox", true), + chromedp.WindowSize(1280, 800), + ) + if p.proxy != "" { + opts = append(opts, chromedp.Flag("proxy-server", p.proxy)) + } + allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...) + ctx, _ := chromedp.NewContext(allocCtx) + if err := chromedp.Run(ctx); err != nil { + cancelAlloc() + 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 + } + + source := readArg(args, "source", "") + if source == "" { + source = "default" + } + + // 同 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.nextID++ + id := fmt.Sprintf("browser_%d", p.nextID) + session.id = id + p.sessions[id] = session + p.mu.Unlock() + + initURL := readArg(args, "url", "") + if initURL != "" { + if err := chromedp.Run(session.ctx, + chromedp.Navigate(initURL), + chromedp.WaitReady("body"), + ); err != nil { + session.Close() + p.mu.Lock() + delete(p.sessions, id) + p.mu.Unlock() + return errResult("navigate failed: " + err.Error()), nil + } + session.currentURL = initURL + } + + log.Printf("[%s] created browser session %s: url=%s timeout=%v source=%s", p.name, id, initURL, timeout, source) + return map[string]interface{}{ + "id": id, + "status": "created", + "mode": "shared-backend", + "url": initURL, + "timeout": timeout.String(), + }, nil +} + +func (p *Plugin) getSession(id string) (*BrowserSession, error) { + p.mu.Lock() + s, ok := p.sessions[id] + p.mu.Unlock() + if !ok { + return nil, fmt.Errorf("浏览器会话 %s 不存在或已关闭", id) + } + return s, nil +} + +func (p *Plugin) handleNavigate(args map[string]interface{}) (interface{}, error) { + id := readArg(args, "id", "") + rawURL := readArg(args, "url", "") + if id == "" || rawURL == "" { + return errResult("id 和 url 不能为空"), nil + } + s, err := p.getSession(id) + if err != nil { + return errResult(err.Error()), nil + } + waitSec := int64(readArg(args, "wait", float64(2))) + if err := chromedp.Run(s.ctx, + chromedp.Navigate(rawURL), + chromedp.WaitReady("body"), + chromedp.Sleep(time.Duration(waitSec)*time.Second), + ); err != nil { + return errResult("navigate failed: " + err.Error()), nil + } + s.currentURL = rawURL + p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL)) + return map[string]interface{}{"status": "ok", "url": rawURL}, nil +} + +func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, error) { + id := readArg(args, "id", "") + if id == "" { + return errResult("id is required"), nil + } + s, sessErr := p.getSession(id) + if sessErr != nil { + return errResult(sessErr.Error()), nil + } + full := false + if v, ok := args["full"].(bool); ok { + full = v + } + format := readArg(args, "format", "png") + if format != "png" { + return errResult("仅支持 png 格式"), nil + } + var buf []byte + var err error + if full { + err = chromedp.Run(s.ctx, chromedp.FullScreenshot(&buf, 90)) + } else { + err = chromedp.Run(s.ctx, chromedp.Screenshot("body", &buf)) + } + if err != nil { + return errResult("screenshot failed: " + err.Error()), nil + } + b64 := base64.StdEncoding.EncodeToString(buf) + return map[string]interface{}{ + "status": "ok", + "format": format, + "size": len(buf), + "base64": b64, + "data_uri": fmt.Sprintf("data:image/png;base64,%s", b64), + }, nil +} + +func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) { + id := readArg(args, "id", "") + if id == "" { + return errResult("id is required"), nil + } + s, err := p.getSession(id) + if err != nil { + return errResult(err.Error()), nil + } + maxChars := 50000 + if v, ok := args["max_chars"].(float64); ok && v > 0 { + maxChars = int(v) + } + var html string + if err := chromedp.Run(s.ctx, chromedp.OuterHTML("html", &html)); err != nil { + return errResult("get html failed: " + err.Error()), nil + } + var title, currentURL string + chromedp.Run(s.ctx, + chromedp.Title(&title), + chromedp.Location(¤tURL), + ) + truncated := len(html) > maxChars + if truncated { + html = html[:maxChars] + "\n\n[HTML truncated]" + } + return map[string]interface{}{ + "status": "ok", + "title": title, + "url": currentURL, + "html": html, + "length": len(html), + }, nil +} + +func (p *Plugin) handleClick(args map[string]interface{}) (interface{}, error) { + id := readArg(args, "id", "") + selector := readArg(args, "selector", "") + if id == "" || selector == "" { + return errResult("id 和 selector 不能为空"), nil + } + s, err := p.getSession(id) + if err != nil { + return errResult(err.Error()), nil + } + if err := chromedp.Run(s.ctx, + chromedp.WaitVisible(selector), + chromedp.Click(selector), + ); err != nil { + return errResult("click failed (element may not exist or page blocking): " + err.Error()), nil + } + return map[string]interface{}{"status": "ok", "selector": selector}, nil +} + +func (p *Plugin) handleType(args map[string]interface{}) (interface{}, error) { + id := readArg(args, "id", "") + selector := readArg(args, "selector", "") + text := readArg(args, "text", "") + if id == "" || selector == "" || text == "" { + return errResult("id, selector, text 不能为空"), nil + } + s, err := p.getSession(id) + if err != nil { + return errResult(err.Error()), nil + } + actions := []chromedp.Action{ + chromedp.WaitVisible(selector), + chromedp.Click(selector, chromedp.NodeVisible), + chromedp.Clear(selector), + chromedp.SendKeys(selector, text), + } + submit := false + if v, ok := args["submit"].(bool); ok { + submit = v + } + if submit { + actions = append(actions, chromedp.SendKeys(selector, "\r")) + } + if err := chromedp.Run(s.ctx, actions...); err != nil { + return errResult("type failed (element may not exist or page blocking): " + err.Error()), nil + } + return map[string]interface{}{"status": "ok", "selector": selector}, nil +} + +func (p *Plugin) handleScroll(args map[string]interface{}) (interface{}, error) { + id := readArg(args, "id", "") + if id == "" { + return errResult("id is required"), nil + } + s, err := p.getSession(id) + if err != nil { + return errResult(err.Error()), nil + } + dir := readArg(args, "dir", "down") + amount := readArg(args, "amount", float64(500)) + var scrollJS string + switch dir { + case "up": + scrollJS = fmt.Sprintf("window.scrollBy(0, -%d)", int(amount)) + case "down": + scrollJS = fmt.Sprintf("window.scrollBy(0, %d)", int(amount)) + case "left": + scrollJS = fmt.Sprintf("window.scrollBy(-%d, 0)", int(amount)) + case "right": + scrollJS = fmt.Sprintf("window.scrollBy(%d, 0)", int(amount)) + default: + return errResult("dir 必须是 up/down/left/right"), nil + } + if err := chromedp.Run(s.ctx, chromedp.Evaluate(scrollJS, nil)); err != nil { + return errResult("scroll failed: " + err.Error()), nil + } + return map[string]interface{}{"status": "ok", "dir": dir, "amount": amount}, nil +} + +func (p *Plugin) handleBrowserClose(args map[string]interface{}) (interface{}, error) { + id := readArg(args, "id", "") + if id == "" { + return errResult("id is required"), nil + } + p.mu.Lock() + s, ok := p.sessions[id] + if ok { + delete(p.sessions, id) + } + p.mu.Unlock() + if !ok { + return errResult(fmt.Sprintf("浏览器会话 %s 不存在或已关闭", id)), nil + } + s.Close() + log.Printf("[%s] closed browser session %s", p.name, id) + return map[string]interface{}{"status": "closed", "id": id}, nil +} + +func (s *BrowserSession) Close() { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return + } + s.closed = true + s.cancel() +} + +func (p *Plugin) cleanupLoop() { + defer p.wg.Done() + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + p.mu.Lock() + for id, s := range p.sessions { + if time.Since(s.createdAt) >= s.timeout { + log.Printf("[%s] cleanup: browser session %s expired", p.name, id) + delete(p.sessions, id) + s.Close() + p.sdk.InjectInterruptText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id)) + } + } + p.mu.Unlock() + } + } +} + +// ── 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": "请先安装 chromium:apt 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" + + "全机共享说明:本机所有 agent(HomeAgent、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 +}