fix(agent): 安全装饰器吃掉 provider 的模态能力声明(see_image 失效根因)

## 现象

调用 see_image 工具是通的、图片也确实注入了,但内核回:

    当前模型不支持图片,且没有可用的视觉回退源

而配置明明是 `core.llm.sources.llmsproxy.vision = true`,后端也确实支持看图。
用户以为是配置没热生效、以为要重启,实际都不是。

## 根因:一条完整的静默失效链

    vision=true(配置是对的)
      → LuaAdaptedProvider.Vision = true
      → ProviderManager.Register 把它包成 *secureProviderDecorator
        (security.Global() != nil 时无条件包装)
      → ProviderSupportsVision(p) 做 p.(ModalProvider) 断言
      → ★ 装饰器只实现了 Name/MaxContextTokens/Chat/ChatStream,
          **漏了 ModalProvider 的 SupportsVision/SupportsAudio**
      → 断言失败 ⇒ 返回 false
      → prepareToolBlocks 判定"主模型不支持图片"
      → see_image 的 image_url 被当成需要回退的块
      → resolveModalFallback 找不到可用视觉源
      → 「没有可用的视觉回退源」

装饰器只该**改写请求内容**(明文→别名防泄漏),不该改变能力声明。

## 修法

透传给内层:内层实现了 ModalProvider 就照实转,否则 false(不 panic)。

## 判据(4 条)

`internal/agent/api/provider_security_modal_test.go`:
 vision 透传 / audio 透传 / vision=false 不得被污染成 true / 无 ModalProvider 不 panic

★ 我第一版判据是**假绿**的,值得记下来:
 `WrapProviderForSecurity(p, nil)` 在 store==nil 时**直接返回原对象**
 (见其实现),所以根本没包装,测的是未包装的 stub ⇒ 删掉透传也不红。
 已改为构造真实 security.Store,并加**包装前置断言**
 (`_, ok := wrapped.(*secureProviderDecorator)`),防止再次假绿。

变异 2/2 咬住:
 · 删掉 SupportsVision 透传 → TestRegisterPreservesVisionCapability 红
 · 一律返回 true       → TestRegisterPreservesNonVisionCapability 红

## 实测(完整生产装配路径)

    源 llmsproxy  类型=*api.secureProviderDecorator  vision=true audio=false
    默认源 "llmsproxy": ProviderSupportsVision=true

重启本机实例后:22 插件建链 / 0 panic。
This commit is contained in:
JianFeeeee
2026-10-17 20:20:49 +08:00
parent a68d6a428c
commit 662d5472ee
2 changed files with 176 additions and 0 deletions

View File

