新增 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(网关给每条在途路挂互斥身份)。
135 lines
6.0 KiB
Go
135 lines
6.0 KiB
Go
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)
|
||
}
|
||
}
|