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。
This commit is contained in:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
+221
@@ -0,0 +1,221 @@
|
||||
// Package cdp 实现跨平台 browser 工具所需的 Chrome DevTools Protocol 客户端:
|
||||
// WebSocket 传输(coder/websocket)、CDP 连接层、Chrome 启动发现、browser 工具。
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// maxWSMessage 限制单条 CDP 消息大小(截图 base64 可达数 MB,留足余量)。
|
||||
const maxWSMessage = 64 << 20
|
||||
|
||||
// Conn 是一条到 CDP target 的 WebSocket 连接:请求/响应按 id 配对,
|
||||
// 事件按方法名分发给先注册的等待者(必须先 Subscribe 再触发事件)。
|
||||
type Conn struct {
|
||||
ws *websocket.Conn
|
||||
ctx context.Context // 连接生命周期(readLoop 用)
|
||||
|
||||
wmu sync.Mutex // coder/websocket 要求写方串行
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
pending map[int64]chan cdpReply
|
||||
subs map[string][]*EventWaiter
|
||||
closed bool
|
||||
}
|
||||
|
||||
type cdpReply struct {
|
||||
Result json.RawMessage
|
||||
Err *cdpError
|
||||
}
|
||||
|
||||
type cdpError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type cdpEvent struct {
|
||||
Method string
|
||||
Params json.RawMessage
|
||||
}
|
||||
|
||||
type cdpWireMsg struct {
|
||||
ID int64 `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *cdpError `json:"error"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
// Dial 连接一个 target 的 WebSocket 调试端点并启动读取循环。
|
||||
func Dial(ctx context.Context, wsURL string) (*Conn, error) {
|
||||
dctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
ws, _, err := websocket.Dial(dctx, wsURL, &websocket.DialOptions{CompressionMode: websocket.CompressionDisabled})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ws dial %s: %w", wsURL, err)
|
||||
}
|
||||
ws.SetReadLimit(maxWSMessage)
|
||||
c := &Conn{
|
||||
ws: ws,
|
||||
ctx: ctx,
|
||||
pending: map[int64]chan cdpReply{},
|
||||
subs: map[string][]*EventWaiter{},
|
||||
}
|
||||
go c.readLoop()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Call 发送一条 CDP 命令并等待同 id 响应。
|
||||
func (c *Conn) Call(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return nil, errors.New("cdp connection closed")
|
||||
}
|
||||
c.nextID++
|
||||
id := c.nextID
|
||||
ch := make(chan cdpReply, 1)
|
||||
c.pending[id] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
msg := map[string]any{"id": id, "method": method}
|
||||
if params != nil {
|
||||
msg["params"] = params
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
c.removePending(id)
|
||||
return nil, err
|
||||
}
|
||||
c.wmu.Lock()
|
||||
err = c.ws.Write(ctx, websocket.MessageText, data)
|
||||
c.wmu.Unlock()
|
||||
if err != nil {
|
||||
c.removePending(id)
|
||||
return nil, fmt.Errorf("%s: %w", method, err)
|
||||
}
|
||||
select {
|
||||
case r := <-ch:
|
||||
if r.Err != nil {
|
||||
return nil, fmt.Errorf("%s: %s (code %d)", method, r.Err.Message, r.Err.Code)
|
||||
}
|
||||
return r.Result, nil
|
||||
case <-ctx.Done():
|
||||
c.removePending(id)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe 注册事件等待者(必须在事件触发前调用;缓冲 8,满则丢弃)。
|
||||
func (c *Conn) Subscribe(event string) *EventWaiter {
|
||||
w := &EventWaiter{ch: make(chan cdpEvent, 8), method: event}
|
||||
c.mu.Lock()
|
||||
c.subs[event] = append(c.subs[event], w)
|
||||
c.mu.Unlock()
|
||||
return w
|
||||
}
|
||||
|
||||
// EventWaiter 等待一个已订阅的 CDP 事件。
|
||||
type EventWaiter struct {
|
||||
ch chan cdpEvent
|
||||
method string
|
||||
}
|
||||
|
||||
// Wait 等待事件到达(超时返回错误)。
|
||||
func (w *EventWaiter) Wait(ctx context.Context, timeout time.Duration) (json.RawMessage, error) {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case ev := <-w.ch:
|
||||
return ev.Params, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil, fmt.Errorf("timeout waiting for event %s", w.method)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) removePending(id int64) {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// readLoop 按 id 分发响应、按方法名分发事件;连接断开时唤醒所有等待者。
|
||||
func (c *Conn) readLoop() {
|
||||
for {
|
||||
_, data, err := c.ws.Read(c.ctx)
|
||||
if err != nil {
|
||||
c.failAll(err)
|
||||
return
|
||||
}
|
||||
var m cdpWireMsg
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
continue
|
||||
}
|
||||
if m.ID != 0 {
|
||||
c.mu.Lock()
|
||||
ch := c.pending[m.ID]
|
||||
delete(c.pending, m.ID)
|
||||
c.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- cdpReply{Result: m.Result, Err: m.Error}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if m.Method != "" {
|
||||
c.mu.Lock()
|
||||
waiters := c.subs[m.Method]
|
||||
c.mu.Unlock()
|
||||
for _, w := range waiters {
|
||||
select {
|
||||
case w.ch <- cdpEvent{Method: m.Method, Params: m.Params}:
|
||||
default: // 满则丢弃,避免阻塞 readLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) failAll(err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.closed = true
|
||||
for id, ch := range c.pending {
|
||||
ch <- cdpReply{Err: &cdpError{Message: "connection lost: " + err.Error()}}
|
||||
delete(c.pending, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭连接(幂等)。
|
||||
func (c *Conn) Close() {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.closed = true
|
||||
pending := c.pending
|
||||
subs := c.subs
|
||||
c.pending = map[int64]chan cdpReply{}
|
||||
c.subs = map[string][]*EventWaiter{}
|
||||
c.mu.Unlock()
|
||||
for id, ch := range pending {
|
||||
ch <- cdpReply{Err: &cdpError{Message: "connection closed"}}
|
||||
delete(pending, id)
|
||||
}
|
||||
for _, ws := range subs {
|
||||
for _, w := range ws {
|
||||
close(w.ch)
|
||||
}
|
||||
}
|
||||
_ = c.ws.Close(websocket.StatusNormalClosure, "")
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// tinyPNG 是 1x1 红色 PNG 的 base64(截图测试用)。
|
||||
const tinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
|
||||
// fakeCDPHandler 处理一条 CDP 命令并回响应;navigate 先发事件再回响应,
|
||||
// 用于验证"先订阅后触发"的事件时序。
|
||||
func fakeCDPHandler(t *testing.T, title string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close(websocket.StatusInternalError, "")
|
||||
ctx := r.Context()
|
||||
for {
|
||||
_, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &req); err != nil {
|
||||
continue
|
||||
}
|
||||
reply := func(v map[string]any) {
|
||||
b, _ := json.Marshal(map[string]any{"id": req.ID, "result": v})
|
||||
_ = c.Write(ctx, websocket.MessageText, b)
|
||||
}
|
||||
switch req.Method {
|
||||
case "Page.enable":
|
||||
reply(map[string]any{})
|
||||
case "Page.navigate":
|
||||
// 事件先于响应到达:验证 Subscribe 必须先行
|
||||
ev, _ := json.Marshal(map[string]any{"method": "Page.loadEventFired", "params": map[string]any{}})
|
||||
_ = c.Write(ctx, websocket.MessageText, ev)
|
||||
reply(map[string]any{"frameId": "f1"})
|
||||
case "Runtime.evaluate":
|
||||
reply(map[string]any{"result": map[string]any{"type": "string", "value": title}})
|
||||
case "Page.captureScreenshot":
|
||||
reply(map[string]any{"data": tinyPNG})
|
||||
default:
|
||||
b, _ := json.Marshal(map[string]any{"id": req.ID, "error": map[string]any{"code": -32601, "message": "unknown method"}})
|
||||
_ = c.Write(ctx, websocket.MessageText, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnCallAndEventTiming:事件先到时 Call 仍能配对响应、Subscribe 先行能等到事件。
|
||||
func TestConnCallAndEventTiming(t *testing.T) {
|
||||
srv := httptest.NewServer(fakeCDPHandler(t, "Fake Page"))
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/devtools/page/t1"
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := Dial(ctx, wsURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
loaded := conn.Subscribe("Page.loadEventFired")
|
||||
if _, err := conn.Call(ctx, "Page.enable", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.Call(ctx, "Page.navigate", map[string]any{"url": "https://example.com"}); err != nil {
|
||||
t.Fatalf("navigate: %v", err)
|
||||
}
|
||||
// 事件在响应前已发出:订阅先行则必然能等到
|
||||
if _, err := loaded.Wait(ctx, 2*time.Second); err != nil {
|
||||
t.Fatalf("event wait: %v", err)
|
||||
}
|
||||
title, err := evaluateString(ctx, conn, "document.title")
|
||||
if err != nil || title != "Fake Page" {
|
||||
t.Fatalf("evaluate: %q %v", title, err)
|
||||
}
|
||||
// 服务端错误响应 → Go error
|
||||
if _, err := conn.Call(ctx, "NoSuch.Method", nil); err == nil || !strings.Contains(err.Error(), "unknown method") {
|
||||
t.Fatalf("expected rpc error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnClosed:连接断开后 Call 立即失败。
|
||||
func TestConnClosed(t *testing.T) {
|
||||
srv := httptest.NewServer(fakeCDPHandler(t, "x"))
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/devtools/page/t1"
|
||||
ctx := context.Background()
|
||||
conn, err := Dial(ctx, wsURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
if _, err := conn.Call(ctx, "Page.enable", nil); err == nil {
|
||||
t.Fatal("call on closed connection should fail")
|
||||
}
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 浏览器 profile 的发现、体检与复制。
|
||||
//
|
||||
// 为什么需要这些:Chrome 的**登录态(cookies)与扩展都存在 user-data-dir 里**。
|
||||
// 以前 profile 为空就用 os.MkdirTemp 建个空目录,于是每次启动都像一台从没登录过的
|
||||
// 新机器——而且那个临时目录**从不清理**,退出后在 /tmp 留下几百 MB。
|
||||
//
|
||||
// 想复用已有登录态,就把 browser.profile 指到一份**已登录的** user-data-dir(通常是
|
||||
// 系统默认目录的副本)。体检负责把几个常见坑说清楚:指到了 Default 子目录、指到了
|
||||
// Chrome 默认目录、或者那份 profile 正被一个运行中的 Chrome 占用。
|
||||
|
||||
// DefaultUserDataDir 返回当前平台 Chrome 的默认用户数据目录(那份带登录态与扩展的)。
|
||||
// 找不到时返回候选列表供人肉判断。
|
||||
func DefaultUserDataDir() (string, error) {
|
||||
for _, c := range defaultUserDataDirCandidates() {
|
||||
if st, err := os.Stat(c); err == nil && st.IsDir() {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("找不到默认的 Chrome 用户数据目录(找过:%s)", strings.Join(defaultUserDataDirCandidates(), ", "))
|
||||
}
|
||||
|
||||
// defaultUserDataDirCandidates 列出各平台可能的默认 user-data-dir。
|
||||
func defaultUserDataDirCandidates() []string {
|
||||
home, _ := os.UserHomeDir()
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return []string{
|
||||
filepath.Join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||
filepath.Join(home, "Library", "Application Support", "Chromium"),
|
||||
filepath.Join(home, "Library", "Application Support", "Microsoft Edge"),
|
||||
}
|
||||
case "windows":
|
||||
base := os.Getenv("LOCALAPPDATA")
|
||||
if base == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{
|
||||
filepath.Join(base, "Google", "Chrome", "User Data"),
|
||||
filepath.Join(base, "Microsoft", "Edge", "User Data"),
|
||||
}
|
||||
default:
|
||||
return []string{
|
||||
filepath.Join(home, ".config", "google-chrome"),
|
||||
filepath.Join(home, ".config", "chromium"),
|
||||
filepath.Join(home, ".config", "microsoft-edge"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentProfileDir 是 AutoGo 自己的持久 profile 目录。
|
||||
//
|
||||
// 用它而不是临时目录:临时目录意味着"登录一次也留不住"(每次进程启动都是新的空
|
||||
// profile),而且没人清理。持久目录下登录一次就长期有效。
|
||||
func PersistentProfileDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".autogo", "browser-profile"), nil
|
||||
}
|
||||
|
||||
// IsChromeDefaultDir 报告 dir 是不是(或位于)系统默认的 Chrome user-data-dir。
|
||||
// 是的话要提醒:Chrome 136+ 起出于安全**禁止在默认 profile 上开启远程调试**,必须用
|
||||
// 非默认的 user-data-dir;而且默认目录通常被日常 Chrome 占着(单实例锁)。
|
||||
func IsChromeDefaultDir(dir string) bool {
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, c := range defaultUserDataDirCandidates() {
|
||||
if abs == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// looksLikeProfileDir 判断 dir 像是"某个 profile 本体"(Default/ 里的那种),
|
||||
// 而不是 user-data-dir。这是最常见的配置错误:--user-data-dir 要指向**父目录**。
|
||||
func looksLikeProfileDir(dir string) bool {
|
||||
for _, f := range []string{"Cookies", "Login Data", "Preferences"} {
|
||||
if st, err := os.Stat(filepath.Join(dir, f)); err == nil && !st.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckProfile 对配置里的 profile 目录做体检,返回若干条**人类可读的告警**(可以为空)。
|
||||
// 不存在之类属于致命问题,由调用方按错误处理——这里只做"能启动但可能不符合预期"的提醒。
|
||||
func CheckProfile(dir string) []string {
|
||||
var warns []string
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
st, err := os.Stat(dir)
|
||||
if err != nil || !st.IsDir() {
|
||||
return []string{fmt.Sprintf("profile 目录不存在:%s(它不会被自动创建;先确认路径,或用 `a2go browser profile-copy` 复制一份)", dir)}
|
||||
}
|
||||
if looksLikeProfileDir(dir) {
|
||||
warns = append(warns, fmt.Sprintf(
|
||||
"profile 指向的像是**profile 本体**(里面有 Cookies/Preferences):%s\n"+
|
||||
" --user-data-dir 要指向它的**父目录**,否则 Chrome 会在里面另建一套结构,你的登录态不会生效。", dir))
|
||||
}
|
||||
if IsChromeDefaultDir(dir) {
|
||||
warns = append(warns, fmt.Sprintf(
|
||||
"profile 是 Chrome 的**默认目录**:%s\n"+
|
||||
" Chrome 136+ 起禁止在默认 profile 上开启远程调试,而且日常 Chrome 通常正占着它。\n"+
|
||||
" 建议改用副本:a2go browser profile-copy", dir))
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, "SingletonLock")); err == nil {
|
||||
warns = append(warns, fmt.Sprintf(
|
||||
"该 profile 里有 SingletonLock:%s\n"+
|
||||
" 说明可能有一个正在运行的 Chrome 在用这份 profile。同一个 user-data-dir 起第二个实例通常\n"+
|
||||
" 拿不到调试端口(会被转交给已运行的实例)。请先完全退出 Chrome,或改用副本。", dir))
|
||||
}
|
||||
return warns
|
||||
}
|
||||
|
||||
// profileCacheDirs 是复制时跳过的缓存目录:体积大、且与登录态无关(含扩展的缓存)。
|
||||
// 注意 **不跳过 Extensions** —— 复用方案里扩展正是要带过去的东西之一。
|
||||
var profileCacheDirs = map[string]bool{
|
||||
"Cache": true,
|
||||
"Code Cache": true,
|
||||
"GPUCache": true,
|
||||
"ShaderCache": true,
|
||||
"GrShaderCache": true,
|
||||
"DawnCache": true,
|
||||
"DawnGraphiteCache": true,
|
||||
"GraphiteDawnCache": true,
|
||||
"Media Cache": true,
|
||||
"Service Worker": true,
|
||||
"Crashpad": true,
|
||||
"BrowserMetrics": true,
|
||||
"component_crx_cache": true,
|
||||
"extensions_crx_cache": true,
|
||||
"Safe Browsing": true,
|
||||
}
|
||||
|
||||
// profileSkipFile 判断某个文件是否属于"复制时跳过"(单实例锁等,带过去反而会误导 Chrome)。
|
||||
func profileSkipFile(name string) bool {
|
||||
return strings.HasPrefix(name, "Singleton")
|
||||
}
|
||||
|
||||
// CopyProfile 把 src 里的登录态与扩展复制到 dst。
|
||||
//
|
||||
// 复制前会拒绝"源正被占用"的情况:Chrome 运行中复制的 profile 可能不一致,而且它的
|
||||
// 单实例锁文件会一起被带过去。
|
||||
func CopyProfile(src, dst string) (int64, error) {
|
||||
st, err := os.Stat(src)
|
||||
if err != nil || !st.IsDir() {
|
||||
return 0, fmt.Errorf("源 profile 不存在或不是目录:%s", src)
|
||||
}
|
||||
if looksLikeProfileDir(src) {
|
||||
return 0, fmt.Errorf("源指向的像是 profile 本体(含 Cookies/Preferences):%s\n要复制的是它的**父目录**(user-data-dir)", src)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(src, "SingletonLock")); err == nil {
|
||||
return 0, fmt.Errorf("源 profile 正被占用(有 SingletonLock):%s\n请先完全退出 Chrome 再复制——运行中复制的 profile 可能不一致", src)
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var total int64
|
||||
err = filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // 个别文件读不了(权限/正在写)不该中断整次复制
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
// 跳过缓存目录(整个子树)、单实例锁、以及 *-journal 之类的临时文件。
|
||||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||
if profileCacheDirs[part] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
if profileSkipFile(d.Name()) {
|
||||
return nil
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil // 符号链接等一律跳过
|
||||
}
|
||||
n, err := copyFile(path, target)
|
||||
total += n
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
// 目标里也清一次:万一 dst 是之前从运行中的 Chrome 拷来的。
|
||||
for _, f := range []string{"SingletonLock", "SingletonSocket", "SingletonCookie"} {
|
||||
_ = os.Remove(filepath.Join(dst, f))
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) (int64, error) {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return 0, nil // 读不了就跳过,不中断
|
||||
}
|
||||
defer in.Close()
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer out.Close()
|
||||
n, err := io.Copy(out, in)
|
||||
if err != nil {
|
||||
return n, nil
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// LooksLikeBrowserProfile 判断 dir 是否像一份浏览器 user-data-dir(用于 --force 前的安全校验:
|
||||
// 只有确认目标确实是 profile 才允许覆盖删除,避免手滑删掉无关目录)。
|
||||
func LooksLikeBrowserProfile(dir string) bool {
|
||||
for _, f := range []string{"Local State", "Default", "First Run"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, f)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ErrProfileMissing 是 profile 不存在时的哨兵(调用方据此给出配置建议)。
|
||||
var ErrProfileMissing = errors.New("browser profile not found")
|
||||
@@ -0,0 +1,186 @@
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeFile 建一个带内容的文件(自动建父目录)。
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckProfileWarnings:三个最常见的坑都要被明确讲出来。
|
||||
//
|
||||
// 这三条正是"配了 profile 却用不上登录态"的真实原因:
|
||||
// 1. 指到了 profile 本体(Default/ 里面)而不是 user-data-dir;
|
||||
// 2. 指到了 Chrome 默认目录(Chrome 136+ 禁止在其上开远程调试,且日常 Chrome 占着);
|
||||
// 3. 那份 profile 正被一个运行中的 Chrome 占用。
|
||||
func TestCheckProfileWarnings(t *testing.T) {
|
||||
t.Run("目录不存在", func(t *testing.T) {
|
||||
warns := CheckProfile(filepath.Join(t.TempDir(), "nope"))
|
||||
if len(warns) != 1 || !strings.Contains(warns[0], "不存在") {
|
||||
t.Fatalf("应提示目录不存在,实际 %v", warns)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("指向 profile 本体", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, "Cookies"), "x")
|
||||
warns := CheckProfile(dir)
|
||||
if !hasSubstr(warns, "父目录") {
|
||||
t.Fatalf("应提示 --user-data-dir 要指向父目录,实际 %v", warns)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("指向 Chrome 默认目录", func(t *testing.T) {
|
||||
cands := defaultUserDataDirCandidates()
|
||||
if len(cands) == 0 {
|
||||
t.Skip("当前平台没有已知的默认目录")
|
||||
}
|
||||
// 直接对候选路径做判断(纯字符串逻辑,不需要它真的存在)。
|
||||
if !IsChromeDefaultDir(cands[0]) {
|
||||
t.Fatalf("IsChromeDefaultDir(%q) 应为 true", cands[0])
|
||||
}
|
||||
if IsChromeDefaultDir(filepath.Join(t.TempDir(), "somecopy")) {
|
||||
t.Error("非默认目录不该被判为默认目录")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("被运行中的 Chrome 占用", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, "Local State"), "{}")
|
||||
writeFile(t, filepath.Join(dir, "SingletonLock"), "")
|
||||
warns := CheckProfile(dir)
|
||||
if !hasSubstr(warns, "SingletonLock") {
|
||||
t.Fatalf("应提示 profile 被占用,实际 %v", warns)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func hasSubstr(list []string, sub string) bool {
|
||||
for _, s := range list {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestCopyProfile:复制要**保留登录态与扩展、跳过缓存、不带单实例锁**。
|
||||
func TestCopyProfile(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
dst := filepath.Join(t.TempDir(), "copy")
|
||||
|
||||
// 要保留的:登录态与扩展。
|
||||
writeFile(t, filepath.Join(src, "Local State"), "{}")
|
||||
writeFile(t, filepath.Join(src, "Default", "Cookies"), "cookies-db")
|
||||
writeFile(t, filepath.Join(src, "Default", "Preferences"), "{}")
|
||||
writeFile(t, filepath.Join(src, "Default", "Extensions", "abc", "manifest.json"), "{}")
|
||||
// 要跳过的:缓存(体积大且与登录态无关)。
|
||||
writeFile(t, filepath.Join(src, "Default", "Cache", "big.bin"), strings.Repeat("x", 4096))
|
||||
writeFile(t, filepath.Join(src, "Default", "Code Cache", "big.bin"), strings.Repeat("y", 4096))
|
||||
writeFile(t, filepath.Join(src, "Default", "Service Worker", "s.bin"), "z")
|
||||
|
||||
n, err := CopyProfile(src, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("复制失败: %v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Error("复制字节数为 0")
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
filepath.Join("Default", "Cookies"),
|
||||
filepath.Join("Default", "Preferences"),
|
||||
filepath.Join("Default", "Extensions", "abc", "manifest.json"),
|
||||
"Local State",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(dst, want)); err != nil {
|
||||
t.Errorf("应保留 %s:%v", want, err)
|
||||
}
|
||||
}
|
||||
for _, skip := range []string{
|
||||
filepath.Join("Default", "Cache"),
|
||||
filepath.Join("Default", "Code Cache"),
|
||||
filepath.Join("Default", "Service Worker"),
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(dst, skip)); err == nil {
|
||||
t.Errorf("缓存目录应被跳过:%s", skip)
|
||||
}
|
||||
}
|
||||
if !LooksLikeBrowserProfile(dst) {
|
||||
t.Error("复制结果应当被识别为浏览器 profile")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyProfileRefusesRunningChrome:源被占用(SingletonLock)时必须拒绝——
|
||||
// 运行中复制的 profile 可能不一致,而且锁文件带过去会让 Chrome 犯迷糊。
|
||||
func TestCopyProfileRefusesRunningChrome(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
writeFile(t, filepath.Join(src, "Local State"), "{}")
|
||||
writeFile(t, filepath.Join(src, "SingletonLock"), "")
|
||||
if _, err := CopyProfile(src, filepath.Join(t.TempDir(), "copy")); err == nil {
|
||||
t.Fatal("源被占用时应拒绝复制")
|
||||
} else if !strings.Contains(err.Error(), "退出 Chrome") {
|
||||
t.Errorf("错误应提示先退出 Chrome:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyProfileNoSingletonInDst:目标里不该出现单实例锁,否则 Chrome 会以为另有一个实例在跑。
|
||||
func TestCopyProfileNoSingletonInDst(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
writeFile(t, filepath.Join(src, "Local State"), "{}")
|
||||
writeFile(t, filepath.Join(src, "Default", "Cookies"), "c")
|
||||
dst := filepath.Join(t.TempDir(), "copy")
|
||||
if _, err := CopyProfile(src, dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range []string{"SingletonLock", "SingletonSocket", "SingletonCookie"} {
|
||||
if _, err := os.Lstat(filepath.Join(dst, f)); err == nil {
|
||||
t.Errorf("目标不该有 %s", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyProfileRejectsProfileBodyAsSource:源指向 profile 本体时拒绝并说清该指哪。
|
||||
func TestCopyProfileRejectsProfileBodyAsSource(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
writeFile(t, filepath.Join(src, "Cookies"), "x")
|
||||
if _, err := CopyProfile(src, filepath.Join(t.TempDir(), "copy")); err == nil {
|
||||
t.Fatal("源是 profile 本体时应拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistentProfileDir:默认目录必须是**固定**路径。
|
||||
//
|
||||
// 旧的实现用 os.MkdirTemp——每次都给出一个新目录,于是每次进程启动都像一台从没
|
||||
// 登录过的新机器(登录一次也留不住)。这里断言的关键性质就是"两次调用同一个路径"。
|
||||
func TestPersistentProfileDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
dir, err := PersistentProfileDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".autogo", "browser-profile")
|
||||
if dir != want {
|
||||
t.Errorf("默认 profile = %q, want %q", dir, want)
|
||||
}
|
||||
again, err := PersistentProfileDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again != dir {
|
||||
t.Errorf("两次调用应返回同一路径(临时目录才会每次不同):%q vs %q", dir, again)
|
||||
}
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.gopher.ink/mirrors2/AutoZero/llm"
|
||||
"git.gopher.ink/mirrors2/AutoZero/textx"
|
||||
"git.gopher.ink/mirrors2/AutoZero/tools"
|
||||
)
|
||||
|
||||
// browser 工具:经 Chrome DevTools Protocol 跨平台驱动浏览器
|
||||
// (替代旧 macOS-only 的 AppleScript 方案)。截图走 Page.captureScreenshot,
|
||||
// 不依赖屏幕录制权限;js 执行走 Runtime.evaluate,无需任何浏览器开关。
|
||||
|
||||
const (
|
||||
navigateTimeout = 15 * time.Second
|
||||
evaluateTimeout = 30 * time.Second
|
||||
maxJSOutput = 16000
|
||||
)
|
||||
|
||||
var (
|
||||
browserMu sync.Mutex
|
||||
browserShared *Browser
|
||||
defaultsMu sync.Mutex
|
||||
defaultsSet bool
|
||||
defaultsVal *Browser
|
||||
)
|
||||
|
||||
// SetBrowserDefaults 由 main 在配置装载后调用(env 已并入 config);
|
||||
// 未调用时 sharedBrowser 回退为直接读环境变量(库独立使用场景)。
|
||||
func SetBrowserDefaults(bin string, headless bool, profile, debugBase string) {
|
||||
defaultsMu.Lock()
|
||||
defer defaultsMu.Unlock()
|
||||
defaultsSet = true
|
||||
defaultsVal = &Browser{Bin: bin, Headless: headless, ProfileDir: profile, DebugBase: debugBase}
|
||||
}
|
||||
|
||||
func sharedBrowser() *Browser {
|
||||
browserMu.Lock()
|
||||
defer browserMu.Unlock()
|
||||
if browserShared == nil {
|
||||
defaultsMu.Lock()
|
||||
if defaultsSet {
|
||||
browserShared = defaultsVal
|
||||
} else {
|
||||
browserShared = &Browser{
|
||||
Bin: os.Getenv("AUTOGO_BROWSER_BIN"),
|
||||
Headless: os.Getenv("AUTOGO_BROWSER_HEADLESS") != "0",
|
||||
ProfileDir: os.Getenv("AUTOGO_BROWSER_PROFILE"),
|
||||
DebugBase: os.Getenv("AUTOGO_BROWSER_DEBUG_BASE"),
|
||||
}
|
||||
}
|
||||
defaultsMu.Unlock()
|
||||
}
|
||||
return browserShared
|
||||
}
|
||||
|
||||
// CloseBrowser 终止托管的浏览器进程(main 退出前调用)。
|
||||
func CloseBrowser() {
|
||||
browserMu.Lock()
|
||||
defer browserMu.Unlock()
|
||||
if browserShared != nil {
|
||||
browserShared.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// BrowserTool 返回跨平台 browser 工具。
|
||||
func BrowserTool() *tools.Tool {
|
||||
return browserToolWith(sharedBrowser())
|
||||
}
|
||||
|
||||
func browserToolWith(br *Browser) *tools.Tool {
|
||||
return &tools.Tool{
|
||||
Name: "browser",
|
||||
Description: "Control a browser via Chrome DevTools Protocol (cross-platform; spawns a headless Chrome by default, set AUTOGO_BROWSER_HEADLESS=0 to show a window). " +
|
||||
"Actions: open (navigate the current tab to url), tabs (list open tabs), js (execute JavaScript in the current tab and return the result; async/await supported), screenshot (capture the current tab, returned as an image you can see). " +
|
||||
"Prefer `js` to read and verify page state (fast, returns values like document.title or video.paused); use `open`/`js` to navigate and act. Use `screenshot` only to deliver a visual to the user, not for routine verification. " +
|
||||
"AUTOGO_BROWSER_BIN overrides the browser binary (Chrome/Chromium/Edge).",
|
||||
SideEffect: true,
|
||||
Kind: tools.KindNetwork, // 访问外部网络
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{"type": "string", "enum": []string{"open", "tabs", "js", "screenshot"},
|
||||
"description": "open: navigate to url; tabs: list tabs; js: run javascript in current tab; screenshot: capture current tab"},
|
||||
"url": map[string]any{"type": "string", "description": "URL to open (action=open)"},
|
||||
"javascript": map[string]any{"type": "string",
|
||||
"description": "JavaScript to execute, should end with an expression or return value, e.g. document.title or JSON.stringify(document.body.innerText.slice(0,5000)) (action=js)"},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
Run: func(ctx context.Context, input string) (tools.Result, error) {
|
||||
m, err := arg(input)
|
||||
if err != nil {
|
||||
return tools.Result{}, err
|
||||
}
|
||||
switch action := str(m, "action"); action {
|
||||
case "open":
|
||||
u := str(m, "url")
|
||||
if strings.TrimSpace(u) == "" {
|
||||
return tools.Result{}, fmt.Errorf("action open requires url")
|
||||
}
|
||||
return br.open(ctx, u)
|
||||
case "tabs":
|
||||
return br.tabs(ctx)
|
||||
case "js":
|
||||
js := str(m, "javascript")
|
||||
if strings.TrimSpace(js) == "" {
|
||||
return tools.Result{}, fmt.Errorf("action js requires javascript")
|
||||
}
|
||||
return br.js(ctx, js)
|
||||
case "screenshot":
|
||||
return br.screenshot(ctx)
|
||||
default:
|
||||
return tools.Result{}, fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// resolveTarget 选定当前作用 tab:优先记住的 current(已关闭则回退),
|
||||
// 其次第一个 page target;都没有则新开 about:blank。
|
||||
func (b *Browser) resolveTarget(ctx context.Context) (*Target, error) {
|
||||
targets, err := b.ListTargets(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pages []Target
|
||||
for _, t := range targets {
|
||||
if t.Type == "page" {
|
||||
pages = append(pages, t)
|
||||
}
|
||||
}
|
||||
for i := range pages {
|
||||
if pages[i].ID == b.current {
|
||||
return &pages[i], nil
|
||||
}
|
||||
}
|
||||
if len(pages) > 0 {
|
||||
b.current = pages[0].ID
|
||||
return &pages[0], nil
|
||||
}
|
||||
t, err := b.NewTab(ctx, "about:blank")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.current = t.ID
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// withTargetConn 解析目标 tab 并建立临时 CDP 连接(动作结束即关闭)。
|
||||
func (b *Browser) withTargetConn(ctx context.Context, fn func(c *Conn) (tools.Result, error)) (tools.Result, error) {
|
||||
b.actionMu.Lock()
|
||||
defer b.actionMu.Unlock()
|
||||
t, err := b.resolveTarget(ctx)
|
||||
if err != nil {
|
||||
return tools.Result{}, err
|
||||
}
|
||||
conn, err := Dial(ctx, t.WebSocketDebuggerURL)
|
||||
if err != nil {
|
||||
return tools.Result{}, err
|
||||
}
|
||||
defer conn.Close()
|
||||
return fn(conn)
|
||||
}
|
||||
|
||||
func (b *Browser) open(ctx context.Context, u string) (tools.Result, error) {
|
||||
return b.withTargetConn(ctx, func(c *Conn) (tools.Result, error) {
|
||||
if _, err := c.Call(ctx, "Page.enable", nil); err != nil {
|
||||
return tools.Result{}, err
|
||||
}
|
||||
loaded := c.Subscribe("Page.loadEventFired")
|
||||
if _, err := c.Call(ctx, "Page.navigate", map[string]any{"url": u}); err != nil {
|
||||
return tools.Result{}, err
|
||||
}
|
||||
note := ""
|
||||
if _, err := loaded.Wait(ctx, navigateTimeout); err != nil {
|
||||
note = " (load event timeout, page may still be loading)"
|
||||
}
|
||||
title, _ := evaluateString(ctx, c, "document.title")
|
||||
return tools.TextResult("opened " + u + " — title: " + title + note), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Browser) tabs(ctx context.Context) (tools.Result, error) {
|
||||
targets, err := b.ListTargets(ctx)
|
||||
if err != nil {
|
||||
return tools.Result{}, err
|
||||
}
|
||||
var lines []string
|
||||
for _, t := range targets {
|
||||
if t.Type != "page" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s | %s | %s", t.ID, t.Title, t.URL))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return tools.TextResult("no open tabs"), nil
|
||||
}
|
||||
return tools.TextResult(strings.Join(lines, "\n")), nil
|
||||
}
|
||||
|
||||
func (b *Browser) js(ctx context.Context, code string) (tools.Result, error) {
|
||||
return b.withTargetConn(ctx, func(c *Conn) (tools.Result, error) {
|
||||
out, err := evaluateString(ctx, c, jsWrap(code))
|
||||
if err != nil {
|
||||
return tools.Result{}, err
|
||||
}
|
||||
if len(out) > maxJSOutput {
|
||||
// 页面文本几乎必然含中文:按字节切会在结果尾部留下半个字,
|
||||
// 那既污染 transcript(模型看到乱码),也会让会话 JSONL 里出现非法序列。
|
||||
out = textx.ClipBytes(out, maxJSOutput) + "...[truncated]"
|
||||
}
|
||||
return tools.TextResult(out), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Browser) screenshot(ctx context.Context) (tools.Result, error) {
|
||||
return b.withTargetConn(ctx, func(c *Conn) (tools.Result, error) {
|
||||
raw, err := c.Call(ctx, "Page.captureScreenshot", map[string]any{"format": "png"})
|
||||
if err != nil {
|
||||
return tools.Result{}, err
|
||||
}
|
||||
var res struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil || res.Data == "" {
|
||||
return tools.Result{}, fmt.Errorf("captureScreenshot: bad result")
|
||||
}
|
||||
png, err := base64.StdEncoding.DecodeString(res.Data)
|
||||
if err != nil {
|
||||
return tools.Result{}, err
|
||||
}
|
||||
path := fmt.Sprintf("%s/autogo-page-%d.png", os.TempDir(), time.Now().UnixNano())
|
||||
if err := os.WriteFile(path, png, 0o644); err != nil {
|
||||
return tools.Result{}, err
|
||||
}
|
||||
if p, serr := tools.ShrinkForVision(path); serr == nil {
|
||||
path = p
|
||||
}
|
||||
title, _ := evaluateString(ctx, c, "document.location.href")
|
||||
return tools.Result{Text: "page screenshot: " + title, Images: []llm.Image{{Path: path}}}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// evaluateString 执行 JS 并把结果转为字符串(错误详情带回)。
|
||||
func evaluateString(ctx context.Context, c *Conn, expression string) (string, error) {
|
||||
raw, err := c.Call(ctx, "Runtime.evaluate", map[string]any{
|
||||
"expression": expression,
|
||||
"returnByValue": true,
|
||||
"awaitPromise": true,
|
||||
"userGesture": true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var res struct {
|
||||
Result struct {
|
||||
Type string `json:"type"`
|
||||
Value any `json:"value"`
|
||||
} `json:"result"`
|
||||
ExceptionDetails *struct {
|
||||
Text string `json:"text"`
|
||||
Exception struct {
|
||||
Description string `json:"description"`
|
||||
} `json:"exception"`
|
||||
} `json:"exceptionDetails"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res.ExceptionDetails != nil {
|
||||
detail := res.ExceptionDetails.Exception.Description
|
||||
if detail == "" {
|
||||
detail = res.ExceptionDetails.Text
|
||||
}
|
||||
return "", fmt.Errorf("js error: %s", detail)
|
||||
}
|
||||
switch v := res.Result.Value.(type) {
|
||||
case nil:
|
||||
return "", nil
|
||||
case string:
|
||||
return v, nil
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprint(v), nil
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
}
|
||||
|
||||
// jsWrap 包一层 async + try/catch:支持 await,保证返回字符串。
|
||||
func jsWrap(js string) string {
|
||||
return fmt.Sprintf(
|
||||
`(async()=>{try{const r=await eval(%q);return typeof r==="string"?r:JSON.stringify(r)}catch(e){return "JS_ERROR: "+e.message}})()`,
|
||||
js,
|
||||
)
|
||||
}
|
||||
|
||||
// arg 解析工具输入 JSON(与 tools 包内同名逻辑一致)。
|
||||
// 兼容模型可能生成的 {"arguments": {...}} 包装格式。
|
||||
func arg(input string) (map[string]any, error) {
|
||||
m := map[string]any{}
|
||||
if input == "" {
|
||||
return m, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(input), &m); err != nil {
|
||||
return nil, fmt.Errorf("invalid tool arguments: %w", err)
|
||||
}
|
||||
// 兼容 {"arguments": {...}} 包装:提取内层对象
|
||||
if args, ok := m["arguments"]; ok {
|
||||
if argsMap, ok := args.(map[string]any); ok {
|
||||
return argsMap, nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func str(m map[string]any, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeBrowserEnv 构造完整的假调试端点:/json/list、/json/new(PUT)、
|
||||
// 每个 target 的 ws 端点(CDP 协议)。
|
||||
func fakeBrowserEnv(t *testing.T) (*Browser, *httptest.Server) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
targets := []Target{{
|
||||
ID: "t1", Title: "Blank", URL: "about:blank", Type: "page",
|
||||
WebSocketDebuggerURL: "", // srv.URL 已知后再填
|
||||
}}
|
||||
var srv *httptest.Server
|
||||
|
||||
wsHandler := fakeCDPHandler(t, "Fake Page")
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/json/list", func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(targets)
|
||||
})
|
||||
mux.HandleFunc("/json/new", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
targets = append(targets, Target{ID: "t2", Title: "New", URL: r.URL.Query().Get("url"), Type: "page"})
|
||||
nt := targets[len(targets)-1]
|
||||
mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(nt)
|
||||
})
|
||||
mux.HandleFunc("/devtools/page/t1", wsHandler)
|
||||
mux.HandleFunc("/devtools/page/t2", wsHandler)
|
||||
|
||||
srv = httptest.NewServer(mux)
|
||||
mu.Lock()
|
||||
targets[0].WebSocketDebuggerURL = "ws" + strings.TrimPrefix(srv.URL, "http") + "/devtools/page/t1"
|
||||
mu.Unlock()
|
||||
|
||||
br := &Browser{DebugBase: srv.URL}
|
||||
t.Cleanup(func() { srv.Close(); br.Close() })
|
||||
return br, srv
|
||||
}
|
||||
|
||||
func TestBrowserToolEndToEnd(t *testing.T) {
|
||||
br, _ := fakeBrowserEnv(t)
|
||||
tool := browserToolWith(br)
|
||||
ctx := context.Background()
|
||||
|
||||
// tabs
|
||||
res, err := tool.Run(ctx, `{"action":"tabs"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(res.Text, "t1 | Blank | about:blank") {
|
||||
t.Fatalf("tabs: %q", res.Text)
|
||||
}
|
||||
|
||||
// open:navigate + loadEventFired + 读取标题
|
||||
res, err = tool.Run(ctx, `{"action":"open","url":"https://example.com/x"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(res.Text, "opened https://example.com/x") || !strings.Contains(res.Text, "Fake Page") {
|
||||
t.Fatalf("open: %q", res.Text)
|
||||
}
|
||||
|
||||
// js:Runtime.evaluate 结果 + 错误路径
|
||||
res, err = tool.Run(ctx, `{"action":"js","javascript":"document.title"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Text != "Fake Page" {
|
||||
t.Fatalf("js: %q", res.Text)
|
||||
}
|
||||
|
||||
// screenshot:PNG 落盘并作为 Image 返回
|
||||
res, err = tool.Run(ctx, `{"action":"screenshot"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Images) != 1 {
|
||||
t.Fatalf("screenshot images: %v", res.Images)
|
||||
}
|
||||
data, err := os.ReadFile(res.Images[0].Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data[:4]) != "\x89PNG" {
|
||||
t.Fatalf("not a png: %x", data[:4])
|
||||
}
|
||||
os.Remove(res.Images[0].Path)
|
||||
|
||||
// 参数校验
|
||||
if _, err := tool.Run(ctx, `{"action":"open"}`); err == nil {
|
||||
t.Fatal("open without url should fail")
|
||||
}
|
||||
if _, err := tool.Run(ctx, `{"action":"js"}`); err == nil {
|
||||
t.Fatal("js without javascript should fail")
|
||||
}
|
||||
if _, err := tool.Run(ctx, `{"action":"bogus"}`); err == nil {
|
||||
t.Fatal("unknown action should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewTabPUT:无 page target 时经 PUT /json/new 新开 tab。
|
||||
func TestNewTabPUT(t *testing.T) {
|
||||
br, srv := fakeBrowserEnv(t)
|
||||
// 清空 targets:强制走 NewTab
|
||||
_ = srv
|
||||
ctx := context.Background()
|
||||
tg, err := br.NewTab(ctx, "https://example.com/new")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tg.ID != "t2" || tg.URL != "https://example.com/new" {
|
||||
t.Fatalf("new tab: %+v", tg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindBrowserExplicit:显式指定不存在的二进制给出可操作错误。
|
||||
func TestFindBrowserExplicit(t *testing.T) {
|
||||
if _, err := findBrowser("/no/such/browser"); err == nil || !strings.Contains(err.Error(), "AUTOGO_BROWSER_BIN") {
|
||||
t.Fatalf("expected explicit-bin error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user