- browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机
Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志)
- 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+
导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only)
- 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效)
- 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**;
但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false
(把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口
⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用
pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
138 lines
6.5 KiB
Go
138 lines
6.5 KiB
Go
package main
|
||
|
||
// 本文件是 design/28(浏览器与页面截图)B1→B2 的**组合根接线**:把纯库
|
||
// `internal/capability/browsercfg`(配置读写 + 可用性探测)接进能力面,并在可用时
|
||
// 注册 `browser` 工具(`plugins/tools/browser` 的薄层)。
|
||
//
|
||
// 六条口径:
|
||
//
|
||
// 1. **配置只从文件来**:`$DATA_DIR/config/plugins/browser.json`(design/13 §2.1 的
|
||
// 插件配置落点、design/28 §1 M2)。**不读任何新增 env 键** —— `.env` 已冻结为
|
||
// 「最小化功能可跑」的引导面(AGENTS §10);本文件通篇没有 `os.Getenv`。
|
||
// 2. **文件缺失 = 交给自动探测**(`browsercfg.Resolve(nil, …)` 走「本机
|
||
// Chrome/Chromium → 已在跑的 CDP 端点」);**文件非法 = 不注册**(如实报错,
|
||
// 不静默拿默认值顶替 —— 那会让「面板里那份坏配置」永远看不见)。
|
||
// 3. **不可用就不注册**(照 web-search `off` 的诚实口径,design/28 §7 判据 1):
|
||
// 模型根本看不见这个工具,而不是看得见、一调就报错。原因**分类**打进日志
|
||
// (`reason=no_source` / `executable_missing` / `cdp_unreachable`),不糊成
|
||
// 「失败」两个字。
|
||
// 4. **profile 目录含凭据**:注册之前先过 `EnsureProfileDir`(0700 + 不得位于 git
|
||
// 工作树内,design/28 §2 三件套之 2、§4);不过关就不注册。
|
||
// 5. **两条失败都不阻塞启动**(与 wireVision / wireTranscode 同族):浏览器是**可选
|
||
// 增强**,磁盘上没有 Chrome 的机器照样得把整个进程拉起来。
|
||
// 6. **幂等**:重复装配只发现工具已在注册表里并跳过,不重复注册、不重复探测。
|
||
//
|
||
// 关于挂点:组合根在启动期只调用 `ioToolOptions()`(wire_runtime.go)这一个归属本片
|
||
// 的装配函数,而本片不得改他人拥有的 `wire_runtime.go` / `wire.go`
|
||
// ⇒ browser 的接线由 `wire_io_tools.go` 的 `ioToolOptions()` 转交给本文件的
|
||
// `wireBrowser()`(那一处有同样的说明)。
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"time"
|
||
|
||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||
)
|
||
|
||
const (
|
||
// browserLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。
|
||
browserLogSource = "cmd/cyrene/wire_browser"
|
||
|
||
// browserProbeTimeout 约束可用性探测。探测问的是「有没有在听」(本机可执行文件
|
||
// 是否存在、CDP 端点是否回话),不是「页面能不能加载」,所以给得短:最坏情况
|
||
// 也只是启动路径上多等 3 秒,而不是拖住整个组合根。
|
||
browserProbeTimeout = 3 * time.Second
|
||
|
||
// 生效配置的来源标签(只进日志):一眼看出本次结论是「面板文件说的」还是
|
||
// 「没有配置文件、自动探测出来的」。
|
||
browserConfigSourceFile = "panel-file"
|
||
browserConfigSourceProbe = "probe"
|
||
|
||
// 不注册工具时的原因码(进日志的 reason 字段,与 browsercfg.Reason 同风格)。
|
||
browserReasonInvalidConfig = "invalid-config"
|
||
browserReasonProfileRejected = "profile-dir-rejected"
|
||
|
||
// browserNotRegisteredEffect 是「不注册」这件事对模型面**实际**意味着什么:
|
||
// 写死在日志里,免得读日志的人以为只是「降级运行」。
|
||
browserNotRegisteredEffect = "the model will not see a browser tool at all"
|
||
)
|
||
|
||
// wireBrowser 装配 `browser` 工具。它没有错误返回值:一切失败都是**如实降级**
|
||
// (记分类原因 + 不注册),绝不阻塞启动。
|
||
func (a *app) wireBrowser() {
|
||
log := logger.WithSource(a.log, browserLogSource)
|
||
if a.caps == nil || a.cfg == nil {
|
||
// 部分测试夹具只填了 app 的几个字段;没有注册面就没有「注册」这件事。
|
||
return
|
||
}
|
||
if _, err := a.caps.Get(browsertools.ToolName); err == nil {
|
||
return // 同一 app 上重复装配:已经注册过,不重复探测
|
||
}
|
||
|
||
dataDir := resolveDataDir(a.cfg)
|
||
configPath := browsercfg.DefaultConfigPath(dataDir)
|
||
fileCfg, err := browsercfg.Load(configPath, dataDir)
|
||
if err != nil {
|
||
log.Warn("browser tool not registered",
|
||
"reason", browserReasonInvalidConfig, "configPath", configPath, "error", err,
|
||
"effect", browserNotRegisteredEffect)
|
||
return
|
||
}
|
||
effective := browsercfg.Defaults(dataDir)
|
||
configSource := browserConfigSourceProbe
|
||
if fileCfg != nil {
|
||
effective = *fileCfg
|
||
configSource = browserConfigSourceFile
|
||
}
|
||
if !a.registerBrowserTool(log, fileCfg, effective, configPath, configSource, dataDir) {
|
||
return
|
||
}
|
||
}
|
||
|
||
// registerBrowserTool 做「探测 → 建管理器 → 注册」这三步,返回是否真的注册了。
|
||
// 抽成独立函数是为了让每一处「不注册」都**只**有一个出口(口径不会在两条分支里漂移)。
|
||
func (a *app) registerBrowserTool(
|
||
log *slog.Logger, fileCfg *browsercfg.Config, effective browsercfg.Config,
|
||
configPath, configSource, dataDir string,
|
||
) bool {
|
||
ctx, cancel := context.WithTimeout(context.Background(), browserProbeTimeout)
|
||
avail := browsercfg.Resolve(ctx, fileCfg, browsercfg.NewProber())
|
||
cancel()
|
||
if !avail.Usable {
|
||
log.Warn("browser tool not registered",
|
||
"reason", string(avail.Reason), "detail", avail.Detail,
|
||
"configPath", configPath, "configSource", configSource,
|
||
"effect", browserNotRegisteredEffect)
|
||
return false
|
||
}
|
||
// profile 含 cookie / 登录态:0700,且不得落在 git 工作树内(会被 git add 收走)。
|
||
if err := browsercfg.EnsureProfileDir(effective.ProfileDir, dataDir); err != nil {
|
||
log.Warn("browser tool not registered",
|
||
"reason", browserReasonProfileRejected, "profileDir", effective.ProfileDir,
|
||
"error", err, "effect", browserNotRegisteredEffect)
|
||
return false
|
||
}
|
||
|
||
mgr := browsercap.NewManager(browsercap.Options{
|
||
Availability: avail,
|
||
Config: effective,
|
||
DataDir: dataDir,
|
||
Logger: a.log,
|
||
})
|
||
tool := browsertools.New(mgr, browsertools.WithLogger(a.log))
|
||
if err := a.caps.RegisterTool(tool); err != nil {
|
||
log.Warn("browser tool register skipped", "tool", browsertools.ToolName, "error", err)
|
||
return false
|
||
}
|
||
log.Info("browser tool registered",
|
||
"source", string(avail.Source), "headless", avail.Headless,
|
||
"configSource", configSource, "configPath", configPath,
|
||
"shotsDir", browsercfg.DefaultShotsDir(dataDir),
|
||
"richActions", "click/type/wait/extract 由 MCP(Playwright/Puppeteer server)提供,本工具不重造")
|
||
return true
|
||
}
|