Files
Cyrene_v2/plugins/tools/io/tools.go
T
AskaEth c2e413ec38
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(io): web-fetch 直连→浏览器渲染降级档 + fetchedBy 如实标注(Z5 机制)
- 降级链:直连失败 / 判定为动态页 ⇒ 浏览器渲染取正文;返回体带 fetchedBy=direct|browser,
  两条档的失败各自留痕(directError/browserError/browserSource/dynamicSuspect),**绝不静默替换**
- 被 SSRF 拒绝的目标**不换路**(红线);渲染器缺位时如实报双失败
- 3 个变异自检(去掉动态页判据 / 去掉 SSRF 短路 / 掩盖双失败)
⚠️ **生产接不通**:internal/capability/browser 没有「取正文」入口(不在本片文件面)⇒
   开关默认关、不假装可用;解封形状已在报告里给出(加 RenderText ≈40 行)
2026-10-07 09:34:47 +08:00

410 lines
15 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 (
"fmt"
"log/slog"
"net/http"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// source tags every log record emitted by this package (project rule §10).
const source = "plugins/tools/io"
// Default size, timeout and result limits. Named constants only — no bare
// magic numbers (project rule §10).
const (
defaultMaxReadBytes = 5 << 20 // 5 MiB file read cap
defaultMaxWriteBytes = 5 << 20 // 5 MiB file write cap
defaultMaxHTTPBytes = 1 << 20 // 1 MiB http response cap
defaultMaxFetchBytes = 2 << 20 // 2 MiB web-fetch cap
defaultHTTPTimeout = 15 * time.Second
defaultMaxResults = 10
defaultMaxListEntries = 200
maxRedirects = 3
filePerm = 0o644
dirPerm = 0o755
)
// tools is the constructed, shared state for the whole tool set. A single HTTP
// client is shared by http/web-search/web-fetch so the connection pool is
// unified (project rule §10); it is never a package-level global.
type tools struct {
cfg *config.Config
client *http.Client
allowedHosts []string
searchURL string
retriever KnowledgeRetriever
fileWhitelist []string
maxReadBytes int64
maxWriteBytes int64
maxHTTPBytes int64
maxFetchBytes int64
httpTimeout time.Duration
maxResults int
maxList int
log *slog.Logger
// searchDisabled 让 web-search **根本不进注册面**(切片 WS1-A 的诚实性要求):
// 拿不到可用端点时,不该给模型一把拎起来必报错的锤子。见 WithoutSearch。
searchDisabled bool
// searchTimeout 是 web-search 单次请求的超时覆盖(0 = 沿用本套工具的
// httpTimeout,也就是 io 包的既有超时 —— 这里刻意不另造默认值)。
searchTimeout time.Duration
// searchProviders 是组合根给的各家接线(WS1-B:provider 由配置选定);
// searchPrimary / searchFallback 是主 provider 与降级链。
searchProviders []SearchProviderSpec
searchPrimary string
searchFallback []string
// searchSelected 报告组合根是否**显式**选过 provider:只有显式选择才做启动期
// fail-fast 校验(io 包的默认构造路径保留 WS1-A 的历史行为:工具在、调用时报错)。
searchSelected bool
// searchMaxResults 是 web-search 自己的条数上限(0 = 沿用 maxResults)。
// 与 WithMaxResults 分开,是因为后者会一起改到 knowledge 工具的条数。
searchMaxResults int
// bodyRenderer 是「浏览器渲染取正文」这一档(design/28 §1 M5 / §8 B5)。
// nil = **这一档关着**(默认),见 WithBrowserFallback。
bodyRenderer BodyRenderer
}
// SearchProviderSpec 是组合根给 web-search 的**一家** provider 的接线(一家一行)。
//
// Name 必须是 plugins/tools/io/search 的接入位(search.Known);Configured 由组合根
// 用 search.Configured 判定后显式给出 —— 未配置的家不进注册表、不出现在可用清单里
// (design/27 §1.2 的 fail-closed)。
type SearchProviderSpec struct {
Name string
APIKey string
BaseURL string
Configured bool
}
// Option customises the tool set built by New.
type Option func(*tools)
// WithHTTPTimeout sets the per-request timeout. Non-positive values are ignored.
func WithHTTPTimeout(d time.Duration) Option {
return func(t *tools) {
if d > 0 {
t.httpTimeout = d
}
}
}
// WithHTTPClient injects the client shared by the network tools. When nil (the
// default) New builds one with a unified timeout and redirect SSRF guard.
func WithHTTPClient(c *http.Client) Option {
return func(t *tools) {
if c != nil {
t.client = c
}
}
}
// WithAllowedHosts adds hosts to the SSRF allowlist. Hosts here bypass the
// loopback/private deny rules (they are the "host whitelist" side of the SSRF
// policy), letting the agent reach an internal search endpoint or the runtime.
func WithAllowedHosts(hosts ...string) Option {
return func(t *tools) { t.allowedHosts = append(t.allowedHosts, hosts...) }
}
// WithSearchEndpoint sets the **searxng provider's** base endpoint (a SearXNG-compatible
// URL). WithSearchEndpoint("") clears it so web-search reports a clear error.
//
// 语义与既往逐字不变(design/27 §4:现有 WithSearchEndpoint 保留为 searxng provider 的
// 配置)。组合根走配置路径时用 WithSearchProviders 给出 searxng 的端点与可用性(单一
// 事实来源);本选项保留给 io 包的既有调用方与测试。
//
// **注意**:置空只是「端点为空」,工具仍在注册面里;
// 要「模型根本看不见它」请用 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)不是同一件事。WS1-B 起它按**每一次上游尝试**
// 生效(降级链里每家都各有自己的预算)。
func WithSearchTimeout(d time.Duration) Option {
return func(t *tools) {
if d > 0 {
t.searchTimeout = d
}
}
}
// WithSearchProviders 声明 web-search 可用的各家接线(WS1-B,design/27 §3)。
// 未传时退回 WS1-A 的既有路径:只有 searxng 一家(端点来自 WithSearchEndpoint)。
//
// 传进来的顺序即「可用清单」的展示顺序;Configured=false 的家不进注册表。
func WithSearchProviders(specs ...SearchProviderSpec) Option {
return func(t *tools) { t.searchProviders = append(t.searchProviders, specs...) }
}
// WithSearchSelection 选定主 provider 与降级链(primary + fallback,按序)。
// 它同时打开**启动期 fail-fast 校验**:主 provider 未登记(如 tavily)或未配置(缺 key)
// 时 New 返回错误,绝不静默回落到兜底(design/27 §3)。
func WithSearchSelection(primary string, fallback []string) Option {
return func(t *tools) {
t.searchPrimary = primary
t.searchFallback = append([]string(nil), fallback...)
t.searchSelected = true
}
}
// WithSearchMaxResults overrides web-search's own result cap (WEB_SEARCH_MAX_RESULTS).
// Non-positive values are ignored (the set's shared cap applies).
func WithSearchMaxResults(n int) Option {
return func(t *tools) {
if n > 0 {
t.searchMaxResults = n
}
}
}
// WithBrowserFallback 打开 design/28 §1 M5 / §8 B5 的降级档:web-fetch **直连取正文**
// 失败、或直连拿到的页面被判成动态页时,改由**浏览器渲染**取正文,并在返回体里如实
// 标注实际走了哪条(`fetchedBy` = direct / browser)。
//
// **默认关**:不传这个选项就是关,传 nil 也是关。两条理由 ——
//
// 1. M5 是**降级档**不是主路径:浏览器渲染要起会话(必要时起进程),比直连慢一个量级;
// 绝大多数站点直连足够。它是「直连不行时的补一手」,不该挂在默认路径上。
// 2. 浏览器是否可用取决于宿主(本机有没有 Chrome/Chromium、有没有配 CDP 端点),
// 拿不到就不该把一条注定走不通的档挂上去 —— 与 web-search「拿不到后端就不注册」
// 同一个诚实口径。
//
// 传入的 renderer 必须自己满足 BodyRenderer 的红线(只出站、复用同一份 SSRF 判定源、
// 渲染完成后才取正文)。这一档无论开关,**都不会**把被 SSRF 拒绝的目标换条路再去访问。
func WithBrowserFallback(r BodyRenderer) Option {
return func(t *tools) { t.bodyRenderer = r }
}
// WithKnowledgeRetriever wires the knowledge tool to a runtime memory retriever.
// WithKnowledgeRetriever(nil) makes knowledge report "no knowledge base".
func WithKnowledgeRetriever(r KnowledgeRetriever) Option {
return func(t *tools) { t.retriever = r }
}
// WithFilePathWhitelist adds extra directories the file tool may access, in
// addition to the configured DataDir.
func WithFilePathWhitelist(paths ...string) Option {
return func(t *tools) { t.fileWhitelist = append(t.fileWhitelist, paths...) }
}
// WithReadLimit overrides the file read cap (bytes). Non-positive values are
// ignored.
func WithReadLimit(n int64) Option {
return func(t *tools) {
if n > 0 {
t.maxReadBytes = n
}
}
}
// WithWriteLimit overrides the file write cap (bytes). Non-positive values are
// ignored.
func WithWriteLimit(n int64) Option {
return func(t *tools) {
if n > 0 {
t.maxWriteBytes = n
}
}
}
// WithHTTPResponseLimit overrides the http response cap (bytes). Non-positive
// values are ignored.
func WithHTTPResponseLimit(n int64) Option {
return func(t *tools) {
if n > 0 {
t.maxHTTPBytes = n
}
}
}
// WithMaxResults caps the number of results returned by web-search and
// knowledge. Non-positive values are ignored.
func WithMaxResults(n int) Option {
return func(t *tools) {
if n > 0 {
t.maxResults = n
}
}
}
// WithLogger injects the base logger. When nil the package builds one tagged
// with source "plugins/tools/io".
func WithLogger(l *slog.Logger) Option {
return func(t *tools) {
if l != nil {
t.log = l
}
}
}
// 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")
}
t := &tools{
cfg: cfg,
maxReadBytes: defaultMaxReadBytes,
maxWriteBytes: defaultMaxWriteBytes,
maxHTTPBytes: defaultMaxHTTPBytes,
maxFetchBytes: defaultMaxFetchBytes,
httpTimeout: defaultHTTPTimeout,
maxResults: defaultMaxResults,
maxList: defaultMaxListEntries,
log: logger.WithSource(logger.New(logger.Options{}), source),
}
for _, opt := range opts {
if opt != nil {
opt(t)
}
}
if t.client == nil {
t.client = newHTTPClient(t)
}
return t.build()
}
// Tools returns the tool set built from platform defaults. Production wiring
// should prefer New(cfg, opts...) so the tools honor the real configuration.
func Tools() []sdk.Tool {
set, err := New(&config.Config{DataDir: platform.DefaultDataDir()})
if err != nil {
return nil
}
return set
}
// Register registers the default tool set (from Tools) with reg. It returns
// sdk.ErrNilRegistrar when reg is nil and stops at the first registration error.
func Register(reg sdk.Registrar) error {
if reg == nil {
return sdk.ErrNilRegistrar
}
for _, t := range Tools() {
if err := sdk.RegisterTool(reg, t); err != nil {
return fmt.Errorf("io: register %q: %w", t.Name(), err)
}
}
return nil
}
// build assembles the tools sharing this set's client, limits and logger.
func (t *tools) build() ([]sdk.Tool, error) {
file := newFileTool(t)
set := []sdk.Tool{
file,
newCropTool(t, file),
newHTTPTool(t),
}
// web-search 只有在**确实有可用后端**时才进注册面:端点显式关闭或选定 provider
// 未登记/未配置时由组合根传 WithoutSearch,模型便根本看不见它。顺序与既往一致
// (http 之后、web-fetch 之前),启用态下工具表的形状逐字不变。
if !t.searchDisabled {
ws, err := newWebSearchTool(t)
if err != nil {
// 启动期 fail-fast(design/27 §3):选定的 provider 有问题就不许静默回落。
return nil, err
}
set = append(set, ws)
}
return append(set,
newWebFetchTool(t),
newKnowledgeTool(t),
newDocumentReadTool(t),
), nil
}
// searchWires 汇总要交给搜索注册表的接线(WS1-B):
//
// - 组合根给了接线表(WithSearchProviders)⇒ 用它,是否可用由 Configured 决定;
// - 没给 ⇒ 退回 WS1-A 的既有路径:只有 searxng 一家,且**始终登记**(哪怕端点是
// 空串)—— 那是历史行为:工具在注册面里、调用时如实报「no search endpoint」。
func (t *tools) searchWires() []search.Wire {
if len(t.searchProviders) == 0 {
return []search.Wire{{Name: search.NameSearXNG, BaseURL: t.searchURL, Configured: true}}
}
wires := make([]search.Wire, 0, len(t.searchProviders))
for _, s := range t.searchProviders {
wires = append(wires, search.Wire{
Name: s.Name,
APIKey: s.APIKey,
BaseURL: s.BaseURL,
Configured: s.Configured,
})
}
return wires
}
// 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: 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
}