主人报的故障:她调 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 条全红
73 lines
3.7 KiB
Go
73 lines
3.7 KiB
Go
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}
|
||
}
|