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。
252 lines
8.9 KiB
Go
252 lines
8.9 KiB
Go
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")
|