Files
Cyrene_v2/plugins/tools/io/web_search.go
T
AskaEth 8e6c77ad10
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 条全红
2026-10-05 20:29:21 +08:00

209 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}