主人报的故障:她调 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 条全红
209 lines
6.9 KiB
Go
209 lines
6.9 KiB
Go
package io
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
)
|
||
|
||
// Compile-time assertion that webSearchTool implements sdk.Tool.
|
||
var _ sdk.Tool = (*webSearchTool)(nil)
|
||
|
||
// webSearchTool queries a configured SearXNG-compatible (or custom) search
|
||
// endpoint and returns ranked results, capped in size and count.
|
||
type webSearchTool struct {
|
||
client *http.Client
|
||
endpoint string
|
||
allowedHosts []string
|
||
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.clientFor(allowedHosts),
|
||
endpoint: t.searchURL,
|
||
allowedHosts: allowedHosts,
|
||
maxBytes: t.maxHTTPBytes,
|
||
maxResults: t.maxResults,
|
||
log: t.log,
|
||
timeout: t.searchTimeout,
|
||
}
|
||
}
|
||
|
||
// Name implements sdk.Tool.
|
||
func (s *webSearchTool) Name() string { return "web-search" }
|
||
|
||
// Description implements sdk.Tool.
|
||
func (s *webSearchTool) Description() string {
|
||
return "Search the web via a configured SearXNG or custom search endpoint and return ranked results."
|
||
}
|
||
|
||
// Parameters implements sdk.Tool.
|
||
func (s *webSearchTool) Parameters() (map[string]any, error) {
|
||
return map[string]any{
|
||
"type": "object",
|
||
"properties": map[string]any{
|
||
"query": map[string]any{"type": "string", "description": "Search query."},
|
||
"count": map[string]any{"type": "integer", "description": "Max results to return."},
|
||
},
|
||
"required": []any{"query"},
|
||
}, nil
|
||
}
|
||
|
||
// Execute implements sdk.Tool.
|
||
func (s *webSearchTool) Execute(ctx context.Context, args any) (any, error) {
|
||
m, ok := args.(map[string]any)
|
||
if !ok {
|
||
return nil, fmt.Errorf("io: web-search: args must be an object")
|
||
}
|
||
query := argString(m, "query")
|
||
if strings.TrimSpace(query) == "" {
|
||
return nil, fmt.Errorf("io: web-search: query is required")
|
||
}
|
||
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
|
||
}
|
||
if _, err := validateRequestURL(ctx, target, s.allowedHosts); err != nil {
|
||
return nil, err
|
||
}
|
||
if s.client == nil {
|
||
return nil, fmt.Errorf("io: web-search: no underlying client")
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("io: web-search: build request: %w", err)
|
||
}
|
||
resp, err := s.client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("io: web-search: do: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("io: web-search: endpoint returned status %d", resp.StatusCode)
|
||
}
|
||
data, truncated, err := readLimited(resp.Body, s.maxBytes)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("io: web-search: read: %w", err)
|
||
}
|
||
if truncated {
|
||
return nil, fmt.Errorf("io: web-search: response exceeds the %d-byte limit", s.maxBytes)
|
||
}
|
||
results, err := parseSearchResults(data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
n := s.maxResults
|
||
if v, ok := argInt(m, "count"); ok && v > 0 {
|
||
n = v
|
||
}
|
||
if len(results) > n {
|
||
results = results[:n]
|
||
}
|
||
return map[string]any{
|
||
"query": query,
|
||
"count": len(results),
|
||
"results": results,
|
||
"truncated": truncated,
|
||
}, nil
|
||
}
|
||
|
||
// buildSearchURL expands an endpoint against a query. It supports a "{query}"
|
||
// placeholder, otherwise appends q= and format=json for a SearXNG JSON API.
|
||
func buildSearchURL(endpoint, query string) (string, error) {
|
||
if strings.TrimSpace(endpoint) == "" {
|
||
return "", fmt.Errorf("io: web-search: no search endpoint configured")
|
||
}
|
||
q := url.QueryEscape(query)
|
||
if strings.Contains(endpoint, "{query}") {
|
||
return strings.ReplaceAll(endpoint, "{query}", q), nil
|
||
}
|
||
sep := "?"
|
||
if strings.Contains(endpoint, "?") {
|
||
sep = "&"
|
||
}
|
||
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"`
|
||
URL string `json:"url"`
|
||
Content string `json:"content"`
|
||
Snippet string `json:"snippet"`
|
||
}
|
||
|
||
// parseSearchResults decodes a {"results":[...]} payload, falling back to the
|
||
// snippet field when content is absent.
|
||
func parseSearchResults(data []byte) ([]searchResult, error) {
|
||
var payload struct {
|
||
Results []searchResult `json:"results"`
|
||
}
|
||
if err := json.Unmarshal(data, &payload); err != nil {
|
||
return nil, fmt.Errorf("io: web-search: decode results: %w", err)
|
||
}
|
||
for i := range payload.Results {
|
||
if payload.Results[i].Content == "" {
|
||
payload.Results[i].Content = payload.Results[i].Snippet
|
||
}
|
||
}
|
||
return payload.Results, nil
|
||
}
|