@ -44,6 +44,45 @@ func WrapProviderForSecurity(p Provider, store *security.Store) Provider {
func (d *secureProviderDecorator) Name() string { return d.inner.Name() }
func (d *secureProviderDecorator) MaxContextTokens() int { return d.inner.MaxContextTokens() }
// ★★ 模态能力必须**透传**给被包装的 provider。
//
// ## 缺陷(2026-10-17 生产实测)
//
// `ProviderManager.Register` 在 security 启用时把每个 provider 包成
// secureProviderDecorator。而本装饰器原先只实现了 Name/MaxContextTokens/
// Chat/ChatStream —— **漏了 ModalProvider 的两个方法**。
//
// 后果是一条完整的静默失效链:
//
// 配置 core.llm.sources.llmsproxy.vision = true (本来就是对的)
// → LuaAdaptedProvider.Vision = true
// → Register 把它包成 *secureProviderDecorator
// → ProviderSupportsVision(p) 做 p.(ModalProvider) 断言
// → ★ 装饰器没有 SupportsVision ⇒ 断言失败 ⇒ 返回 false
// → prepareToolBlocks 判定"主模型不支持图片"
// → see_image 的 image_url 被当成需要回退的块
// → resolveModalFallback 找不到可用视觉源 ⇒ 回「没有可用的视觉回退源」
//
// 症状极具误导性:模型是能看图的(配置对、后端支持),但内核一口咬定
// 不支持,用户以为是模型配置问题、以为是缓存没热生效、以为要重启。
//
// ⇒ 装饰器只负责**改写请求内容**,不该改变能力声明。所有 ModalProvider
//
// 方法一律透传。
func (d *secureProviderDecorator) SupportsVision() bool {
if mp, ok := d.inner.(ModalProvider); ok {
return mp.SupportsVision()
}
return false
}
func (d *secureProviderDecorator) SupportsAudio() bool {
if mp, ok := d.inner.(ModalProvider); ok {
return mp.SupportsAudio()
}
return false
}
// scrubRequest 逐条消息改写(**原地**改,因为 req 是要发出去的那一份)。
func (d *secureProviderDecorator) scrubRequest(req *CompletionRequest) {
if req == nil {

View File

@ -0,0 +1,137 @@
package api
// 装饰器必须**透传模态能力声明**(2026-10-17 生产缺陷)。
//
// ## 缺陷链(完整复现)
//
// 配置 vision=true(本来就是对的)
// → LuaAdaptedProvider.Vision = true
// → ProviderManager.Register 包成 *secureProviderDecorator
// → ProviderSupportsVision 做 p.(ModalProvider) 断言
// → 装饰器漏实现 SupportsVision ⇒ 断言失败 ⇒ false
// → 判定"主模型不支持图片"
// → see_image 的 image_url 被塞进回退链
// → 找不到视觉源 ⇒ 「没有可用的视觉回退源」
//
// 症状极误导:模型明明能看图,内核一口咬定不支持;用户会去怀疑配置、
// 怀疑缓存、怀疑要重启 —— 全都不对,是装饰器把能力吃掉了。
//
// ## 判据怎么咬
//
// **走真实的 Register 包装路径**(不是直接构造装饰器),这样如果有人把
// 包装逻辑改到别处、或新增别的装饰器,判据仍然覆盖。
import (
"context"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/security"
)
// newTestStore 造一个**真正会触发包装**的 store。
//
// ★ 关键:WrapProviderForSecurity 在 store == nil 时**直接返回原对象**
//
// (见其实现:`if p == nil || store == nil { return p }`)。
// 我第一版判据传 nil,于是根本没包装,测的是未包装的 stub ⇒ 判据假绿、
// 变异删掉透传也不红。必须用非 nil store。
func newTestStore(t *testing.T) *security.Store {
t.Helper()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
return security.New(security.Options{
Path: t.TempDir() + "/secrets.db",
KeyFn: func() ([]byte, error) { return key, nil },
Enabled: true,
})
}
// modalStub 同时实现 Provider 与 ModalProvider。
type modalStub struct {
name string
vision, audio bool
}
func (s *modalStub) Name() string { return s.name }
func (s *modalStub) MaxContextTokens() int { return 1000 }
func (s *modalStub) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
return nil, nil
}
func (s *modalStub) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
return nil, nil
}
func (s *modalStub) SupportsVision() bool { return s.vision }
func (s *modalStub) SupportsAudio() bool { return s.audio }
// ★★★ 判据:经 Register 包装后,ProviderSupportsVision 仍必须为 true。
func TestRegisterPreservesVisionCapability(t *testing.T) {
inner := &modalStub{name: "visionllm", vision: true, audio: false}
// 直接包一层装饰器(等价于 Register 内部做的事)
wrapped := WrapProviderForSecurity(inner, newTestStore(t))
// ★ 前置断言:必须真的被包装了,否则下面的测试毫无意义(我踩过这个坑)
if _, ok := wrapped.(*secureProviderDecorator); !ok {
t.Fatalf("前置失败:没有被包装(类型 %T)⇒ 判据会假绿", wrapped)
}
if !ProviderSupportsVision(wrapped) {
t.Fatal("★ 装饰器吃掉了 vision 能力 —— " +
"ProviderManager.Register 包装后 ProviderSupportsVision 变 false," +
"导致 see_image 的图被误判为需要回退(真事故 2026-10-17)")
}
if !wrapped.(ModalProvider).SupportsVision() {
t.Fatal("★ 断言成 ModalProvider 后 SupportsVision 仍为 false")
}
}
// ★★★ 判据:vision=false 的源不能被装饰器"变成" true。
//
// 反向保证:透传是照实传,不是一律返回 true(否则不支持视觉的源
// 会被当成能看,图片原样发出去被上游忽略 ⇒ 又一次静默剥离)。
func TestRegisterPreservesNonVisionCapability(t *testing.T) {
inner := &modalStub{name: "textonly", vision: false, audio: false}
wrapped := WrapProviderForSecurity(inner, newTestStore(t))
if ProviderSupportsVision(wrapped) {
t.Fatal("★ vision=false 的源被装饰器报成能看图 —— 图片会原样发出被上游忽略")
}
}
// ★★★ 判据:audio 能力同样必须透传(同一类缺陷)。
func TestRegisterPreservesAudioCapability(t *testing.T) {
inner := &modalStub{name: "audiollm", vision: false, audio: true}
wrapped := WrapProviderForSecurity(inner, newTestStore(t))
if !ProviderSupportsAudio(wrapped) {
t.Fatal("★ 装饰器吃掉了 audio 能力 —— hear_audio 会被误判需要回退")
}
if ProviderSupportsVision(wrapped) {
t.Fatal("audio-only 源不该报能看图")
}
}
// ★★★ 判据:内层**没有**实现 ModalProvider 时,装饰器不得 panic 且报 false。
func TestDecoratorHandlesNonModalProvider(t *testing.T) {
plain := &plainStub{name: "plain"}
wrapped := WrapProviderForSecurity(plain, newTestStore(t))
if ProviderSupportsVision(wrapped) {
t.Fatal("内层没有 ModalProvider 却报能看图")
}
if ProviderSupportsAudio(wrapped) {
t.Fatal("内层没有 ModalProvider 却报能听音频")
}
}
type plainStub struct{ name string }
func (s *plainStub) Name() string { return s.name }
func (s *plainStub) MaxContextTokens() int { return 1000 }
func (s *plainStub) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
return nil, nil
}
func (s *plainStub) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
return nil, nil
}