Files
Cyrene_v2/cmd/cyrene/wire_browser.go
T
AskaEth 2d211c2d16 feat(backthink,browser): 自排工具面 C1-3 + 浏览器两处偏离收口(X2+X3 同文件一并落)
X2 C1-3(自排工具面与提示词):
- request_think(thinktool.go,照 transcode/tool.go 范式)+ reason 必填/urgency 闭集 + 双守门
  (安全闸 + owner 判定:群聊/陌生人一律拒)
- **成对**进两张唤醒态允许集(quiet + awake;CAPACITY_TOOL_SETS 是整体替换)
- 提示词抽共用段落 + 自排前缀优先(不枚举工具清单);面板加 self_wake 标签与筛选轴
- 4 个变异自检(拿掉 owner 判定/只加一张表/放宽 urgency/前端标签恒等)

X3 收口(与 X2 共用 wire_services.go ⇒ 一并落):
- 删掉第三份 SSRF 实现(guard.go),改由 io.ValidateRequestURL 唯一判定 + 薄门面(保两个错误身份)
- 浏览器会话挂进 a.close(真机:真 Chrome 被关、无残留、生产未扰动)
- a.wireBrowser() 归位到 wire_runtime.go,删掉 ioToolOptions 的临时挂点
- 5 个变异自检

顺带补提交 C1-2 漏掉的 internal/config/runtimeconfig/backthink_test.go(ed50053 漏 add)。
2026-10-06 21:16:23 +08:00

139 lines
6.7 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. **幂等**:重复装配只发现工具已在注册表里并跳过,不重复注册、不重复探测。
//
// 关于挂点(切片 X3 归位):本文件的 `wireBrowser()` 由 `wireRuntime` 调用,位置与
// `wireVision()` / `wireTranscode()` 并列 —— 三者同类(可选增强,不可用就不注册)。
// 它**不再**借 `ioToolOptions()` 转交:那件函数是「组装 io 选项」的只读装配,顺带探测
// 并注册浏览器会让所有只为要一份选项而调它的路径(web-search 的接线测试就是)连带起
// 一次浏览器探测。它的关闭挂点在 `a.close`(wire_services.go),与这一行成对。
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
}