feat(search): web-search 接线本机 SearXNG 兜底 + 显式开关(她不再拿一把用不了的锤子)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人报的故障:她调 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 条全红
This commit is contained in:
AskaEth committed 2026-10-05 20:29:21 +08:00
1 parent 8e56163418
commit 8e6c77ad10
11 files changed
+1137 -20

No files matched your search

+1
View File
@@ -54,6 +54,7 @@ type Config struct {
Context ContextConfig
Guardrail GuardrailConfig
MemoryTidy MemoryTidyConfig
WebSearch WebSearchConfig
}
// ServerConfig holds the single gateway listener settings.
+72
View File
@@ -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}
}
+129
View File
@@ -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)
}
})
}
}