feat(browser): B1 接线 + B2 薄层工具面(V5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
This commit is contained in:
AskaEth committed 2026-10-06 20:25:16 +08:00
1 parent ea150ceea9
commit f1fbc249fd
21 files changed
+3579

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
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
}
+147
View File
@@ -0,0 +1,147 @@
package main
// 本文件是 `browser` 接线的**真机端到端**(默认不跑):从浏览器配置 → 组合根接线 →
// 注册面 → 模型会调用的那把工具 → 真 Chrome → 截图落盘,全程不假任何一层。
//
// 用法(Windows / Git Bash):
//
// CYRENE_BROWSER_E2E=1 go test ./cmd/cyrene/ -run TestBrowserTool_RealHostEndToEnd -v -timeout 5m
//
// 为什么默认 Skip:它会**真的起一个浏览器**、并且要打一次外网(本机没有 Chrome 时
// 自动 Skip)。这类验证属于「人工按需复跑」,不该混进普通 `go test`(AGENTS §10
// 的压测纪律:不在验收里无界占机)。
//
// 为什么用**无头**:这是自动化验证,不该在主人的桌面上弹一个窗口;有头路径的
// 真实验证走 `internal/capability/browser/compare_test.go` 的对照脚本(它默认无头/有头各跑一遍)。
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"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/pkg/plugins/sdk"
)
// browserE2EEnabled 报告是否显式开启了真机 e2e。
func browserE2EEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("CYRENE_BROWSER_E2E"))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// TestBrowserTool_RealHostEndToEnd 走完整条链并断言**页面自报**的事实。
func TestBrowserTool_RealHostEndToEnd(t *testing.T) {
if !browserE2EEnabled() {
t.Skip("未设 CYRENE_BROWSER_E2E=1:真机端到端默认不跑(用法见本文件顶部)")
}
exe, ok := browsercfg.NewProber().FindLocalBrowser()
if !ok {
t.Skip("本机没有 Chrome/Chromium,真机 e2e 无法进行")
}
cfg := wsLoadConfig(t)
raw, _ := json.Marshal(map[string]any{
"executable": exe,
"headless": true,
"timeoutSeconds": 60,
})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
cap, ok := browserEntry(a)
if !ok {
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
}
tool, ok := cap.Value.(sdk.Tool)
if !ok {
t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value)
}
// 收尾必须真的把浏览器关掉:用例结束会删临时目录(含 profile),Chrome 还在跑就会被
// 占用而删不掉。这条 defer 同时是**生产缺口**的现场证明(Tool.Close 目前无人调用)。
if closer, ok := tool.(interface{ Close() error }); ok {
defer func() {
if cerr := closer.Close(); cerr != nil {
t.Errorf("Close: %v", cerr)
}
}()
} else {
t.Error("工具没实现 Close:组合根将没有关闭浏览器的挂点")
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
// ① 指纹自检:证明「这次真的是没在自曝」——navigator.webdriver 必须是 false。
info, err := tool.Execute(ctx, map[string]any{"action": "info"})
if err != nil {
t.Fatalf("action=info: %v", err)
}
fp, ok := info.(browsercap.Fingerprint)
if !ok {
t.Fatalf("info 结果类型 = %T", info)
}
if fp.Webdriver {
t.Errorf("navigator.webdriver = true:自动化痕迹没关掉(%+v)", fp)
}
t.Logf("指纹自检:headless=%v webdriver=%v ua=%s tz=%s lang=%s screen=%dx%d outer=%dx%d",
fp.Headless, fp.Webdriver, fp.UserAgent, fp.Timezone, fp.Language,
fp.ScreenWidth, fp.ScreenHeight, fp.OuterWidth, fp.OuterHeight)
if strings.Contains(strings.ToLower(fp.UserAgent), "headlesschrome") {
t.Logf("提示:无头档的 UA 带 HeadlessChrome —— 这是无头与有头唯一必然不同的一位")
}
// ② 导航:读回页面**自报**的标题(不是我们以为的)。
opened, err := tool.Execute(ctx, map[string]any{"action": "open", "url": "https://example.com/"})
if err != nil {
t.Fatalf("action=open: %v", err)
}
page, ok := opened.(browsercap.PageResult)
if !ok {
t.Fatalf("open 结果类型 = %T", opened)
}
if !strings.Contains(page.Title, "Example") {
t.Errorf("标题 = %q,期望页面自报的 Example Domain", page.Title)
}
if page.ReadyState != "complete" {
t.Errorf("readyState = %q", page.ReadyState)
}
t.Logf("导航:title=%q url=%q readyState=%q source=%q", page.Title, page.URL, page.ReadyState, page.Source)
// ③ 截图落盘:路径存在、非空、尺寸来自字节本身。
shotRaw, err := tool.Execute(ctx, map[string]any{"action": "screenshot"})
if err != nil {
t.Fatalf("action=screenshot: %v", err)
}
shot := shotRaw.(map[string]any)
path, _ := shot["path"].(string)
if path == "" {
t.Fatalf("截图没有路径: %#v", shot)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatalf("截图没落盘: %v", err)
}
if fi.Size() == 0 {
t.Fatal("截图是 0 字节")
}
if !strings.HasPrefix(path, filepath.Join(cfg.DataDir, "browser", "shots")) {
t.Errorf("截图没落在 $DATA_DIR/browser/shots 下: %s", path)
}
width, _ := shot["width"].(int)
height, _ := shot["height"].(int)
if width <= 0 || height <= 0 {
t.Errorf("尺寸没解出来: %#v", shot)
}
t.Logf("截图:path=%s bytes=%v %dx%d sha256=%v", path, shot["bytes"], width, height, shot["sha256"])
}
+225
View File
@@ -0,0 +1,225 @@
package main
// 本文件钉住切片 V5 的组合根接线:`browser` 工具**只在有可用来源时**进注册面,
// 每一个「不注册」的原因都**分类**写进日志(照 web-search `off` 的诚实口径),
// 而 profile 目录的凭据守卫(不得进 git 工作树)在注册之前生效。
//
// 断言落在**注册表**(模型真正看到的那一面)与**日志字段**上,而不是源码字符串。
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// browserTestApp 造一个「有注册面 + 有日志出口」的 app:browser 的接线只依赖这三件。
func browserTestApp(t *testing.T, cfg *config.Config, out *bytes.Buffer) *app {
t.Helper()
return &app{
cfg: cfg,
log: logger.New(logger.Options{Out: out, Level: "debug"}),
caps: registry.New(),
}
}
// writeBrowserConfig 把一份 browser.json 写进 $DATA_DIR/config/plugins/(原样字节)。
func writeBrowserConfig(t *testing.T, cfg *config.Config, raw string) string {
t.Helper()
path := browsercfg.DefaultConfigPath(cfg.DataDir)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("建配置目录: %v", err)
}
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatalf("写 %s: %v", path, err)
}
return path
}
// browserEntry 报告注册面里有没有 browser 工具。
func browserEntry(a *app) (registry.Capability, bool) {
cap, err := a.caps.Get(browsertools.ToolName)
return cap, err == nil
}
// TestWireBrowser_InvalidConfigDoesNotRegister 钉住「配置非法 ⇒ 不注册 + 分类原因」:
// 静默拿默认值顶替会让面板里那份坏配置永远看不见。
func TestWireBrowser_InvalidConfigDoesNotRegister(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
writeBrowserConfig(t, cfg, "{ this is not json")
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
if _, ok := browserEntry(a); ok {
t.Fatal("配置非法时 browser 不该进注册面")
}
if got := wsLogField(t, out.String(), "reason"); got != browserReasonInvalidConfig {
t.Errorf("reason = %q, want %q", got, browserReasonInvalidConfig)
}
}
// TestWireBrowser_MissingExecutableDoesNotRegister 钉住「面板把路径写错 ⇒ 不注册」,
// 且原因来自 browsercfg 的分类码(不偷偷回退到自动探测)。
func TestWireBrowser_MissingExecutableDoesNotRegister(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
raw, _ := json.Marshal(map[string]any{"executable": filepath.Join(t.TempDir(), "no-such-browser.exe")})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
if _, ok := browserEntry(a); ok {
t.Fatal("可执行文件不存在时 browser 不该进注册面")
}
if got := wsLogField(t, out.String(), "reason"); got != string(browsercfg.ReasonExecutableMissing) {
t.Errorf("reason = %q, want %q", got, browsercfg.ReasonExecutableMissing)
}
}
// TestWireBrowser_UnreachableEndpointDoesNotRegister 钉住 CDP 端点连不上 ⇒ 不注册,
// 原因分类为 cdp_unreachable(配置显式给了端点却不可达,就是「我配的容器没生效」,
// 必须让人看见)。
func TestWireBrowser_UnreachableEndpointDoesNotRegister(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
// 127.0.0.1 上一个确定没人听的端口:连不上会立刻返回(不是超时)。
raw, _ := json.Marshal(map[string]any{"cdpEndpoint": "http://127.0.0.1:1"})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
if _, ok := browserEntry(a); ok {
t.Fatal("CDP 端点不可达时 browser 不该进注册面")
}
if got := wsLogField(t, out.String(), "reason"); got != string(browsercfg.ReasonCDPUnreachable) {
t.Errorf("reason = %q, want %q", got, browsercfg.ReasonCDPUnreachable)
}
}
// TestWireBrowser_UsableExecutableRegisters 钉住正向路径:配置给出的可执行文件存在
// ⇒ 工具进注册面,且 profile 目录被按 0700 建好(含凭据的目录不能是 0755)。
func TestWireBrowser_UsableExecutableRegisters(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o700); err != nil {
t.Fatalf("造假可执行文件: %v", err)
}
profileDir := filepath.Join(cfg.DataDir, "browser", "profile-x")
raw, _ := json.Marshal(map[string]any{"executable": exe, "profileDir": profileDir})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
cap, ok := browserEntry(a)
if !ok {
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
}
if cap.Kind != registry.KindTool {
t.Errorf("Kind = %q, want tool", cap.Kind)
}
// 默认权限口径:owner-only(两布尔皆 false)。
if cap.Policy.AllowInGroup || cap.Policy.AllowInStranger {
t.Errorf("Policy = %+v,默认应是 owner-only(design/28 §4 第 2 条)", cap.Policy)
}
if _, err := os.Stat(profileDir); err != nil {
t.Errorf("profile 目录应先被建好: %v", err)
}
// 工具名/动作面:注册进去的那把工具就是薄层那一个。
if tool, ok := cap.Value.(interface{ Name() string }); !ok || tool.Name() != browsertools.ToolName {
t.Errorf("注册的值不是 browser 工具: %#v", cap.Value)
}
}
// TestWireBrowser_ProfileInGitTreeIsRejected 钉住凭据守卫:profile 落在 git 工作树里
// ⇒ **不注册**(被 `git add` 收走就是把登录态提交出去,design/28 §4)。
func TestWireBrowser_ProfileInGitTreeIsRejected(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
t.Fatalf("造假可执行文件: %v", err)
}
// 造一个「像仓库」的目录:里面有 `.git`,profile 落在它下面、且在 dataDir 之外。
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatalf("造 .git: %v", err)
}
profileDir := filepath.Join(repo, "browser-profile")
raw, _ := json.Marshal(map[string]any{"executable": exe, "profileDir": profileDir})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
if _, ok := browserEntry(a); ok {
t.Fatal("profile 位于 git 工作树内时 browser 不该进注册面")
}
if got := wsLogField(t, out.String(), "reason"); got != browserReasonProfileRejected {
t.Errorf("reason = %q, want %q", got, browserReasonProfileRejected)
}
}
// TestIOToolOptions_WiresBrowserExactlyOnce 钉住**挂点与幂等**:browser 的接线挂在
// `ioToolOptions()`(组合根启动期唯一归属本片的装配入口)上,且重复调用不重复注册。
func TestIOToolOptions_WiresBrowserExactlyOnce(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
t.Fatalf("造假可执行文件: %v", err)
}
raw, _ := json.Marshal(map[string]any{"executable": exe})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.ioToolOptions()
first, ok := browserEntry(a)
if !ok {
t.Fatalf("ioToolOptions 应当把 browser 接上(日志:%s)", out.String())
}
a.ioToolOptions()
second, ok := browserEntry(a)
if !ok {
t.Fatal("第二次调用后 browser 不该消失")
}
if first.Name != second.Name || first.Kind != second.Kind {
t.Errorf("重复装配改写了条目:%+v -> %+v", first, second)
}
// 注册面里只该有一个 browser(registry 重名会被拒;这里确认没被静默换掉)。
count := 0
for _, c := range a.caps.List(registry.KindTool) {
if c.Name == browsertools.ToolName {
count++
}
}
if count != 1 {
t.Errorf("注册面里 browser 条目数 = %d, want 1", count)
}
}
// TestWireBrowser_NilRegistryIsSkipped 钉住夹具友好性:没有注册面时**不 panic**
// (部分既有测试夹具只填了 app 的几个字段)。
func TestWireBrowser_NilRegistryIsSkipped(t *testing.T) {
t.Parallel()
a := &app{}
a.wireBrowser()
}
+7
View File
@@ -80,7 +80,14 @@ const webSearchToolName = "web-search"
// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New,
// 因此「注册面里有哪些 io 工具」只由这一处决定。
//
// 它**同时是切片 V5 的 browser 接线挂点**:组合根在启动期只调用这一个归属本片的装配
// 函数(wire_runtime.go 的 `a.ioToolOptions()`),而 browser 的接线不得改他人拥有的
// `wire_runtime.go` / `wire.go` ⇒ 由这里转交给 `wireBrowser()`(见 wire_browser.go 的
// 口径说明)。转交对 io 选项**零影响**(它不改、不追加任何 iotools.Option),并且
// **幂等**:同一个 app 上重复调用只会发现工具已注册并跳过。
func (a *app) ioToolOptions() []iotools.Option {
a.wireBrowser()
opts := []iotools.Option{
iotools.WithKnowledgeRetriever(a.retriever),
iotools.WithLogger(a.log),