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 条全红
This commit is contained in:
1 parent
8e56163418
commit
8e6c77ad10
11 files changed
+1137
-20
No files matched your search
@@ -54,6 +54,7 @@ type Config struct {
|
||||
Context ContextConfig
|
||||
Guardrail GuardrailConfig
|
||||
MemoryTidy MemoryTidyConfig
|
||||
WebSearch WebSearchConfig
|
||||
}
|
||||
|
||||
// ServerConfig holds the single gateway listener settings.
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user