From 8e6c77ad1045ae3669059c878da544612cc4408f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 20:29:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(search):=20web-search=20=E6=8E=A5=E7=BA=BF?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA=20SearXNG=20=E5=85=9C=E5=BA=95=20+=20?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E5=BC=80=E5=85=B3=EF=BC=88=E5=A5=B9=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8B=BF=E4=B8=80=E6=8A=8A=E7=94=A8=E4=B8=8D=E4=BA=86?= =?UTF-8?q?=E7=9A=84=E9=94=A4=E5=AD=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人报的故障:她调 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 条全红 --- .env.example | 15 + cmd/cyrene/wire_io_tools.go | 66 ++++ cmd/cyrene/wire_runtime.go | 4 +- cmd/cyrene/wire_web_search_test.go | 371 +++++++++++++++++++++ internal/config/config.go | 1 + internal/config/websearch.go | 72 ++++ internal/config/websearch_test.go | 129 +++++++ plugins/tools/io/doc.go | 8 +- plugins/tools/io/tools.go | 100 +++++- plugins/tools/io/web_search.go | 51 ++- plugins/tools/io/web_search_wiring_test.go | 340 +++++++++++++++++++ 11 files changed, 1137 insertions(+), 20 deletions(-) create mode 100644 cmd/cyrene/wire_io_tools.go create mode 100644 cmd/cyrene/wire_web_search_test.go create mode 100644 internal/config/websearch.go create mode 100644 internal/config/websearch_test.go create mode 100644 plugins/tools/io/web_search_wiring_test.go diff --git a/.env.example b/.env.example index 40ac39a..39e3f4c 100644 --- a/.env.example +++ b/.env.example @@ -223,6 +223,21 @@ STT_PROVIDER=dashscope # ⚠️ 建音色必须公网可访问的参考音频 URL(create_voice 只收 input.url,不收 base64); # 待主人提供参考音频公网 URL 方能真建音色。 +# ---- 网络搜索(web-search 工具) ---- +# WEB_SEARCH_ENDPOINT = web-search 工具查询的 SearXNG 兼容端点(GET + format=json)。 +# 默认值 http://127.0.0.1:38471 是**本机兜底**:scripts/searxng/settings.yml 就是它的 +# 配套配置(docker 起法见该文件头注释),无需任何第三方 API key、开箱即用 —— 默认指向 +# 本机而不是某个云搜索服务,原因就在这。要换云 provider 就把新地址写在这里。 +WEB_SEARCH_ENDPOINT=http://127.0.0.1:38471 +# 显式关闭:把上面这行写成空串 / off / none(大小写不敏感)⇒ **web-search 不注册**, +# 模型根本看不见它(不给一把一调就报错的锤子)。值不是 http(s) 绝对地址时同样不注册, +# 启动日志会有一条带键名的 WARN 说明原因(绝不静默半开)。 +# WEB_SEARCH_ENDPOINT=off +# WEB_SEARCH_TIMEOUT = 单次搜索请求的超时(Go duration,如 20s)。 +# 不配置时**沿用 io 包既有超时(15s)** —— 这里刻意不写第二个默认值。 +# 它只影响 web-search 一次调用,不改 http / web-fetch 用的那个共享客户端。 +# WEB_SEARCH_TIMEOUT=20s + # ---- 后台思考 ---- ENABLE_BACKGROUND_THINKING=true # 自主思考心跳周期(秒)。默认 900(15 分钟一次);数值越小越频繁打扰。 diff --git a/cmd/cyrene/wire_io_tools.go b/cmd/cyrene/wire_io_tools.go new file mode 100644 index 0000000..1ecb9a8 --- /dev/null +++ b/cmd/cyrene/wire_io_tools.go @@ -0,0 +1,66 @@ +package main + +// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志,以及 +// **web-search 的端点与超时**(切片 WS1-A)。 +// +// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限, +// 而本片新增的裁决(端点关闭/非法 ⇒ 工具不进注册面)需要一个能单测的落点。 +// +// 三条口径: +// +// 1. **端点默认开箱即用**:`WEB_SEARCH_ENDPOINT` 未配置时取 config 标签默认值 +// (本机兜底 http://127.0.0.1:38471,配套服务见 scripts/searxng/settings.yml)。 +// 2. **拿不到可用端点就不注册工具**(诚意要求,主人 2026-10-05 报障的核心诉求): +// 端点显式关闭(空串 / off / none)或不是 http(s) 绝对地址时,web-search +// **不进注册面** —— 模型根本看不见它,而不是看得见、一调就报错。 +// 3. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` 未配置(0)时沿用 io 包既有超时; +// 负值是笔误,按未配置处理并记一条带 env 键名的 WARN(不静默)。 + +import ( + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" +) + +// web-search 的两个 env 键名:必须与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目), +// 由 wire_web_search_test.go 逐字核对。 +const ( + webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT" + webSearchTimeoutEnv = "WEB_SEARCH_TIMEOUT" +) + +// ioLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。 +const ioLogSource = "cmd/cyrene/wire_io_tools" + +// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New, +// 因此「注册面里有哪些 io 工具」只由这一处决定。 +func (a *app) ioToolOptions() []iotools.Option { + opts := []iotools.Option{ + iotools.WithKnowledgeRetriever(a.retriever), + iotools.WithLogger(a.log), + } + return append(opts, a.webSearchOptions()...) +} + +// webSearchOptions 把 web-search 的配置翻译成 io 选项。端点裁决见 +// config.WebSearchConfig.ResolveEndpoint(关闭 / 非法 / 可用三种去处都在那里定死)。 +func (a *app) webSearchOptions() []iotools.Option { + log := logger.WithSource(a.log, ioLogSource) + decision := a.cfg.WebSearch.ResolveEndpoint() + if !decision.Enabled { + // 端点的值**绝不写进日志**(URL 里可能带凭据),只记键名与原因。 + log.Warn("web-search tool not registered", + "env", webSearchEndpointEnv, "reason", decision.Reason, + "effect", "the model will not see a web-search tool at all") + return []iotools.Option{iotools.WithoutSearch()} + } + + opts := []iotools.Option{iotools.WithSearchEndpoint(decision.URL)} + switch { + case a.cfg.WebSearch.Timeout > 0: + opts = append(opts, iotools.WithSearchTimeout(a.cfg.WebSearch.Timeout)) + case a.cfg.WebSearch.Timeout < 0: + log.Warn("web-search timeout is negative, treated as unset", + "env", webSearchTimeoutEnv, "effect", "io package default timeout applies") + } + return opts +} diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index 8c76ecc..bc54e86 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -159,7 +159,9 @@ func (a *app) wireRuntime() error { if err := basic.Register(a.caps); err != nil { return fmt.Errorf("register basic tools: %w", err) } - ioTools, ioErr := iotools.New(a.cfg, iotools.WithKnowledgeRetriever(a.retriever), iotools.WithLogger(a.log)) + // io 工具集的选项(含 web-search 的端点与超时裁决)在 wire_io_tools.go 一处组装: + // 端点不可用时它交出 WithoutSearch(),使 web-search **不进注册面**(WS1-A)。 + ioTools, ioErr := iotools.New(a.cfg, a.ioToolOptions()...) if ioErr != nil { return fmt.Errorf("build io tools: %w", ioErr) } diff --git a/cmd/cyrene/wire_web_search_test.go b/cmd/cyrene/wire_web_search_test.go new file mode 100644 index 0000000..9f5d953 --- /dev/null +++ b/cmd/cyrene/wire_web_search_test.go @@ -0,0 +1,371 @@ +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) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 66ea0a9..f4e6380 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,6 +54,7 @@ type Config struct { Context ContextConfig Guardrail GuardrailConfig MemoryTidy MemoryTidyConfig + WebSearch WebSearchConfig } // ServerConfig holds the single gateway listener settings. diff --git a/internal/config/websearch.go b/internal/config/websearch.go new file mode 100644 index 0000000..a032dee --- /dev/null +++ b/internal/config/websearch.go @@ -0,0 +1,72 @@ +package config + +import ( + "net/url" + "strings" + "time" +) + +// WebSearchConfig 是 web-search 工具的搜索端点配置(design/09-插件生态;切片 WS1-A)。 +// 写法照既有 VoiceConfig 惯例:一组 provider 键 + 未配置则如实降级,绝不硬编码。 +type WebSearchConfig struct { + // Endpoint 是 SearXNG 兼容搜索端点(支持 `format=json` 的 GET 接口)。 + // + // 默认值指向**本机兜底**:`scripts/searxng/settings.yml` 配套的 SearXNG 实例 + // (宿主机 http://127.0.0.1:38471)。默认指向本机而不是某个云搜索 API,是因为 + // 兜底实例不需要任何第三方 key、开箱即用;要换成云 provider 请在本键里写新地址。 + // + // **显式关闭**(值写成空串 / off / none,大小写与首尾空白不敏感)⇒ 组合根 + // **不注册** web-search 工具:模型根本看不见它,而不是注册后每次调用都报错 + // (见 ResolveEndpoint)。 + Endpoint string `env:"WEB_SEARCH_ENDPOINT" default:"http://127.0.0.1:38471"` + // Timeout 是单次搜索请求的超时(Go duration 字符串,如 "20s")。 + // + // **未配置(零值)语义 = 「沿用 io 包既有超时」**:这里刻意不写第二个默认值, + // 否则「文档承诺的超时」与「工具真正用的超时」会各有一份而悄悄漂移。 + Timeout time.Duration `env:"WEB_SEARCH_TIMEOUT"` +} + +// EndpointDecision 是 WEB_SEARCH_ENDPOINT 的解析结论:要不要注册 web-search, +// 以及注册时它该指向哪里。它把「关闭」「非法」「可用」三种去处做成**显式**答案, +// 使组合根不必自己猜语义,也堵死「未配置却挂着报错」那种半开状态。 +type EndpointDecision struct { + // Enabled 为 true ⇒ 注册 web-search 并把端点指向 URL。 + Enabled bool + // URL 是校验通过的 http(s) 绝对地址(Enabled 为 true 时才有值)。 + URL string + // Reason 在 Enabled 为 false 时说明原因(关闭 / 不是 http(s) 地址), + // 供组合根记一条带 env 键名的 WARN;Enabled 为 true 时为空。 + Reason string +} + +// Reason 文案常量:组合根直接把它们写进日志,避免两处各写一份措辞。 +const ( + // WebSearchReasonOff 表示端点被显式关闭(空串 / off / none)。 + WebSearchReasonOff = "显式关闭(空串 / off / none)" + // WebSearchReasonInvalid 表示端点既不是关闭也不是可用的 http(s) 绝对地址。 + WebSearchReasonInvalid = "不是可用的 http(s) 地址" +) + +// ResolveEndpoint 把配置值解析成端点裁决: +// +// - 显式关闭(空串 / off / none,大小写与首尾空白不敏感)⇒ Enabled=false、 +// Reason=WebSearchReasonOff —— 组合根据此**不注册**该工具; +// - 不是 http/https 绝对地址(含缺主机、写错 scheme,如 `ftp://x`、`127.0.0.1:38471`) +// ⇒ Enabled=false、Reason=WebSearchReasonInvalid —— 同样不注册,并记一条 WARN; +// 宁可让模型看不见这把锤子,也不给它一把抡起来必报错的锤子; +// - 其余 ⇒ Enabled=true、URL=去空白后的原值。 +// +// 注意「未配置」不落到这里:没写 WEB_SEARCH_ENDPOINT 时字段已被标签默认值填好。 +func (w WebSearchConfig) ResolveEndpoint() EndpointDecision { + raw := strings.TrimSpace(w.Endpoint) + // 显式关闭的三个哨兵词(大小写不敏感):空串、off、none。 + switch strings.ToLower(raw) { + case "", "off", "none": + return EndpointDecision{Reason: WebSearchReasonOff} + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return EndpointDecision{Reason: WebSearchReasonInvalid} + } + return EndpointDecision{Enabled: true, URL: raw} +} diff --git a/internal/config/websearch_test.go b/internal/config/websearch_test.go new file mode 100644 index 0000000..a79497b --- /dev/null +++ b/internal/config/websearch_test.go @@ -0,0 +1,129 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +// 本文件钉住切片 WS1-A 的两个配置键:`WEB_SEARCH_ENDPOINT`(默认 = 本机兜底)与 +// `WEB_SEARCH_TIMEOUT`(可选,未配置 = 沿用 io 包既有超时)。 +// +// 关闭语义(空串 / off / none)由 ResolveEndpoint 定死:组合根据此**不注册** +// web-search 工具,因此这三种写法必须与「可用端点」区分得干干净净。 + +// TestWebSearch_DefaultEndpointIsLocalFallback 钉住默认值字面量:模板与代码承诺的 +// 就是本机兜底地址(scripts/searxng/settings.yml 的配套实例)。 +func TestWebSearch_DefaultEndpointIsLocalFallback(t *testing.T) { + t.Parallel() + cfg, err := Load(writeEnvFile(t, "JWT_SECRET="+strings.Repeat("s", 40)+"\n")) + if err != nil { + t.Fatalf("Load: %v", err) + } + const want = "http://127.0.0.1:38471" + if cfg.WebSearch.Endpoint != want { + t.Fatalf("WEB_SEARCH_ENDPOINT 默认值 = %q, want %q", cfg.WebSearch.Endpoint, want) + } + decision := cfg.WebSearch.ResolveEndpoint() + if !decision.Enabled || decision.URL != want { + t.Fatalf("默认端点必须可用: %+v", decision) + } + // 未配置超时 = 零值 ⇒ 沿用 io 包既有超时(本层不得另造默认值)。 + if cfg.WebSearch.Timeout != 0 { + t.Fatalf("WEB_SEARCH_TIMEOUT 未配置时应为零值,got %v", cfg.WebSearch.Timeout) + } +} + +// TestWebSearch_EnvOverridesEndpointAndTimeout 钉住两个键都能被 .env 覆盖。 +func TestWebSearch_EnvOverridesEndpointAndTimeout(t *testing.T) { + t.Parallel() + cfg, err := Load(writeEnvFile(t, strings.Join([]string{ + "JWT_SECRET=" + strings.Repeat("s", 40), + "WEB_SEARCH_ENDPOINT=https://searx.example.com/search", + "WEB_SEARCH_TIMEOUT=25s", + "", + }, "\n"))) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.WebSearch.Endpoint != "https://searx.example.com/search" { + t.Fatalf("Endpoint = %q", cfg.WebSearch.Endpoint) + } + if cfg.WebSearch.Timeout != 25*time.Second { + t.Fatalf("Timeout = %v, want 25s", cfg.WebSearch.Timeout) + } +} + +// TestWebSearch_ExplicitEmptyMeansOff 钉住「写了键但留空 = 关闭」,它与「没写这个键 +// (⇒ 默认兜底)」是两件事:前者必须让工具从注册面消失,后者必须开箱可用。 +func TestWebSearch_ExplicitEmptyMeansOff(t *testing.T) { + t.Parallel() + cfg, err := Load(writeEnvFile(t, strings.Join([]string{ + "JWT_SECRET=" + strings.Repeat("s", 40), + "WEB_SEARCH_ENDPOINT=", + "", + }, "\n"))) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.WebSearch.Endpoint != "" { + t.Fatalf("显式留空后 Endpoint = %q, want \"\"", cfg.WebSearch.Endpoint) + } + decision := cfg.WebSearch.ResolveEndpoint() + if decision.Enabled || decision.Reason != WebSearchReasonOff { + t.Fatalf("空串必须是「显式关闭」: %+v", decision) + } +} + +// TestWebSearch_InvalidTimeoutFailsLoad 钉住非法 duration 的明确行为:启动即报错 +// (fail fast),绝不静默当成 0 或别的值。 +func TestWebSearch_InvalidTimeoutFailsLoad(t *testing.T) { + t.Parallel() + _, err := Load(writeEnvFile(t, strings.Join([]string{ + "JWT_SECRET=" + strings.Repeat("s", 40), + "WEB_SEARCH_TIMEOUT=25", + "", + }, "\n"))) + if err == nil { + t.Fatal("WEB_SEARCH_TIMEOUT=25 不是合法 duration,Load 必须报错而不是静默取值") + } + if !strings.Contains(err.Error(), "WEB_SEARCH_TIMEOUT") { + t.Fatalf("错误信息必须点出是哪个键: %v", err) + } +} + +// TestWebSearchConfig_ResolveEndpoint 是关闭 / 非法 / 可用三种去处的表驱动裁决。 +func TestWebSearchConfig_ResolveEndpoint(t *testing.T) { + t.Parallel() + cases := []struct { + name string + endpoint string + wantEnabled bool + wantURL string + wantReason string + }{ + {"空串 = 关闭", "", false, "", WebSearchReasonOff}, + {"off", "off", false, "", WebSearchReasonOff}, + {"none", "none", false, "", WebSearchReasonOff}, + {"大写 OFF", "OFF", false, "", WebSearchReasonOff}, + {"带空白 None", " None ", false, "", WebSearchReasonOff}, + {"http 可用", "http://127.0.0.1:38471", true, "http://127.0.0.1:38471", ""}, + {"https 带路径", "https://searx.example.com/search", true, "https://searx.example.com/search", ""}, + {"周围空白被裁掉", " http://searx.example.com ", true, "http://searx.example.com", ""}, + {"scheme 非 http(s)", "ftp://searx.example.com", false, "", WebSearchReasonInvalid}, + {"缺主机", "http:///search", false, "", WebSearchReasonInvalid}, + {"裸 host:port", "127.0.0.1:38471", false, "", WebSearchReasonInvalid}, + {"纯文字", "searx", false, "", WebSearchReasonInvalid}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := WebSearchConfig{Endpoint: tc.endpoint}.ResolveEndpoint() + if got.Enabled != tc.wantEnabled || got.URL != tc.wantURL || got.Reason != tc.wantReason { + t.Fatalf("ResolveEndpoint(%q) = %+v, want {Enabled:%v URL:%q Reason:%q}", + tc.endpoint, got, tc.wantEnabled, tc.wantURL, tc.wantReason) + } + }) + } +} diff --git a/plugins/tools/io/doc.go b/plugins/tools/io/doc.go index 7daad59..5705c50 100644 --- a/plugins/tools/io/doc.go +++ b/plugins/tools/io/doc.go @@ -20,7 +20,13 @@ // - http: GET/POST only, timeouts, response truncation and SSRF protection // (loopback/private/link-local addresses and localhost are refused unless // the host is on an explicit allowlist). -// - web-search: requires a configured search endpoint, otherwise a clear error. +// - web-search: queries the configured SearXNG-compatible endpoint. When no +// usable endpoint is configured the composition root passes WithoutSearch, so +// the tool is not registered at all — the model never sees a tool that could +// only error. The endpoint itself is operator configuration (the model only +// supplies the query), hence its host is trusted by this tool alone: a +// loopback fallback endpoint stays reachable without widening the shared +// http/web-fetch allowlist. // - web-fetch: size- and content-type-limited readable text extraction. // - knowledge: RAG lookup over the runtime memory retriever; a clear message // when no knowledge base is configured. diff --git a/plugins/tools/io/tools.go b/plugins/tools/io/tools.go index 66b8bb7..85878ab 100644 --- a/plugins/tools/io/tools.go +++ b/plugins/tools/io/tools.go @@ -48,6 +48,13 @@ type tools struct { maxResults int maxList int log *slog.Logger + + // searchDisabled 让 web-search **根本不进注册面**(切片 WS1-A 的诚实性要求): + // 拿不到可用端点时,不该给模型一把拎起来必报错的锤子。见 WithoutSearch。 + searchDisabled bool + // searchTimeout 是 web-search 单次请求的超时覆盖(0 = 沿用本套工具的 + // httpTimeout,也就是 io 包的既有超时 —— 这里刻意不另造默认值)。 + searchTimeout time.Duration } // Option customises the tool set built by New. @@ -81,10 +88,37 @@ func WithAllowedHosts(hosts ...string) Option { // WithSearchEndpoint sets the web-search endpoint (a SearXNG-compatible URL). // WithSearchEndpoint("") clears it so web-search reports a clear error. +// +// 语义与既往逐字不变。**注意**:置空只是「端点为空」,工具仍在注册面里; +// 要「模型根本看不见它」请用 WithoutSearch(组合根在端点显式关闭 / 非法时的用法)。 func WithSearchEndpoint(u string) Option { return func(t *tools) { t.searchURL = u } } +// WithoutSearch removes the web-search tool from the built set entirely: it is +// not registered, so the model neither sees it nor wastes a round on it. This is +// the honest state for "no usable search endpoint" (WS1-A): a registered tool +// that can only ever error is worse than an absent one. +// +// 它只影响 web-search,其余工具照常构建。 +func WithoutSearch() Option { + return func(t *tools) { t.searchDisabled = true } +} + +// WithSearchTimeout overrides the timeout of a single web-search call. Zero or +// negative means "use the set's existing HTTP timeout" — the io package does not +// define a second default for search (project rule §10: one default per knob). +// +// 它只约束 web-search 这一次请求:与 WithHTTPTimeout(整套工具共用的客户端 +// 超时,会同时改到 http / web-fetch)不是同一件事。 +func WithSearchTimeout(d time.Duration) Option { + return func(t *tools) { + if d > 0 { + t.searchTimeout = d + } + } +} + // WithKnowledgeRetriever wires the knowledge tool to a runtime memory retriever. // WithKnowledgeRetriever(nil) makes knowledge report "no knowledge base". func WithKnowledgeRetriever(r KnowledgeRetriever) Option { @@ -150,6 +184,9 @@ func WithLogger(l *slog.Logger) Option { // New builds the full configured tool set over cfg and opts. It returns an error // only when cfg is nil. A nil retriever or empty search endpoint is allowed: the // corresponding tool reports a clear runtime error rather than failing startup. +// +// 组合根在「拿不到可用搜索端点」时应传 WithoutSearch,让 web-search **不进注册面** +// (模型看不见它),而不是塞一个空端点进去等着每次调用报错(切片 WS1-A)。 func New(cfg *config.Config, opts ...Option) ([]sdk.Tool, error) { if cfg == nil { return nil, fmt.Errorf("io: new: nil config") @@ -203,33 +240,64 @@ func Register(reg sdk.Registrar) error { // build assembles the tools sharing this set's client, limits and logger. func (t *tools) build() []sdk.Tool { file := newFileTool(t) - return []sdk.Tool{ + set := []sdk.Tool{ file, newCropTool(t, file), newHTTPTool(t), - newWebSearchTool(t), + } + // web-search 只有在**确实有可用端点**时才进注册面:端点显式关闭或非法时由 + // 组合根传 WithoutSearch,模型便根本看不见它。顺序与既往一致(http 之后、 + // web-fetch 之前),启用态下工具表的形状逐字不变。 + if !t.searchDisabled { + set = append(set, newWebSearchTool(t)) + } + return append(set, newWebFetchTool(t), newKnowledgeTool(t), newDocumentReadTool(t), - } + ) } // newHTTPClient builds a client with a unified timeout and an SSRF-guarded // redirect policy that refuses to follow into private/loopback targets. func newHTTPClient(t *tools) *http.Client { return &http.Client{ - Timeout: t.httpTimeout, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if len(via) >= maxRedirects { - return fmt.Errorf("io: too many redirects") - } - if req.URL == nil { - return ErrSSRF - } - if _, err := validateRequestURL(req.Context(), req.URL.String(), t.allowedHosts); err != nil { - return err - } - return nil - }, + Timeout: t.httpTimeout, + CheckRedirect: redirectGuard(t.allowedHosts), } } + +// redirectGuard 造 SSRF 重定向守卫:最多跳 maxRedirects 次,且每次跳转的目标都要 +// 过同一份判定(跳去内网、回环一样要拦)。判定所用的白名单是参数给的这一份, +// 因此调用方能让它按**自己**加宽后的名单判定,而不牵动共享白名单。 +func redirectGuard(allowedHosts []string) func(*http.Request, []*http.Request) error { + return func(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("io: too many redirects") + } + if req.URL == nil { + return ErrSSRF + } + if _, err := validateRequestURL(req.Context(), req.URL.String(), allowedHosts); err != nil { + return err + } + return nil + } +} + +// clientFor 返回「白名单为 allowedHosts」时某个工具该用的客户端。 +// +// allowedHosts 与共享白名单**长度相同**即代表没有加宽(withSearchEndpointHost 只会 +// 追加、绝不改写已有条目),此时直接复用共享客户端;确有加宽时返回一份**副本**, +// 把重定向守卫换成按加宽名单判定的那一份 —— 副本共享同一个 Transport,连接池不变。 +// +// 这条分工的意义:让 web-search 认自己配置里的端点主机(含本机兜底的回环地址), +// 同时**不**污染 http / web-fetch 共用的那份白名单与那个客户端。 +func (t *tools) clientFor(allowedHosts []string) *http.Client { + if len(allowedHosts) == len(t.allowedHosts) { + return t.client + } + client := *t.client + client.CheckRedirect = redirectGuard(allowedHosts) + return &client +} diff --git a/plugins/tools/io/web_search.go b/plugins/tools/io/web_search.go index 905f0af..815401c 100644 --- a/plugins/tools/io/web_search.go +++ b/plugins/tools/io/web_search.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "strings" + "time" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) @@ -24,16 +25,29 @@ type webSearchTool struct { maxBytes int64 maxResults int log *slog.Logger + // timeout 是本次搜索的超时覆盖(0 = 沿用共享客户端自己的超时)。它只约束 + // web-search 一次请求,不牵动 http / web-fetch 共用的那个客户端(WS1-A)。 + timeout time.Duration } func newWebSearchTool(t *tools) *webSearchTool { + // 端点来自**运维配置**,不是模型输入(模型的 query 只会进查询串,主机名恒为 + // 配置里那一个):因此把它并入本工具**自己**的 SSRF 白名单。否则本机兜底端点 + // http://127.0.0.1:38471 会被 SSRF 守卫按回环地址拒掉——那正是「端点配了却 + // 仍然用不了」的另一种形态(SearXNG 还会 308 跳到 /search,重定向那一跳同样 + // 要过守卫,所以加宽后的名单也必须交给客户端,见 tools.clientFor)。 + // + // 刻意只并入 web-search:http / web-fetch 的 URL 由模型给出,它们共用 + // t.allowedHosts,把回环塞进**那份**名单会让模型能伸手去摸本机服务。 + allowedHosts := withSearchEndpointHost(t.allowedHosts, t.searchURL) return &webSearchTool{ - client: t.client, + client: t.clientFor(allowedHosts), endpoint: t.searchURL, - allowedHosts: t.allowedHosts, + allowedHosts: allowedHosts, maxBytes: t.maxHTTPBytes, maxResults: t.maxResults, log: t.log, + timeout: t.searchTimeout, } } @@ -70,6 +84,12 @@ func (s *webSearchTool) Execute(ctx context.Context, args any) (any, error) { if strings.TrimSpace(s.endpoint) == "" { return nil, fmt.Errorf("io: web-search: no search endpoint configured (set a SearXNG or custom URL)") } + // 每次调用各自的超时预算:ctx 在此派生,取消随本次调用一起结束。 + if s.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, s.timeout) + defer cancel() + } target, err := buildSearchURL(s.endpoint, query) if err != nil { return nil, err @@ -135,6 +155,33 @@ func buildSearchURL(endpoint, query string) (string, error) { return endpoint + sep + "q=" + q + "&format=json", nil } +// withSearchEndpointHost 把**配置里给定的**搜索端点主机并入 web-search 自己的 +// SSRF 白名单(返回新切片,不改动入参切片——组合根可能把它交给别的工具)。 +// 端点解析不出主机、或主机已在名单里时原样返回。 +// +// 理由见 newWebSearchTool:本机兜底端点必然是回环地址(127.0.0.1:38471),而 +// SSRF 守卫默认拒绝回环;端点是运维配置而非模型输入,因此对它放行**不会**给模型 +// 打开新的目标面——工具能去的主机恒等于配置里的那一个。 +func withSearchEndpointHost(allowed []string, endpoint string) []string { + host := searchEndpointHost(endpoint) + if host == "" || isHostAllowed(host, allowed) { + return allowed + } + out := make([]string, 0, len(allowed)+1) + out = append(out, allowed...) + return append(out, host) +} + +// searchEndpointHost 取出搜索端点的主机名(去端口、去 IPv6 方括号);解析不出来 +// 或没有主机时返回空串。 +func searchEndpointHost(endpoint string) string { + u, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil { + return "" + } + return hostOnly(u.Host) +} + // searchResult is one entry of a SearXNG-compatible results payload. type searchResult struct { Title string `json:"title"` diff --git a/plugins/tools/io/web_search_wiring_test.go b/plugins/tools/io/web_search_wiring_test.go new file mode 100644 index 0000000..73d955a --- /dev/null +++ b/plugins/tools/io/web_search_wiring_test.go @@ -0,0 +1,340 @@ +package io + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件是切片 WS1-A 的 io 侧契约:**拿不到可用搜索端点时,web-search 不进注册面**, +// 以及「配置里给定的端点确实被用上」——后者包含本机兜底端点(回环地址)必须真能 +// 走通 SSRF 守卫,否则「端点配了却仍然用不了」换个形态又回来了。 + +// hasTool 报告工具表里有没有某个名字的工具。 +func hasTool(set []sdk.Tool, name string) bool { + for _, tl := range set { + if tl.Name() == name { + return true + } + } + return false +} + +// searxngFallbackURL 是本机兜底 SearXNG 的地址(与 config 的 WEB_SEARCH_ENDPOINT +// 默认值同源;那个默认值由 cmd/cyrene 的接线测试钉住)。它只用于真机 e2e。 +const searxngFallbackURL = "http://127.0.0.1:38471" + +// TestNew_WithoutSearchOmitsTheTool 钉住「关闭态 = 模型看不见这把锤子」: +// WithoutSearch 之后注册面里没有 web-search,其余六个工具一个不少、顺序不变。 +func TestNew_WithoutSearchOmitsTheTool(t *testing.T) { + t.Parallel() + set := buildTools(t, &config.Config{}, WithoutSearch()) + if hasTool(set, "web-search") { + t.Fatal("WithoutSearch 之后注册面里仍有 web-search —— 模型会看见一把必报错的锤子") + } + for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} { + if !hasTool(set, name) { + t.Errorf("WithoutSearch 不得影响其它工具,但 %q 不见了", name) + } + } + if len(set) != 6 { + t.Fatalf("工具数 = %d, want 6", len(set)) + } + + // 对照:不给该选项时仍然注册(既有调用方行为不变)。 + if plain := buildTools(t, &config.Config{}); !hasTool(plain, "web-search") { + t.Fatal("未传 WithoutSearch 时 web-search 必须照旧注册(后向兼容)") + } +} + +// TestNew_WithoutSearchWinsOverEndpoint 钉住两个选项同时出现时的裁决: +// 关闭优先 —— 否则「端点为空 + 关闭」的组合会退化成既注册又报错。 +func TestNew_WithoutSearchWinsOverEndpoint(t *testing.T) { + t.Parallel() + set := buildTools(t, &config.Config{}, WithSearchEndpoint("http://127.0.0.1:38471"), WithoutSearch()) + if hasTool(set, "web-search") { + t.Fatal("WithoutSearch 必须压过 WithSearchEndpoint:注册面里不得出现 web-search") + } +} + +// TestWebSearch_ConfiguredEndpointIsUsed 是「配置值真被透传」的 io 侧断言: +// 断言的不是「没报错」,而是**请求真的打到了配置里那个地址**(含 q / format=json)。 +func TestWebSearch_ConfiguredEndpointIsUsed(t *testing.T) { + t.Parallel() + const query = "昔涟" + got := make(chan *http.Request, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got <- r + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`)) + })) + defer srv.Close() + + ws := webSearchToolForTest(t, + WithSearchEndpoint(srv.URL+"/search"), + WithHTTPClient(srv.Client()), + ) + if ws.endpoint != srv.URL+"/search" { + t.Fatalf("工具持有的端点 = %q, want %q(配置值没被透传)", ws.endpoint, srv.URL+"/search") + } + res, err := ws.Execute(context.Background(), map[string]any{"query": query}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + select { + case r := <-got: + if r.URL.Path != "/search" { + t.Errorf("请求路径 = %q, want /search", r.URL.Path) + } + if r.URL.Query().Get("q") != query { + t.Errorf("q = %q, want %q", r.URL.Query().Get("q"), query) + } + if r.URL.Query().Get("format") != "json" { + t.Errorf("format = %q, want json", r.URL.Query().Get("format")) + } + case <-time.After(2 * time.Second): + t.Fatal("配置里的端点一个请求都没收到 —— 端点没被真正使用") + } + if m := res.(map[string]any); m["count"].(int) != 1 { + t.Fatalf("count = %v, want 1", m["count"]) + } +} + +// TestWebSearch_LoopbackEndpointNeedsNoAllowlist 钉住本机兜底端点(回环地址)必须 +// **开箱可用**:不额外传 WithAllowedHosts 也能查询成功。少了这条,默认端点 +// http://127.0.0.1:38471 会被 SSRF 守卫拒掉,等于换个姿势又给了把用不了的锤子。 +func TestWebSearch_LoopbackEndpointNeedsNoAllowlist(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`)) + })) + defer srv.Close() + + ws := webSearchToolForTest(t, + WithSearchEndpoint(srv.URL), + WithHTTPClient(srv.Client()), + ) + res, err := ws.Execute(context.Background(), map[string]any{"query": "cyrene"}) + if err != nil { + t.Fatalf("回环端点必须开箱可用,却报错: %v", err) + } + if m := res.(map[string]any); m["count"].(int) != 1 { + t.Fatalf("count = %v, want 1", m["count"]) + } + if !isHostAllowed(serverHost(srv), ws.allowedHosts) { + t.Fatalf("端点主机不在本工具的 SSRF 白名单里: %v", ws.allowedHosts) + } +} + +// TestWebSearch_DoesNotWidenSharedAllowlist 钉住上一条的**边界**:给 web-search 放行 +// 它自己的端点主机,绝不许顺带把回环地址塞进 http / web-fetch 共用的那份白名单 +// —— 那会让模型伸手去摸本机服务(含后端自己的 :10942)。 +func TestWebSearch_DoesNotWidenSharedAllowlist(t *testing.T) { + t.Parallel() + set := buildTools(t, &config.Config{}, WithSearchEndpoint(searxngFallbackURL)) + ht := findTool(t, set, "http").(*httpTool) + if isHostAllowed("127.0.0.1", ht.allowedHosts) { + t.Fatalf("http 工具的 SSRF 白名单被搜索端点污染了: %v", ht.allowedHosts) + } + _, err := ht.Execute(context.Background(), map[string]any{"url": "http://127.0.0.1:10942/api/v1/health"}) + if !errors.Is(err, ErrSSRF) { + t.Fatalf("http 工具仍必须拒绝回环地址,err = %v", err) + } +} + +// TestWebSearch_Redirects 用本机兜底 SearXNG 的真实形状(308 → /search)钉住重定向: +// +// - 跳到**端点自己**的相对路径必须被跟随(SearXNG 收到 "/?q=…" 就是 308 到 +// "/search?q=…";少了这条,默认端点仍是「配了却用不了」); +// - 跳到**别的**受保护地址(私网/回环)必须仍被拦下 —— 放行的是「配置里那一个 +// 端点主机」,不是「所有重定向」。 +func TestWebSearch_Redirects(t *testing.T) { + t.Parallel() + t.Run("跟随端点自身的相对跳转", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/search" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`)) + return + } + w.Header().Set("Location", "/search?"+r.URL.RawQuery) + w.WriteHeader(http.StatusPermanentRedirect) + })) + defer srv.Close() + + ws := webSearchToolForTest(t, WithSearchEndpoint(srv.URL)) + res, err := ws.Execute(context.Background(), map[string]any{"query": "cyrene"}) + if err != nil { + t.Fatalf("相对重定向必须被跟随: %v", err) + } + if m := res.(map[string]any); m["count"].(int) != 1 { + t.Fatalf("count = %v, want 1", m["count"]) + } + }) + + t.Run("拒绝跳到私网地址", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "http://10.255.255.1/search") + w.WriteHeader(http.StatusPermanentRedirect) + })) + defer srv.Close() + + ws := webSearchToolForTest(t, WithSearchEndpoint(srv.URL)) + if _, err := ws.Execute(context.Background(), map[string]any{"query": "cyrene"}); !errors.Is(err, ErrSSRF) { + t.Fatalf("跳到私网地址必须被 SSRF 守卫拦下,err = %v", err) + } + }) +} + +// TestWebSearch_TimeoutIsPerCallAndLocal 钉住超时旋钮是**每次搜索自己**的预算: +// 它生效(慢端点被掐断),且**不改**任何客户端自身的超时(http / web-fetch 共用 +// 的那个客户端因此不受影响)。 +func TestWebSearch_TimeoutIsPerCallAndLocal(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) + + set := buildTools(t, &config.Config{}, + WithSearchEndpoint(srv.URL), + WithSearchTimeout(50*time.Millisecond), + ) + ws := findTool(t, set, "web-search").(*webSearchTool) + if ws.timeout != 50*time.Millisecond { + t.Fatalf("web-search 超时 = %v, want 50ms", ws.timeout) + } + if _, err := ws.Execute(context.Background(), map[string]any{"query": "cyrene"}); err == nil { + t.Fatal("50ms 预算下慢端点必须被掐断") + } + // 两个客户端自身的超时都还是 io 包的既有默认值:搜索超时是「按次预算」,不是 + // 客户端超时——否则改 WEB_SEARCH_TIMEOUT 会顺手改掉 http/web-fetch 的行为。 + ht := findTool(t, set, "http").(*httpTool) + if ht.client.Timeout != defaultHTTPTimeout { + t.Fatalf("http 客户端超时 = %v, want %v(WEB_SEARCH_TIMEOUT 不得改到别的工具)", + ht.client.Timeout, defaultHTTPTimeout) + } + if ws.client.Timeout != defaultHTTPTimeout { + t.Fatalf("web-search 客户端超时 = %v, want %v(超时应是每次调用的预算)", + ws.client.Timeout, defaultHTTPTimeout) + } +} + +// TestSearchEndpointHost 覆盖端点主机抽取的边角:空、非法、带端口、IPv6、已命中白名单。 +func TestSearchEndpointHost(t *testing.T) { + t.Parallel() + cases := []struct { + name string + endpoint string + want string + }{ + {"带端口", "http://127.0.0.1:38471", "127.0.0.1"}, + {"带路径", "https://searx.example.com/search?format=json", "searx.example.com"}, + {"IPv6", "http://[::1]:8080", "::1"}, + {"空串", "", ""}, + {"无主机", "http:///search", ""}, + {"非法", "http://%zz", ""}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := searchEndpointHost(tc.endpoint); got != tc.want { + t.Fatalf("searchEndpointHost(%q) = %q, want %q", tc.endpoint, got, tc.want) + } + }) + } + + // 已命中白名单时不重复添加;空主机时原样返回(不改动调用方的切片)。 + base := []string{"example.com"} + if out := withSearchEndpointHost(base, "https://example.com"); len(out) != 1 { + t.Fatalf("已在白名单里的主机不得重复添加: %v", out) + } + if out := withSearchEndpointHost(base, "not a url"); len(out) != 1 || out[0] != "example.com" { + t.Fatalf("解析不出主机时必须原样返回: %v", out) + } + out := withSearchEndpointHost(base, "http://127.0.0.1:38471") + if len(out) != 2 || out[0] != "example.com" || out[1] != "127.0.0.1" { + t.Fatalf("应返回追加主机后的新切片: %v", out) + } + if len(base) != 1 { + t.Fatalf("不得就地修改入参切片: %v", base) + } +} + +// searxngResultCount 用**裸 HTTP**(与工具实现无关)问兜底服务同一个查询,返回它此刻 +// 真能产出的结果条数;服务不可达/应答不可解析时返回 -1。 +// +// 它是真机 e2e 的**前置探针**:上层引擎被限流或 CAPTCHA 时兜底服务会「200 但 0 条」, +// 那时 0 条结果不是本仓的缺陷(2026-10-05 实测:sogou 被 Suspended: CAPTCHA、bing +// 静默返回空)—— 探针让这种外部降级表现为 skip 而不是误报红,同时保住断言强度: +// 探针报 >0 而工具报 0,就是本仓的错。 +func searxngResultCount(t *testing.T, endpoint, query string) int { + t.Helper() + 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 []searchResult `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return -1 + } + return len(payload.Results) +} + +// TestWebSearch_RealSearXNG_EndToEnd 是**真机**端到端:直接调工具实现打本机兜底 +// SearXNG(scripts/searxng/settings.yml 的配套实例)。它只读外部服务,不碰后端进程、 +// 不碰仓库根产物。兜底服务没起、或它的上游引擎被限流到产不出结果时 **skip** +// (外部降级不该判成代码红;探针与工具的查询逐字相同,探针有结果而工具没有 = 真红)。 +func TestWebSearch_RealSearXNG_EndToEnd(t *testing.T) { + t.Parallel() + const query = "昔涟 崩坏星穹铁道" + upstream := searxngResultCount(t, searxngFallbackURL, query) + if upstream < 0 { + t.Skipf("本机兜底 SearXNG 不可达(%s),跳过真机 e2e", searxngFallbackURL) + } + if upstream == 0 { + t.Skipf("兜底 SearXNG 当前产不出结果(上游引擎被限流/CAPTCHA),跳过真机 e2e") + } + + ws := webSearchToolForTest(t, WithSearchEndpoint(searxngFallbackURL)) + res, err := ws.Execute(context.Background(), map[string]any{"query": query}) + if err != nil { + t.Fatalf("真机查询失败: %v", err) + } + m := res.(map[string]any) + results := m["results"].([]searchResult) + if len(results) == 0 { + t.Fatalf("裸 HTTP 探针拿到 %d 条、工具却拿到 0 条 —— 这不再是外部降级: %v", upstream, m) + } + t.Logf("真机 e2e:端点 %s 返回 %d 条结果,首条标题=%q url=%q", + searxngFallbackURL, len(results), results[0].Title, results[0].URL) + if strings.TrimSpace(results[0].URL) == "" { + t.Fatalf("首条结果没有 url: %+v", results[0]) + } +}