Files
Cyrene_v2/cmd/cyrene/wire_browser_test.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

226 lines
8.4 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
// 本文件钉住切片 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()
}