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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
This commit is contained in:
1 parent
ea150ceea9
commit
f1fbc249fd
21 files changed
+3579
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user