Files
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

252 lines
8.9 KiB
Go
Raw Permalink 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 (
"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")