Files
AutoZero/cdp/chrome.go
T
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
2026-10-26 00:54:46 +08:00

302 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cdp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
neturl "net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
// Browser 管理一个 Chrome 实例(懒启动、进程内单例语义由 tool 层保证):
// 首次使用时以 --remote-debugging-port 启动并轮询就绪;DebugBase 非空则
// 直接连接已有调试端点(测试 / 外部浏览器),不启动进程。
type Browser struct {
DebugBase string // 直接使用的调试基址(http://127.0.0.1:9222)
Bin string // 浏览器可执行文件覆盖;空 = 自动发现
Headless bool // 默认 true;AUTOGO_BROWSER_HEADLESS=0 关闭
ProfileDir string // 用户数据目录;空 = ~/.autogo/browser-profile(持久,登录态留得住)
mu sync.Mutex
cmd *exec.Cmd
base string
current string // 当前 page target id(open/js/screenshot 的作用目标)
actionMu sync.Mutex
}
// Target 是调试端点上的一个 tab(page)。
type Target struct {
ID string `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
Type string `json:"type"`
WebSocketDebuggerURL string `json:"webSocketDebuggerURL"`
}
func (b *Browser) baseURL(ctx context.Context) (string, error) {
if b.DebugBase != "" {
return strings.TrimSuffix(b.DebugBase, "/"), nil
}
b.mu.Lock()
defer b.mu.Unlock()
if b.base != "" {
return b.base, nil
}
bin, err := findBrowser(b.Bin)
if err != nil {
return "", err
}
port, err := freePort()
if err != nil {
return "", err
}
dir := b.ProfileDir
if dir == "" {
// 默认用**持久**目录,不用临时目录:Chrome 的登录态与扩展都在 user-data-dir 里,
// 临时目录等于每次启动都像一台从没登录过的新机器(登录一次也留不住),而且旧的
// 临时目录从来没人清理,会在 /tmp 里越堆越多。
dir, err = PersistentProfileDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
}
// profile 体检结论不在这里直接打印(TUI 的 alt-screen 打不得),而是挂到启动
// 失败的 error 上——那正是用户需要看到它们的时候(见下面 waitDebugReady 分支)。
profileWarns := CheckProfile(dir)
args := []string{
fmt.Sprintf("--remote-debugging-port=%d", port),
"--user-data-dir=" + dir,
"--no-first-run", "--no-default-browser-check",
"--disable-background-networking",
"--window-size=1366,900",
"about:blank",
}
if b.Headless {
args = append([]string{"--headless=new", "--hide-scrollbars"}, args...)
}
cmd := exec.Command(bin, args...)
cmd.Stdout, cmd.Stderr = io.Discard, io.Discard
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("launch %s: %w", bin, err)
}
base := fmt.Sprintf("http://127.0.0.1:%d", port)
if err := waitDebugReady(ctx, base, 10*time.Second); err != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
// 起不来时把 profile 和体检结论一起给出:绝大多数"浏览器连不上"都是 profile
// 相关(被运行中的 Chrome 占着 / 指向了默认目录 / 指向了 Default 子目录)。
msg := fmt.Sprintf("浏览器调试端口没起来(%s,profile=%s):%v", bin, dir, err)
for _, w := range profileWarns {
msg += "\n - " + w
}
if len(profileWarns) == 0 {
msg += "\n - profile 看起来正常;可以先手动跑一下该浏览器确认能启动,或用 browser.debug_base 连接一个已开的调试端口"
}
return "", errors.New(msg)
}
b.cmd, b.base = cmd, base
return base, nil
}
// waitDebugReady 轮询 /json/version 直到调试端点就绪。
func waitDebugReady(ctx context.Context, base string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
client := &http.Client{Timeout: 2 * time.Second}
for time.Now().Before(deadline) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/version", nil)
if req != nil {
if resp, err := client.Do(req); err == nil {
resp.Body.Close()
return nil
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
return fmt.Errorf("debug endpoint %s not ready within %s", base, timeout)
}
func freePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
port := l.Addr().(*net.TCPAddr).Port
l.Close()
return port, nil
}
// findBrowser 按 GOOS 定位浏览器可执行文件(Chrome / Chromium / Edge 均支持 CDP)。
func findBrowser(explicit string) (string, error) {
if explicit != "" {
if _, err := os.Stat(explicit); err == nil {
return explicit, nil
}
if _, err := exec.LookPath(explicit); err == nil {
return explicit, nil
}
return "", fmt.Errorf("AUTOGO_BROWSER_BIN %q not found", explicit)
}
var candidates []string
switch runtime.GOOS {
case "darwin":
candidates = []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
}
case "windows":
pf := os.Getenv("ProgramFiles")
pf86 := os.Getenv("ProgramFiles(x86)")
lap := os.Getenv("LOCALAPPDATA")
// ARM64 Windows 可能安装在 Program Files (Arm) 或 x86 目录
for _, root := range []string{pf, pf86, lap} {
if root == "" {
continue
}
candidates = append(candidates,
filepath.Join(root, `Google\Chrome\Application\chrome.exe`),
filepath.Join(root, `Microsoft\Edge\Application\msedge.exe`),
)
}
// Chrome SxS (Canary) 和 Edge Beta 也常见于 ARM64
if lap != "" {
candidates = append(candidates,
filepath.Join(lap, `Google\Chrome SxS\Application\chrome.exe`),
)
}
default: // linux 等 unix
// 标准 PATH 查找
for _, name := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "microsoft-edge-stable"} {
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
}
// Flatpak 路径
flatpakPaths := []string{
"/var/lib/flatpak/exports/bin/com.google.Chrome",
"/var/lib/flatpak/exports/bin/org.chromium.Chromium",
"/home/*/.local/share/flatpak/exports/bin/com.google.Chrome",
"/home/*/.local/share/flatpak/exports/bin/org.chromium.Chromium",
}
for _, p := range flatpakPaths {
if strings.Contains(p, "*") {
// 展开 home 目录通配符
home, _ := os.UserHomeDir()
if home != "" {
p = strings.Replace(p, "/home/*", home, 1)
}
}
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
// Snap 路径
snapPaths := []string{
"/snap/chromium/current/usr/bin/chromium",
"/snap/google-chrome/current/usr/bin/google-chrome",
}
for _, p := range snapPaths {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
for _, name := range []string{"google-chrome", "chromium", "chrome"} {
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
}
return "", fmt.Errorf("no Chrome/Chromium/Edge found (set AUTOGO_BROWSER_BIN; candidates tried: %v)", candidates)
}
// ListTargets 列出调试端点上的全部 target。
func (b *Browser) ListTargets(ctx context.Context) ([]Target, error) {
base, err := b.baseURL(ctx)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/list", nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var targets []Target
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
return nil, err
}
return targets, nil
}
// NewTab 新开一个 page 并导航到 url。
func (b *Browser) NewTab(ctx context.Context, url string) (*Target, error) {
base, err := b.baseURL(ctx)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 10 * time.Second}
do := func(method string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, base+"/json/new?url="+neturl.QueryEscape(url), nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
resp, err := do(http.MethodPut)
if err == nil && resp.StatusCode == http.StatusMethodNotAllowed {
resp.Body.Close()
resp, err = do(http.MethodGet) // 老版本 Chrome 只支持 GET /json/new
}
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("/json/new: HTTP %d", resp.StatusCode)
}
var t Target
if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
return nil, err
}
return &t, nil
}
// Close 终止托管的 Chrome 进程(DebugBase 注入模式为空操作)。
func (b *Browser) Close() {
b.mu.Lock()
cmd := b.cmd
b.cmd, b.base = nil, ""
b.mu.Unlock()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
}