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。
302 lines
8.9 KiB
Go
302 lines
8.9 KiB
Go
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()
|
||
}
|
||
}
|