根因(从 checkpoints 取到的那一轮 tool_call 实际 ARGS 逐字节核实):
模型交给工具的 image 参数是 "data:image/png;base64," —— 只有前缀、
载荷为空(全长 22 字符)。两个加载器早就会剥 data: 前缀,但把空载荷
解码成零字节后照样挂上合法 MIME 放行:
image-crop → 零字节进 image.Decode ⇒ image: unknown format
vision → DataURI("image/png", nil) 发给上游 ⇒ 400 unsupported image
(模型复述不出入参里那条 58583 字符的 data: URI,只抄下了前缀。)
- 新增 checkInlinePayload:data: URI 解码后做内容嗅探(复用既有裁决)
- 空载荷 / 缺 ;base64 段 / 缺逗号 / 非法 base64 ⇒ 明确拒绝
- 报错含「不是图片」+ 嗅到的类型 + 支持格式 + 十六进制物证 + 可行动提示,
且不回显正文或 base64
- 裸 base64、http(s) URL、工具 schema、安全策略一律未动
- 测试:新增 16 个用例;全树 61 包 ok;4 个变异(含停用嗅探 ⇒ 5 用例红)
349 lines
12 KiB
Go
349 lines
12 KiB
Go
package io
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"image"
|
||
"image/color"
|
||
"image/jpeg"
|
||
"image/png"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
)
|
||
|
||
// gradientPNG writes an image whose pixel (x,y) encodes its own coordinates in the
|
||
// red/green channels. That makes a crop self-verifying: if the crop's top-left
|
||
// pixel is not RGBA{sourceX, sourceY, 0, 255} the wrong region was returned.
|
||
func gradientPNG(t *testing.T, w, h int) []byte {
|
||
t.Helper()
|
||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||
for y := 0; y < h; y++ {
|
||
for x := 0; x < w; x++ {
|
||
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), A: 255})
|
||
}
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := png.Encode(&buf, img); err != nil {
|
||
t.Fatalf("png.Encode: %v", err)
|
||
}
|
||
return buf.Bytes()
|
||
}
|
||
|
||
// decodeCropResult pulls the PNG out of a crop tool result and decodes it.
|
||
func decodeCropResult(t *testing.T, result any) image.Image {
|
||
t.Helper()
|
||
m, ok := result.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("result type = %T, want map[string]any", result)
|
||
}
|
||
uri, _ := m["image"].(string)
|
||
if !strings.HasPrefix(uri, "data:image/png;base64,") {
|
||
t.Fatalf("image field = %q, want a PNG data URI", uri)
|
||
}
|
||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(uri, "data:image/png;base64,"))
|
||
if err != nil {
|
||
t.Fatalf("decode data URI: %v", err)
|
||
}
|
||
img, _, err := image.Decode(bytes.NewReader(raw))
|
||
if err != nil {
|
||
t.Fatalf("decode crop: %v", err)
|
||
}
|
||
return img
|
||
}
|
||
|
||
func newCropToolForTest(t *testing.T, extra ...Option) (*cropTool, string) {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
set := buildTools(t, &config.Config{DataDir: dir}, extra...)
|
||
tool, ok := findTool(t, set, "image-crop").(*cropTool)
|
||
if !ok {
|
||
t.Fatalf("image-crop tool has type %T, want *cropTool", findTool(t, set, "image-crop"))
|
||
}
|
||
return tool, dir
|
||
}
|
||
|
||
// TestCropTool_CropsRequestedRegion pins the region arithmetic: the returned crop
|
||
// must be exactly the requested region with the requested pixels, not merely the
|
||
// right size (裁决 14.4.5③).
|
||
func TestCropTool_CropsRequestedRegion(t *testing.T) {
|
||
t.Parallel()
|
||
tool, dir := newCropToolForTest(t)
|
||
path := filepath.Join(dir, "src.png")
|
||
if err := os.WriteFile(path, gradientPNG(t, 100, 80), filePerm); err != nil {
|
||
t.Fatalf("write source: %v", err)
|
||
}
|
||
|
||
res, err := tool.Execute(context.Background(), map[string]any{
|
||
"image": path, "x": 10, "y": 20, "width": 30, "height": 40,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
img := decodeCropResult(t, res)
|
||
if img.Bounds().Dx() != 30 || img.Bounds().Dy() != 40 {
|
||
t.Fatalf("crop size = %dx%d, want 30x40", img.Bounds().Dx(), img.Bounds().Dy())
|
||
}
|
||
got := color.NRGBAModel.Convert(img.At(0, 0)).(color.NRGBA)
|
||
if got.R != 10 || got.G != 20 {
|
||
t.Fatalf("top-left pixel = %v, want the source pixel (10,20)", got)
|
||
}
|
||
last := color.NRGBAModel.Convert(img.At(29, 39)).(color.NRGBA)
|
||
if last.R != 39 || last.G != 59 {
|
||
t.Fatalf("bottom-right pixel = %v, want the source pixel (39,59)", last)
|
||
}
|
||
}
|
||
|
||
// TestCropTool_RejectsRegionOutsideImage pins fail-loud instead of clamping: a
|
||
// clamp would hand the model a different region than it asked for.
|
||
func TestCropTool_RejectsRegionOutsideImage(t *testing.T) {
|
||
t.Parallel()
|
||
tool, dir := newCropToolForTest(t)
|
||
path := filepath.Join(dir, "src.png")
|
||
if err := os.WriteFile(path, gradientPNG(t, 50, 50), filePerm); err != nil {
|
||
t.Fatalf("write source: %v", err)
|
||
}
|
||
cases := []map[string]any{
|
||
{"image": path, "x": 40, "y": 0, "width": 20, "height": 10},
|
||
{"image": path, "x": 0, "y": 40, "width": 10, "height": 20},
|
||
{"image": path, "x": -1, "y": 0, "width": 10, "height": 10},
|
||
{"image": path, "x": 0, "y": 0, "width": 0, "height": 10},
|
||
{"image": path, "x": 0, "y": 0, "width": 10, "height": -5},
|
||
}
|
||
for _, args := range cases {
|
||
if _, err := tool.Execute(context.Background(), args); err == nil {
|
||
t.Errorf("args %v accepted, want an out-of-bounds error", args)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestCropTool_RejectsPathOutsideSandbox pins the file sandbox reuse: cropping can
|
||
// never reach a file the file tool could not read.
|
||
func TestCropTool_RejectsPathOutsideSandbox(t *testing.T) {
|
||
t.Parallel()
|
||
tool, _ := newCropToolForTest(t)
|
||
outside := filepath.Join(t.TempDir(), "outside.png")
|
||
if err := os.WriteFile(outside, gradientPNG(t, 10, 10), filePerm); err != nil {
|
||
t.Fatalf("write outside image: %v", err)
|
||
}
|
||
if _, err := tool.Execute(context.Background(), map[string]any{
|
||
"image": outside, "x": 0, "y": 0, "width": 2, "height": 2,
|
||
}); err == nil {
|
||
t.Fatal("path outside the data dir accepted, want a sandbox rejection")
|
||
}
|
||
}
|
||
|
||
// TestCropTool_AcceptsDataURIAndURL pins the other two sources: a data: URI and an
|
||
// http(s) URL both decode into a crop.
|
||
func TestCropTool_AcceptsDataURIAndURL(t *testing.T) {
|
||
t.Parallel()
|
||
src := gradientPNG(t, 20, 20)
|
||
uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(src)
|
||
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
w.Header().Set("Content-Type", "image/png")
|
||
_, _ = w.Write(src)
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
|
||
tool, _ := newCropToolForTest(t, WithAllowedHosts(serverHost(srv)))
|
||
cases := []string{uri, srv.URL + "/img.png"}
|
||
for _, source := range cases {
|
||
res, err := tool.Execute(context.Background(), map[string]any{
|
||
"image": source, "x": 5, "y": 5, "width": 4, "height": 3,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Execute(%s): %v", source, err)
|
||
}
|
||
if img := decodeCropResult(t, res); img.Bounds().Dx() != 4 || img.Bounds().Dy() != 3 {
|
||
t.Fatalf("source %s crop size = %dx%d, want 4x3", source, img.Bounds().Dx(), img.Bounds().Dy())
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestCropTool_RejectsUnsupportedDataURI pins a non-base64 data URI as an error
|
||
// rather than a silent empty decode.
|
||
func TestCropTool_RejectsUnsupportedDataURI(t *testing.T) {
|
||
t.Parallel()
|
||
tool, _ := newCropToolForTest(t)
|
||
if _, err := tool.Execute(context.Background(), map[string]any{
|
||
"image": "data:image/png,plaintext", "x": 0, "y": 0, "width": 1, "height": 1,
|
||
}); err == nil {
|
||
t.Fatal("non-base64 data URI accepted, want an error")
|
||
}
|
||
}
|
||
|
||
// gradientJPEG 与 gradientPNG 同构,供「data: URI 带真 JPEG 载荷」的用例使用。
|
||
// JPEG 有损,故用例只钉裁切尺寸,不逐像素比对。
|
||
func gradientJPEG(t *testing.T, w, h int) []byte {
|
||
t.Helper()
|
||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||
for y := 0; y < h; y++ {
|
||
for x := 0; x < w; x++ {
|
||
img.Set(x, y, color.RGBA{R: uint8(x * 2), G: uint8(y * 2), A: 255})
|
||
}
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||
t.Fatalf("jpeg.Encode: %v", err)
|
||
}
|
||
return buf.Bytes()
|
||
}
|
||
|
||
// cropArgs 拼一组 image-crop 的参数,省得每个用例重抄区域坐标。
|
||
func cropArgs(source string) map[string]any {
|
||
return map[string]any{"image": source, "x": 2, "y": 2, "width": 4, "height": 3}
|
||
}
|
||
|
||
// TestCropTool_DataURIRealImages 钉住主用例:带着**完整 base64 载荷**的
|
||
// data: URI(png 与 jpeg)都必须照常裁切 —— 也就是 2026-10-07 那次失败里
|
||
// 「本该成立的形状」。
|
||
func TestCropTool_DataURIRealImages(t *testing.T) {
|
||
t.Parallel()
|
||
tool, _ := newCropToolForTest(t)
|
||
sources := map[string]string{
|
||
"png": "data:image/png;base64," + base64.StdEncoding.EncodeToString(gradientPNG(t, 20, 20)),
|
||
"jpeg": "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(gradientJPEG(t, 20, 20)),
|
||
}
|
||
for name, source := range sources {
|
||
t.Run(name, func(t *testing.T) {
|
||
t.Parallel()
|
||
res, err := tool.Execute(context.Background(), cropArgs(source))
|
||
if err != nil {
|
||
t.Fatalf("Execute(%s data URI): %v", name, err)
|
||
}
|
||
if img := decodeCropResult(t, res); img.Bounds().Dx() != 4 || img.Bounds().Dy() != 3 {
|
||
t.Fatalf("crop size = %dx%d, want 4x3", img.Bounds().Dx(), img.Bounds().Dy())
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestCropTool_RejectsEmptyDataURIPayload 是本缺陷的回归用例(2026-10-07
|
||
// 18:32:51 实测):模型传的是 `data:image/png;base64,` —— **只有前缀、没有载荷**。
|
||
// 旧路径把它解成零字节再报 `image: unknown format`,指不到真正的毛病。
|
||
// 现在必须在本地说清病因,并且**不许**再出现那句 unknown format。
|
||
func TestCropTool_RejectsEmptyDataURIPayload(t *testing.T) {
|
||
t.Parallel()
|
||
tool, _ := newCropToolForTest(t)
|
||
_, err := tool.Execute(context.Background(), cropArgs("data:image/png;base64,"))
|
||
if err == nil {
|
||
t.Fatal("空载荷的 data: URI 被接受,want 报错")
|
||
}
|
||
msg := err.Error()
|
||
if strings.Contains(msg, "unknown format") {
|
||
t.Fatalf("错误文案退回旧形状(unknown format):%v", msg)
|
||
}
|
||
if !strings.Contains(msg, "no payload") {
|
||
t.Errorf("错误文案没点出「载荷为空」:%v", msg)
|
||
}
|
||
if !strings.Contains(msg, "http(s) URL") {
|
||
t.Errorf("错误文案没给可行动提示:%v", msg)
|
||
}
|
||
}
|
||
|
||
// TestCropTool_RejectsNonImageAsNotAnImage 钉住「这不是图片」:把 HTML 当图片
|
||
// 传(无论走 data: URI 还是 URL)都要报明确错误 —— 说清嗅探到的类型、
|
||
// 支持的格式与可行动提示,且**不得回显正文**。
|
||
func TestCropTool_RejectsNonImageAsNotAnImage(t *testing.T) {
|
||
t.Parallel()
|
||
html := []byte("<!DOCTYPE html><html><body>502 Bad Gateway</body></html>")
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_, _ = w.Write(html)
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
|
||
tool, _ := newCropToolForTest(t, WithAllowedHosts(serverHost(srv)))
|
||
sources := map[string]string{
|
||
"data uri": "data:text/html;base64," + base64.StdEncoding.EncodeToString(html),
|
||
"url": srv.URL + "/page.html",
|
||
}
|
||
for name, source := range sources {
|
||
t.Run(name, func(t *testing.T) {
|
||
t.Parallel()
|
||
_, err := tool.Execute(context.Background(), cropArgs(source))
|
||
if err == nil {
|
||
t.Fatal("HTML 被当成图片接受,want 报错")
|
||
}
|
||
msg := err.Error()
|
||
if strings.Contains(msg, "502 Bad Gateway") || strings.Contains(msg, "<!DOCTYPE") {
|
||
t.Fatalf("错误文案回显了正文:%v", msg)
|
||
}
|
||
for _, want := range []string{"not an image", "text/html", "png", "jpeg", "gif"} {
|
||
if !strings.Contains(msg, want) {
|
||
t.Errorf("错误文案缺少 %q:%v", want, msg)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestCropTool_MalformedDataURI 钉住畸形入参不 panic、报可行动错误。
|
||
func TestCropTool_MalformedDataURI(t *testing.T) {
|
||
t.Parallel()
|
||
tool, _ := newCropToolForTest(t)
|
||
cases := map[string]string{
|
||
"no comma": "data:image/jpeg;base64",
|
||
"no base64": "data:image/png,plaintext",
|
||
"illegal base64": "data:image/png;base64,!!!!",
|
||
"empty payload": "data:image/jpeg;base64,",
|
||
}
|
||
for name, source := range cases {
|
||
t.Run(name, func(t *testing.T) {
|
||
t.Parallel()
|
||
if _, err := tool.Execute(context.Background(), cropArgs(source)); err == nil {
|
||
t.Fatalf("%q 被接受,want 报错", source)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestCropTool_BareBase64StaysAPath 钉住既有分类不变:裸 base64 **不是**本工具
|
||
// 支持的输入(cropSourceKindOf 只认 data: / http(s) / 路径),它照旧走文件路径那条
|
||
// 分支并被沙箱拒绝 —— 这次改动不碰它。
|
||
func TestCropTool_BareBase64StaysAPath(t *testing.T) {
|
||
t.Parallel()
|
||
tool, _ := newCropToolForTest(t)
|
||
bare := base64.StdEncoding.EncodeToString(gradientPNG(t, 8, 8))
|
||
_, err := tool.Execute(context.Background(), cropArgs(bare))
|
||
if err == nil {
|
||
t.Fatal("裸 base64 被当成图片接受,want 走路径分支并被拒")
|
||
}
|
||
if strings.Contains(err.Error(), "not an image") {
|
||
t.Fatalf("裸 base64 的失败形状变了(应仍是路径/沙箱报错):%v", err)
|
||
}
|
||
}
|
||
|
||
// TestCropTool_RegisteredInSet pins that the tool ships with the default set and
|
||
// advertises a usable schema (the agent only sees it if it is registered).
|
||
func TestCropTool_RegisteredInSet(t *testing.T) {
|
||
t.Parallel()
|
||
tool, _ := newCropToolForTest(t)
|
||
if tool.Name() != "image-crop" {
|
||
t.Errorf("Name = %q, want image-crop", tool.Name())
|
||
}
|
||
params, err := tool.Parameters()
|
||
if err != nil {
|
||
t.Fatalf("Parameters: %v", err)
|
||
}
|
||
if params["type"] != "object" {
|
||
t.Errorf("schema type = %v, want object", params["type"])
|
||
}
|
||
var _ sdk.Tool = tool
|
||
// A nil/mis-typed argument set must be an error, not a panic.
|
||
if _, err := tool.Execute(context.Background(), "not-a-map"); err == nil {
|
||
t.Error("non-object args accepted, want an error")
|
||
}
|
||
if _, err := tool.Execute(context.Background(), map[string]any{}); err == nil {
|
||
t.Error("missing image accepted, want an error")
|
||
}
|
||
}
|