Files
Cyrene_v2/cmd/cyrene/wire_web_search_test.go
T
AskaEth 8e6c77ad10
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): web-search 接线本机 SearXNG 兜底 + 显式开关(她不再拿一把用不了的锤子)
主人报的故障:她调 web-search 必失败(no search endpoint configured)。根因两条:组合根从未注入 WithSearchEndpoint + 配置层根本没有搜索配置项。
- internal/config/websearch.go:WEB_SEARCH_ENDPOINT(默认本机兜底 http://127.0.0.1:38471)+ WEB_SEARCH_TIMEOUT;ResolveEndpoint() 三态裁决(可用/显式关闭/非法),非法值启动即报错
- cmd/cyrene/wire_io_tools.go:组合根唯一的 io 选项装配点;端点不可用 ⇒ WithoutSearch() + 带 source 的 WARN(只记键名与原因,不打印 URL 值)
- plugins/tools/io:新增 WithoutSearch()(**根本不进注册面** ⇒ 模型看不见)+ WithSearchTimeout();协议解析逻辑一字未动
- 隐蔽坑(额外查出并修):SearXNG 对 /?q= 回 308 → /search,重定向那一跳走**共享 SSRF 白名单** ⇒ 只注入端点照样被拦。改为**只给 web-search 一件**加宽自己的客户端与白名单,共享白名单与 http/web-fetch 客户端一字未动(有测试钉住它仍拒绝本机网关地址)
- .env.example 新增 2 键 + 注释说明「空串/off/none = 不注册」
- 真机 e2e 两次绿(SearXNG 返回 9 条);变异:不注入端点 / 关闭态仍注册 / 默认值改错 / 不并入端点主机 / 重定向用共享白名单 ⇒ 5 条全红
2026-10-05 20:29:21 +08:00

372 lines
13 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
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
)
// 本文件钉住切片 WS1-A 的组合根接线:`WEB_SEARCH_ENDPOINT` / `WEB_SEARCH_TIMEOUT`
// 经 `a.ioToolOptions()` 传进 io 工具集,端点不可用时 web-search **不进注册面**。
//
// 断言落在**注册表**上(`toolNames`,即模型真正看到的那一面),而不是源码字符串 ——
// 源码接缝只作为最后一段的防回归补充。
// wsTestEnvFile 写一份只含 JWT_SECRET(唯一的必填键)加若干自定义行的 .env,
// 返回路径。走 config.Load 是为了让「未写这个键 ⇒ 用标签默认值」这条链真实发生。
func wsTestEnvFile(t *testing.T, extra ...string) string {
t.Helper()
lines := append([]string{"JWT_SECRET=" + strings.Repeat("s", 40)}, extra...)
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
return path
}
// wsLoadConfig 按给定 .env 行加载配置(进程环境优先级更高,由 config 层负责)。
func wsLoadConfig(t *testing.T, extra ...string) *config.Config {
t.Helper()
cfg, err := config.Load(wsTestEnvFile(t, extra...))
if err != nil {
t.Fatalf("config.Load: %v", err)
}
return cfg
}
// wsSurfaceApp 用组合根的**同一条路**把 io 工具注册进能力面:ioToolOptions →
// iotools.New → dispatcher.Register。logOut 非空时用它当日志出口(用于断言 WARN)。
func wsSurfaceApp(t *testing.T, cfg *config.Config, logOut *bytes.Buffer) *app {
t.Helper()
caps := registry.New()
dispatcher, err := tool.New(caps)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
a := &app{cfg: cfg, log: testLog()}
if logOut != nil {
a.log = logger.New(logger.Options{Out: logOut, Level: "debug"})
}
set, err := iotools.New(cfg, a.ioToolOptions()...)
if err != nil {
t.Fatalf("iotools.New: %v", err)
}
for _, tl := range set {
if rerr := dispatcher.Register(tl); rerr != nil {
t.Fatalf("register %s: %v", tl.Name(), rerr)
}
}
a.caps = caps
a.dispatcher = dispatcher
return a
}
// wsHasTool 报告注册表(模型可见的工具面)里有没有这个名字。
func wsHasTool(a *app, name string) bool {
for _, n := range toolNames(a) {
if n == name {
return true
}
}
return false
}
// isOffSentinel 报告取值是否属于「显式关闭」的三个哨兵词(大小写/空白不敏感)。
func isOffSentinel(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "off", "none":
return true
default:
return false
}
}
// TestWireIOTools_DefaultEndpointRegistersWebSearch 是判据 ①:**不写这个键**时
// 默认值生效(本机兜底),web-search 出现在注册面里。
func TestWireIOTools_DefaultEndpointRegistersWebSearch(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
const want = "http://127.0.0.1:38471"
if cfg.WebSearch.Endpoint != want {
t.Fatalf("默认端点 = %q, want %q", cfg.WebSearch.Endpoint, want)
}
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, "web-search") {
t.Fatalf("默认端点下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a))
}
for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} {
if !wsHasTool(a, name) {
t.Errorf("其余 io 工具不得受影响,但 %q 不在注册面里", name)
}
}
}
// TestWireIOTools_UnusableEndpointOmitsWebSearch 是判据 ②④:端点显式关闭
// (空串 / off / none)或不是可用 http(s) 地址时,web-search **不进注册面** ——
// 模型看不见它,且启动不报错(是「少一把工具」,不是「启动失败」)。同时必须留下
// 一条带键名的 WARN:配了却没用,绝不静默。
func TestWireIOTools_UnusableEndpointOmitsWebSearch(t *testing.T) {
t.Parallel()
cases := []struct {
name string
envLine string
wantValue string
}{
{"显式留空", "WEB_SEARCH_ENDPOINT=", ""},
{"off", "WEB_SEARCH_ENDPOINT=off", "off"},
{"大写 NONE", "WEB_SEARCH_ENDPOINT=NONE", "NONE"},
{"非 http scheme", "WEB_SEARCH_ENDPOINT=ftp://searx.example.com", "ftp://searx.example.com"},
{"缺 scheme", "WEB_SEARCH_ENDPOINT=127.0.0.1:38471", "127.0.0.1:38471"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
cfg := wsLoadConfig(t, tc.envLine)
if cfg.WebSearch.Endpoint != tc.wantValue {
t.Fatalf("端点 = %q, want %q", cfg.WebSearch.Endpoint, tc.wantValue)
}
a := wsSurfaceApp(t, cfg, &buf)
if wsHasTool(a, "web-search") {
t.Fatalf("端点不可用时 web-search 不得进注册面,实际工具面 = %v", toolNames(a))
}
// 其余工具照常(关掉的只是一件工具,不是整套 io 能力)。
for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} {
if !wsHasTool(a, name) {
t.Errorf("%q 不得因 web-search 关闭而消失", name)
}
}
// WARN 必须点出是哪个键、为什么(不打印 URL 本身,避免 URL 里带凭据)。
out := buf.String()
if !strings.Contains(out, webSearchEndpointEnv) {
t.Fatalf("日志里没有点出 %s:%q", webSearchEndpointEnv, out)
}
if !strings.Contains(out, "web-search tool not registered") {
t.Fatalf("日志里没有说明 web-search 未注册:%q", out)
}
// 关闭哨兵词本身会出现在原因文案里("空串 / off / none"),故只对
// 「像端点/主机」的那几个取值断言它没被打印。
if !isOffSentinel(tc.wantValue) && tc.wantValue != "" && strings.Contains(out, tc.wantValue) {
t.Fatalf("日志里出现了端点值本身(URL 可能带凭据,禁止打印):%q", out)
}
})
}
}
// TestWireIOTools_EndpointIsPassedThrough 是判据 ③:断言的不是「没报错」,而是
// **配置里的端点真的被用了** —— 用一个可注入的 httptest 出口收请求,核对路径/查询串。
func TestWireIOTools_EndpointIsPassedThrough(t *testing.T) {
t.Parallel()
hits := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits <- r
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`))
}))
defer srv.Close()
cfg := wsLoadConfig(t, "WEB_SEARCH_ENDPOINT="+srv.URL+"/search")
a := wsSurfaceApp(t, cfg, nil)
tl, err := a.caps.Get("web-search")
if err != nil {
t.Fatalf("web-search 不在注册面里: %v", err)
}
toolValue, ok := tl.Value.(interface {
Execute(context.Context, any) (any, error)
})
if !ok {
t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value)
}
if _, err := toolValue.Execute(context.Background(), map[string]any{"query": "昔涟"}); err != nil {
t.Fatalf("Execute: %v", err)
}
select {
case r := <-hits:
if r.URL.Path != "/search" {
t.Errorf("请求路径 = %q, want /search(配置里的端点必须原样使用)", r.URL.Path)
}
if got := r.URL.Query().Get("q"); got != "昔涟" {
t.Errorf("q = %q", got)
}
if got := r.URL.Query().Get("format"); got != "json" {
t.Errorf("format = %q, want json", got)
}
case <-time.After(2 * time.Second):
t.Fatal("配置里的端点一个请求都没收到 —— 配置没被透传到工具")
}
}
// TestWireIOTools_TimeoutIsWired 钉住 WEB_SEARCH_TIMEOUT 真的接到了工具上:
// 一个慢端点 + 100ms 预算必须被掐断(默认 15s 够它跑完,因此「被掐断」只可能来自
// 这个配置)。负值按未配置处理并记一条 WARN。
func TestWireIOTools_TimeoutIsWired(t *testing.T) {
t.Parallel()
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
}
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer srv.Close()
defer close(release)
cfg := wsLoadConfig(t,
"WEB_SEARCH_ENDPOINT="+srv.URL,
"WEB_SEARCH_TIMEOUT=100ms",
)
if cfg.WebSearch.Timeout != 100*time.Millisecond {
t.Fatalf("WEB_SEARCH_TIMEOUT 没被解析: %v", cfg.WebSearch.Timeout)
}
a := wsSurfaceApp(t, cfg, nil)
tl, err := a.caps.Get("web-search")
if err != nil {
t.Fatalf("web-search 不在注册面里: %v", err)
}
exec := tl.Value.(interface {
Execute(context.Context, any) (any, error)
})
if _, err := exec.Execute(context.Background(), map[string]any{"query": "cyrene"}); err == nil {
t.Fatal("100ms 预算下慢端点必须被掐断(说明配置没接到工具上)")
}
// 负值 = 笔误:按未配置处理(工具仍在),并留下一条带键名的 WARN。
var buf bytes.Buffer
negCfg := wsLoadConfig(t,
"WEB_SEARCH_ENDPOINT="+srv.URL,
"WEB_SEARCH_TIMEOUT=-5s",
)
negApp := wsSurfaceApp(t, negCfg, &buf)
if !wsHasTool(negApp, "web-search") {
t.Fatal("负超时只应退回默认,不该把工具从注册面摘掉")
}
if out := buf.String(); !strings.Contains(out, webSearchTimeoutEnv) {
t.Fatalf("负超时必须在日志里点出 %s:%q", webSearchTimeoutEnv, out)
}
}
// wsSearxngResultCount 用**裸 HTTP**问兜底服务(与工具实现无关)同一个查询,返回它此刻
// 真能产出的结果条数;不可达/应答不可解析时返回 -1。理由与命名同 io 侧的探针:上游
// 引擎被限流时兜底会「200 但 0 条」,那是外部降级,不该判成本仓的代码红。
func wsSearxngResultCount(endpoint, query string) int {
client := &http.Client{Timeout: 25 * time.Second}
resp, err := client.Get(endpoint + "/search?q=" + url.QueryEscape(query) + "&format=json")
if err != nil {
return -1
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return -1
}
var payload struct {
Results []struct {
URL string `json:"url"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return -1
}
return len(payload.Results)
}
// TestWireIOTools_RealSearXNG_EndToEnd 是**真机**端到端:默认配置(一个 WEB_SEARCH_*
// 键都不写)⇒ 组合根接线 ⇒ 注册面里的 web-search ⇒ 打本机兜底 SearXNG 真的拿到结果。
// 它只读外部服务,不碰后端进程、不碰仓库根产物。兜底没起、或其上游引擎被限流到产不出
// 结果时 **skip**(外部降级不判代码红;探针有结果而工具没有 = 真红)。
func TestWireIOTools_RealSearXNG_EndToEnd(t *testing.T) {
t.Parallel()
const query = "昔涟 崩坏星穹铁道"
cfg := wsLoadConfig(t)
upstream := wsSearxngResultCount(cfg.WebSearch.Endpoint, query)
if upstream < 0 {
t.Skipf("本机兜底 SearXNG 不可达(%s),跳过真机 e2e", cfg.WebSearch.Endpoint)
}
if upstream == 0 {
t.Skipf("兜底 SearXNG 当前产不出结果(上游引擎被限流/CAPTCHA),跳过真机 e2e")
}
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, "web-search") {
t.Fatalf("默认配置下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a))
}
tl, err := a.caps.Get("web-search")
if err != nil {
t.Fatalf("caps.Get(web-search): %v", err)
}
exec := tl.Value.(interface {
Execute(context.Context, any) (any, error)
})
got, err := exec.Execute(context.Background(), map[string]any{"query": query})
if err != nil {
t.Fatalf("默认端点真机查询失败: %v", err)
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("返回值类型 = %T", got)
}
count, _ := m["count"].(int)
if count == 0 {
t.Fatalf("裸 HTTP 探针拿到 %d 条、工具却拿到 0 条 —— 这不再是外部降级: %v", upstream, m)
}
t.Logf("真机 e2e(默认配置 %s):返回 %d 条结果", cfg.WebSearch.Endpoint, count)
}
// TestWireIOTools_SeamsAndEnvTemplate 是防回归补充:组合根那两个接缝还在,
// 两个 env 键与 `.env.example` 逐字一致(AGENTS §10 的 E6 固定动作)。
func TestWireIOTools_SeamsAndEnvTemplate(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_runtime.go": {
// io 工具集必须经这一个入口构造,否则端点裁决会被绕过。
"iotools.New(a.cfg, a.ioToolOptions()...)",
},
"wire_io_tools.go": {
"iotools.WithSearchEndpoint(decision.URL)",
"iotools.WithoutSearch()",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— web-search 的接线/开关断了", file, want)
}
}
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
template := string(raw)
for _, line := range []string{
webSearchEndpointEnv + "=",
webSearchTimeoutEnv + "=",
// 默认值必须在模板里如实写出(本机兜底),否则照模板部署的人看不出它接的是什么。
webSearchEndpointEnv + "=http://127.0.0.1:38471",
} {
if !strings.Contains(template, line) {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", line)
}
}
}