feat(tools): 真实工具声明文件目标(让文件级互斥真正生效)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

新增 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(网关给每条在途路挂互斥身份)。
This commit is contained in:
AskaEth committed 2026-10-05 09:55:29 +08:00
1 parent ef3dcb699b
commit 3589f4b14f
7 files changed
+842 -15

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
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)
}
}