Files
Cyrene_v2/cmd/cyrene/wire_browser.go
T
AskaEth f1fbc249fd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): B1 接线 + B2 薄层工具面(V5)
- 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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
2026-10-06 20:25:16 +08:00

138 lines
6.5 KiB
Go
Raw 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 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
}