Files
AskaEth 3589f4b14f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(tools): 真实工具声明文件目标(让文件级互斥真正生效)
新增 plugins/tools/io/file_targets.go:fileTool 与 cropTool 实现 tool.FileTargeter
(编译期钉住 var _ tool.FileTargeter,声明集中一处可审计)。
file:read/list/search ⇒ 读;其余(含 delete / 未知 / 缺失 action)⇒ 写(保守侧,宁可多挡一次)。
路径取沙箱解析前的声明值(绝对、clean、已确认在 roots 内,纯词法不触盘);沙箱会拒的路径不声明。
search 的缺省 base 与执行路径共用 f.searchBase(避免声明目录 ≠ 实际走查目录)。
image-crop:仅本地路径声明为读(URL/data URI 不落盘);分类与 load 共用一份。
体检清单:file ✅ / image-crop ✅ / document-read ❌(sidecar 走「临时文件 + rename」原子替换,
无撕读风险;且布局属未导出的第二份真相)/ basic、http、knowledge 等不碰盘;
vision·ocr(internal 越界)、terminal(cmd 越界 + 目标不可知)、MCP 外部工具 未做 —— 如实登记为遗留。
测试:单元(action→Op 全表 / 路径归一 / 不声明场景)+ 机制层 6 条(真插件 + 真 Dispatcher + 真占用表,
用闸门工具把并发变确定性事件,不靠 sleep)。
变异 5 条(一切按写 / 未知按读 / 用原始相对串 / crop 不声明 / file 声明被抹掉)。
注:本切片声明正确但生效依赖 ef3dcb6(网关给每条在途路挂互斥身份)。
2026-10-05 09:55:29 +08:00

