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

+84 -16
View File
@@ -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
}