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

252 lines
9.6 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_DoesNotWireBrowser 钉住**临时挂点已删**(切片 X3 把挂点归位到
// `wireRuntime`):browser 的接线原先借 `ioToolOptions()` 转交(V5 改不了
// wire_runtime.go 时的权宜),代价是「取一份 io 选项」这件只读的事会顺带探测并注册
// 浏览器工具 —— 所有只为要选项而调本函数的路径(web-search 的接线测试就是)都会连带
// 起一次浏览器探测。归位后的挂点在 wire_runtime.go,由
// TestWireRuntime_CallsWireBrowser 钉住;这里钉住 io 侧**不再有第二个挂点** ——
// 两处并存不会报错(函数幂等),只会静默多一次探测,因此只有测试能拦住它回流。
func TestIOToolOptions_DoesNotWireBrowser(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()
if _, ok := browserEntry(a); ok {
t.Fatalf("ioToolOptions 不该再挂 browser(挂点已归位 wire_runtime.go;日志:%s)", out.String())
}
}
// TestWireBrowser_Idempotent 钉住**幂等**:同一个 app 上重复装配只发现工具已在注册表里
// 并跳过,不重复注册、不重复探测。挂点归位后幂等仍是唯一的保险(两条路径万一偶遇)。
func TestWireBrowser_Idempotent(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.wireBrowser()
first, ok := browserEntry(a)
if !ok {
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
}
a.wireBrowser()
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()
}