135 lines
6.0 KiB
Go
Raw Permalink 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 (
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件钉住**文件级互斥的声明面单元**(tool.FileTargeter,实现见 file_targets.go):
// 每个 action / 每种 image 来源声明出什么(操作类别与路径)。参数一律写字面量
// "read"/"write"/… —— 那是模型真正发过来的词表,不是实现里的常量。
//
// 「真实插件 + 真实分发器」的机制级回归在 file_claims_test.go(夹具在
// file_claims_rig_test.go):本文件只回答「声明得对不对」,那一层回答「声明真的生效吗」。
// TestFileTargets_OpFollowsAction 钉住 file 工具的操作类别判定:read/list/search 是读,
// write 是写,**未知 / 缺失的 action 一律按写**(保守方向:宁可多挡一次让模型重试,也不
// 放过两路并发写;执行面对未知 action 会拒绝,但声明面跑在它之前)。
func TestFileTargets_OpFollowsAction(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ft := fileToolForTest(t, &config.Config{DataDir: dir})
want := filepath.Join(dir, "notes.txt")
cases := []struct {
name string
args map[string]any
wantOp tool.FileOp
}{
{"read", map[string]any{"action": "read", "path": "notes.txt"}, tool.FileOpRead},
{"list", map[string]any{"action": "list", "path": "notes.txt"}, tool.FileOpRead},
{"search", map[string]any{"action": "search", "path": "notes.txt", "query": "x"}, tool.FileOpRead},
{"write", map[string]any{"action": "write", "path": "notes.txt", "content": "x"}, tool.FileOpWrite},
{"unknown action", map[string]any{"action": "delete", "path": "notes.txt"}, tool.FileOpWrite},
{"missing action", map[string]any{"path": "notes.txt"}, tool.FileOpWrite},
{"absolute path", map[string]any{"action": "write", "path": want}, tool.FileOpWrite},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
got := ft.FileTargets(c.args)
if len(got) != 1 {
t.Fatalf("期望声明 1 个目标,得到 %d 个: %#v", len(got), got)
}
if got[0].Op != c.wantOp {
t.Fatalf("Op = %q,期望 %q", got[0].Op, c.wantOp)
}
// 路径必须是工具**执行时会用的那个**:沙箱解析前的声明值(绝对、clean、
// 相对路径按主 root 展开),而不是模型原样给的相对串 —— 否则占用表的键会
// 按进程当前目录算,与工具的真实落点对不上。
if got[0].Path != want {
t.Fatalf("Path = %q,期望 %q", got[0].Path, want)
}
})
}
}
// TestFileTargets_DeclaresNothingWhenNoFileCanBeTouched 钉住「声明不出来就不参与互斥」:
// 参数不是对象、path 缺失/为空(search 除外)、沙箱会拒绝的路径、根本没有 root 时都不声明。
func TestFileTargets_DeclaresNothingWhenNoFileCanBeTouched(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ft := fileToolForTest(t, &config.Config{DataDir: dir})
cases := []struct {
name string
args any
}{
{"args is not an object", "notes.txt"},
{"path missing", map[string]any{"action": "read"}},
{"path blank", map[string]any{"action": "write", "path": " "}},
{"blank path on a non-search action", map[string]any{"action": "list", "path": ""}},
{"path escaping the sandbox", map[string]any{"action": "write", "path": filepath.Join(dir, "..", "escape.txt")}},
{"NUL byte in path", map[string]any{"action": "read", "path": "a\x00b.txt"}},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := ft.FileTargets(c.args); len(got) != 0 {
t.Fatalf("不该声明任何目标,得到 %#v", got)
}
})
}
// search 的缺省基准是主 root(与 Execute 的缺省同一口径):这种调用真的会走查目录,
// 因此必须声明出来,而不是当成「没 path 就不声明」。
got := ft.FileTargets(map[string]any{"action": "search", "path": "", "query": "x"})
if len(got) != 1 || got[0].Op != tool.FileOpRead || got[0].Path != filepath.Clean(dir) {
t.Fatalf("search 缺省 base 应声明为主 root 的读,得到 %#v", got)
}
// 没有任何 root(配置缺失)时连 search 也无从谈起:不声明。
rootless := newFileTool(&tools{})
if got := rootless.FileTargets(map[string]any{"action": "search", "path": ""}); len(got) != 0 {
t.Fatalf("没有 root 时不该声明目标,得到 %#v", got)
}
if got := rootless.FileTargets(map[string]any{"action": "write", "path": "a.txt"}); len(got) != 0 {
t.Fatalf("没有 root 时不该声明目标,得到 %#v", got)
}
}
// TestCropFileTargets_OnlyLocalPathsDeclareARead 钉住 image-crop:只有本地路径这一种输入
// 会碰磁盘(http(s) 走网络、data URI 是内存里的 base64),且它**只读**(本工具从不写文件)。
func TestCropFileTargets_OnlyLocalPathsDeclareARead(t *testing.T) {
t.Parallel()
dir := t.TempDir()
crop := findTool(t, buildTools(t, &config.Config{DataDir: dir}), "image-crop").(*cropTool)
want := filepath.Join(dir, "shot.png")
got := crop.FileTargets(map[string]any{"image": "shot.png", "x": 0, "y": 0, "width": 1, "height": 1})
if len(got) != 1 || got[0].Op != tool.FileOpRead || got[0].Path != want {
t.Fatalf("本地路径应声明一次读(路径=%q),得到 %#v", want, got)
}
for _, args := range []any{
map[string]any{"image": "https://example.com/a.png"},
map[string]any{"image": "data:image/png;base64,AAAA"},
map[string]any{"image": " "},
map[string]any{"image": filepath.Join(dir, "..", "escape.png")},
map[string]any{"image": "a\x00b.png"},
"shot.png",
} {
if got := crop.FileTargets(args); len(got) != 0 {
t.Fatalf("这些输入不碰磁盘或会被沙箱拒绝,不该声明目标,得到 %#v(args=%#v)", got, args)
}
}
// 没有沙箱(file 工具缺席)时解析不出真实落点:不声明。
if got := (&cropTool{}).FileTargets(map[string]any{"image": "shot.png"}); len(got) != 0 {
t.Fatalf("没有沙箱时不该声明目标,得到 %#v", got)
}
}