From 3589f4b14fe1c4a4e04f0f957e2309d0fb14d07c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 09:55:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(tools):=20=E7=9C=9F=E5=AE=9E=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=A3=B0=E6=98=8E=E6=96=87=E4=BB=B6=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=EF=BC=88=E8=AE=A9=E6=96=87=E4=BB=B6=E7=BA=A7=E4=BA=92=E6=96=A5?= =?UTF-8?q?=E7=9C=9F=E6=AD=A3=E7=94=9F=E6=95=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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(网关给每条在途路挂互斥身份)。 --- plugins/tools/io/crop.go | 32 ++- plugins/tools/io/doc.go | 15 +- plugins/tools/io/file.go | 31 ++- plugins/tools/io/file_claims_rig_test.go | 315 +++++++++++++++++++++++ plugins/tools/io/file_claims_test.go | 208 +++++++++++++++ plugins/tools/io/file_targets.go | 122 +++++++++ plugins/tools/io/file_targets_test.go | 134 ++++++++++ 7 files changed, 842 insertions(+), 15 deletions(-) create mode 100644 plugins/tools/io/file_claims_rig_test.go create mode 100644 plugins/tools/io/file_claims_test.go create mode 100644 plugins/tools/io/file_targets.go create mode 100644 plugins/tools/io/file_targets_test.go diff --git a/plugins/tools/io/crop.go b/plugins/tools/io/crop.go index d97581e..49a8a0c 100644 --- a/plugins/tools/io/crop.go +++ b/plugins/tools/io/crop.go @@ -192,13 +192,39 @@ func encodePNG(img image.Image) ([]byte, error) { return buf.Bytes(), nil } +// cropSourceKind 是 image 参数的来源分类:只有本地路径会碰文件系统,URL 与 data URI +// 都不落盘。分类只此一份 —— 执行分派(load)与目标声明(file_targets.go 的 +// cropTool.FileTargets)共用它,两处就不会各自推演前缀规则而漂移。 +type cropSourceKind int + +const ( + // cropSourceFile 是本地路径:读取要过 file 工具的沙箱。 + cropSourceFile cropSourceKind = iota + // cropSourceURL 是 http(s) 源:走共享的 SSRF 防护客户端。 + cropSourceURL + // cropSourceData 是 base64/data: URI:内容就在参数里,不碰磁盘。 + cropSourceData +) + +// cropSourceKindOf 分类一份 image 参数。 +func cropSourceKindOf(src string) cropSourceKind { + switch { + case strings.HasPrefix(src, "data:"): + return cropSourceData + case strings.HasPrefix(src, "http://"), strings.HasPrefix(src, "https://"): + return cropSourceURL + default: + return cropSourceFile + } +} + // load reads the source image: a local path through the file sandbox, an http(s) // URL through the shared SSRF-guarded client, or a base64/data: URI payload. func (c *cropTool) load(ctx context.Context, src string) ([]byte, error) { - switch { - case strings.HasPrefix(src, "data:"): + switch cropSourceKindOf(src) { + case cropSourceData: return decodeCropDataURI(src) - case strings.HasPrefix(src, "http://"), strings.HasPrefix(src, "https://"): + case cropSourceURL: return c.fetch(ctx, src) default: return c.readFile(src) diff --git a/plugins/tools/io/doc.go b/plugins/tools/io/doc.go index 3d5ff0e..7daad59 100644 --- a/plugins/tools/io/doc.go +++ b/plugins/tools/io/doc.go @@ -1,6 +1,6 @@ // Package io implements the built-in I/O and network tools for Cyrene v2 -// (DevDocs/design/09-插件生态.md). It exposes file, image-crop, http, web-search, -// web-fetch and knowledge as pkg/plugins/sdk.Tool values, wired to the +// (DevDocs/design/09-插件生态.md). It exposes file, image-crop, document-read, http, +// web-search, web-fetch and knowledge as pkg/plugins/sdk.Tool values, wired to the // process-internal config, logger and runtime/memory retriever. // // Build the full configured tool set with New(cfg, opts...). Tools() and @@ -25,5 +25,16 @@ // - knowledge: RAG lookup over the runtime memory retriever; a clear message // when no knowledge base is configured. // +// File-level mutual exclusion +// +// The two tools that touch files declare the file each call will touch +// (tool.FileTargeter, implemented in file_targets.go): file declares read for +// read/list/search and write for write; image-crop declares a read when its image +// is a local path. When another in-flight parallel leg is already operating on that +// same file, the call returns tool.ErrFileBusy with the file's state instead of +// running or queueing; two reads of one file still run in parallel, and calls that +// cannot touch a file (a sandbox-rejected path, an HTTP or data: URI source) declare +// nothing and keep their previous behavior. +// // All tools are safe for concurrent use, as required by sdk.Tool. package io diff --git a/plugins/tools/io/file.go b/plugins/tools/io/file.go index 237b711..e260d22 100644 --- a/plugins/tools/io/file.go +++ b/plugins/tools/io/file.go @@ -61,7 +61,7 @@ func (f *fileTool) Parameters() (map[string]any, error) { return map[string]any{ "type": "object", "properties": map[string]any{ - "action": map[string]any{"type": "string", "enum": []any{"read", "write", "list", "search"}, "description": "Operation to perform."}, + "action": map[string]any{"type": "string", "enum": []any{actionRead, actionWrite, actionList, actionSearch}, "description": "Operation to perform."}, "path": map[string]any{"type": "string", "description": "Absolute or data-directory-relative file path."}, "content": map[string]any{"type": "string", "description": "Body to write."}, "query": map[string]any{"type": "string", "description": "Substring filter for search."}, @@ -77,13 +77,13 @@ func (f *fileTool) Execute(ctx context.Context, args any) (any, error) { return nil, fmt.Errorf("io: file: args must be an object") } switch action := argString(m, "action"); action { - case "read": + case actionRead: return f.read(ctx, argString(m, "path")) - case "write": + case actionWrite: return f.write(ctx, argString(m, "path"), argString(m, "content")) - case "list": + case actionList: return f.list(ctx, argString(m, "path")) - case "search": + case actionSearch: return f.search(ctx, argString(m, "path"), argString(m, "query")) default: return nil, fmt.Errorf("io: file: unknown action %q", action) @@ -202,11 +202,9 @@ func (f *fileTool) search(ctx context.Context, base, query string) (any, error) if strings.TrimSpace(query) == "" { return nil, fmt.Errorf("io: file: search query is required") } - if strings.TrimSpace(base) == "" { - if len(f.roots) == 0 { - return nil, fmt.Errorf("io: file: no base directory configured") - } - base = f.roots[0] + base, err := f.searchBase(base) + if err != nil { + return nil, err } real, err := f.resolveExisting(base) if err != nil { @@ -248,6 +246,19 @@ func (f *fileTool) search(ctx context.Context, base, query string) (any, error) return map[string]any{"base": real, "query": query, "hits": hits, "count": len(hits)}, nil } +// searchBase 解析 search 的基准目录:显式给定就原样返回(由 resolveExisting 归一), +// 缺省时用主 root。执行路径与目标声明(file_targets.go 的 declaredPath)必须用同一 +// 份缺省口径,否则声明出来的路径会与真正走查的目录不是同一个。 +func (f *fileTool) searchBase(base string) (string, error) { + if strings.TrimSpace(base) != "" { + return base, nil + } + if len(f.roots) == 0 { + return "", fmt.Errorf("io: file: no base directory configured") + } + return f.roots[0], nil +} + // ErrNullByte 表示路径中含 NUL 字节:在触达文件系统之前就被拒绝(收敛自原 exec 文件 // 沙箱,裁决 13.4.4)。调用方与测试一律用 errors.Is(err, ErrNullByte) 判定——不要断言 // 错误文本;此处只存不带来源前缀的文本,前缀由返回值处的 fmt.Errorf 补上,保证对外错误 diff --git a/plugins/tools/io/file_claims_rig_test.go b/plugins/tools/io/file_claims_rig_test.go new file mode 100644 index 0000000..bb04e89 --- /dev/null +++ b/plugins/tools/io/file_claims_rig_test.go @@ -0,0 +1,315 @@ +package io + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件是 file_claims_test.go(真实插件 + 真实分发器的互斥回归)的夹具:记录版占用表、 +// 带闸门的工具包装、以及等待与断言助手。夹具只搭台子,不写断言语义 —— 读共享 / 写独占的 +// 判定一律由**真实的**进程内占用表给出(tool.NewFileClaimer),不拿自造的假表去测。 + +// claimProbeTimeout 是「第一路必须进入闸门 / 一路必须结束」这类断言的等待上限:足够宽 +// 以免依赖机器速度,又足够短以免偶发死锁拖成整包超时。 +const claimProbeTimeout = 2 * time.Second + +// legResult 是一路调用的结果。 +type legResult struct { + val any + err error +} + +// recordingClaimer 包住真实的进程内占用表(tool.NewFileClaimer),把每一次登记尝试的目标 +// 键记下来:测试因此能断言「哪些调用参与了互斥、此刻占用几个键」。 +type recordingClaimer struct { + inner tool.FileClaimer + mu sync.Mutex + // live 镜像真实占用表的键集合:目标键 -> 路标识 -> 登记次数(同路重复登记按幂等算, + // 与真实表一致;释放到最后一个占用者时删键)。 + live map[string]map[string]int + // attempted 按发生顺序记录**每一次**登记尝试的目标键(含被判忙而失败的那些): + // 「尝试过」与「成功占到键」是两件事 —— 前者证明这一路确实拿着声明的目标来问过表。 + attempted []string +} + +func newRecordingClaimer() *recordingClaimer { + return &recordingClaimer{inner: tool.NewFileClaimer(), live: map[string]map[string]int{}} +} + +// Claim 实现 tool.FileClaimer:先记账(判忙的尝试也要留痕),再让真实占用表判;只有 +// 成功了才占键(失败即未登记,与真实表一致)。 +func (r *recordingClaimer) Claim(ctx context.Context, legID, target string) error { + r.mu.Lock() + r.attempted = append(r.attempted, target) + r.mu.Unlock() + if err := r.inner.Claim(ctx, legID, target); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + legs := r.live[target] + if legs == nil { + legs = map[string]int{} + r.live[target] = legs + } + legs[legID]++ + return nil +} + +// Release 实现 tool.FileClaimer:无条件转发给真实占用表,并同步镜像(非占用者的释放是 +// 空操作,与真实表一致)。 +func (r *recordingClaimer) Release(ctx context.Context, legID, target string) { + r.inner.Release(ctx, legID, target) + r.mu.Lock() + defer r.mu.Unlock() + legs := r.live[target] + if legs == nil { + return + } + if n := legs[legID]; n > 1 { + legs[legID] = n - 1 + } else { + delete(legs, legID) + } + if len(legs) == 0 { + delete(r.live, target) + } +} + +// liveCount 返回此刻占用的目标键数(= 真实占用表的键数)。 +func (r *recordingClaimer) liveCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.live) +} + +// claimAttempts 返回登记**尝试**的次数(未声明目标的调用必须为 0:它连表都不问)。 +func (r *recordingClaimer) claimAttempts() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.attempted) +} + +// targets 返回登记尝试过的目标键(副本)。 +func (r *recordingClaimer) targets() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.attempted)) + copy(out, r.attempted) + return out +} + +// claimRig 是一套「真实插件 + 真实分发器」的互斥夹具:DataDir 即 file 工具唯一的 root, +// 占用表换成记录版。 +type claimRig struct { + d *tool.Dispatcher + rec *recordingClaimer + dir string + tools map[string]sdk.Tool +} + +func newClaimRig(t *testing.T) *claimRig { + t.Helper() + dir := t.TempDir() + rec := newRecordingClaimer() + d, err := tool.New(registry.New(), tool.WithFileClaimer(rec)) + if err != nil { + t.Fatalf("tool.New: %v", err) + } + set := buildTools(t, &config.Config{DataDir: dir}) + byName := make(map[string]sdk.Tool, len(set)) + for _, tk := range set { + byName[tk.Name()] = tk + if err := d.Register(tk); err != nil { + t.Fatalf("Register %q: %v", tk.Name(), err) + } + } + return &claimRig{d: d, rec: rec, dir: dir, tools: byName} +} + +// seed 在沙箱 root 里放一个文件(测试夹具,不经过工具)。 +func (r *claimRig) seed(t *testing.T, name, content string) string { + t.Helper() + p := filepath.Join(r.dir, name) + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatalf("seed %q: %v", name, err) + } + return p +} + +// gated 注册一个带闸门的**真实插件**实例(名字换成 name,声明面原样转发)。 +func (r *claimRig) gated(t *testing.T, toolName, name string) *gatedTool { + t.Helper() + inner, ok := r.tools[toolName] + if !ok { + t.Fatalf("工具集里没有 %q", toolName) + } + g := newGatedTool(inner, name) + if err := r.d.Register(g); err != nil { + t.Fatalf("Register %q: %v", name, err) + } + return g +} + +// gatedTool 包住一个真实工具,在 Execute 前后加两个可控闸门(进入信号 + 让行等待):占用 +// 登记在 Execute **之前**、释放在它**之后**,所以停在闸门里就等于「这一路持有声明出来的 +// 占用」,撞车因此是确定性事件,而不是靠 sleep 抢时序。它还必须显式转发 FileTargets —— +// 内嵌接口只会提升接口里的方法,声明面不转发就变成了「未实现 FileTargeter 的工具」, +// 用例会退化成一个假绿。 +type gatedTool struct { + inner sdk.Tool + name string + entered chan struct{} + hold chan struct{} + enteredOne sync.Once + holdOne sync.Once +} + +func newGatedTool(inner sdk.Tool, name string) *gatedTool { + return &gatedTool{inner: inner, name: name, entered: make(chan struct{}), hold: make(chan struct{})} +} + +func (g *gatedTool) Name() string { return g.name } + +func (g *gatedTool) Description() string { return g.inner.Description() } + +func (g *gatedTool) Parameters() (map[string]any, error) { return g.inner.Parameters() } + +// FileTargets 原样转发内层工具的声明:测试验证的始终是**插件自己**的目标判定。 +func (g *gatedTool) FileTargets(args any) []tool.FileTarget { + targeter, ok := g.inner.(tool.FileTargeter) + if !ok { + return nil + } + return targeter.FileTargets(args) +} + +func (g *gatedTool) Execute(ctx context.Context, args any) (any, error) { + g.enteredOne.Do(func() { close(g.entered) }) + select { + case <-g.hold: + case <-ctx.Done(): + return nil, ctx.Err() + } + return g.inner.Execute(ctx, args) +} + +// release 放行闸门(幂等:不会二次 close)。 +func (g *gatedTool) release() { g.holdOne.Do(func() { close(g.hold) }) } + +// invokeLeg 在指定并发路上同步调用工具。 +func invokeLeg(d *tool.Dispatcher, leg, name string, args map[string]any) (any, error) { + return d.Invoke(tool.WithLegID(context.Background(), leg), name, args) +} + +// runLeg 在指定并发路上**异步**调用工具:它一进入闸门,持有该目标的占用就已经成立。 +func runLeg(d *tool.Dispatcher, leg, name string, args map[string]any) <-chan legResult { + out := make(chan legResult, 1) + go func() { + val, err := invokeLeg(d, leg, name, args) + out <- legResult{val: val, err: err} + }() + return out +} + +// awaitEnter 等一路真的进入 Execute(此刻它的占用已登记)。 +func awaitEnter(t *testing.T, ch <-chan struct{}) { + t.Helper() + select { + case <-ch: + case <-time.After(claimProbeTimeout): + t.Fatal("第一路没有进入执行") + } +} + +// awaitDone 等一路结束。 +func awaitDone(t *testing.T, ch <-chan legResult) legResult { + t.Helper() + select { + case res := <-ch: + return res + case <-time.After(claimProbeTimeout): + t.Fatal("这一路没有结束") + return legResult{} + } +} + +// assertBusy 断言一次调用**被判忙**:errors.Is 命中 tool.ErrFileBusy,且面向模型的文案里 +// 带文件名与文件状态(说出是谁在动它、本次做不了什么)。 +func assertBusy(t *testing.T, err error, wantSubs ...string) { + t.Helper() + if err == nil { + t.Fatal("期望被判忙,实际调用成功了") + } + if !errors.Is(err, tool.ErrFileBusy) { + t.Fatalf("错误不是 ErrFileBusy: %v", err) + } + msg := err.Error() + for _, want := range append([]string{"请稍后再试"}, wantSubs...) { + if !strings.Contains(msg, want) { + t.Fatalf("回灌给模型的文案缺少 %q: %s", want, msg) + } + } +} + +// assertNotBusy 断言一次调用没被判忙(其它失败允许:这里只关心互斥不该拦它)。 +func assertNotBusy(t *testing.T, err error) { + t.Helper() + if errors.Is(err, tool.ErrFileBusy) { + t.Fatalf("不该被判忙,得到 %v", err) + } +} + +// assertNoClaims 断言此刻占用表为空、且登记**尝试**次数恰为 wantAttempts:未声明目标的 +// 调用必须一次都不问表,声明了但被判忙的那些则只留尝试、不占键。 +func assertNoClaims(t *testing.T, rec *recordingClaimer, wantAttempts int) { + t.Helper() + if got := rec.liveCount(); got != 0 { + t.Fatalf("占用表必须为空,残留 %d 个键", got) + } + if got := rec.claimAttempts(); got != wantAttempts { + t.Fatalf("登记尝试次数 = %d,期望 %d", got, wantAttempts) + } +} + +// plainTool 是最小 sdk.Tool:**只**实现 sdk.Tool(不实现 FileTargeter),用来钉住「未声明 +// 目标的工具完全不参与互斥」。 +type plainTool struct{ name string } + +func (p *plainTool) Name() string { return p.name } + +func (p *plainTool) Description() string { return "test tool without file targets" } + +func (p *plainTool) Parameters() (map[string]any, error) { + return map[string]any{ + "type": "object", + "properties": map[string]any{"echo": map[string]any{"type": "string"}}, + }, nil +} + +func (p *plainTool) Execute(_ context.Context, args any) (any, error) { + m, _ := args.(map[string]any) + return argString(m, "echo"), nil +} + +// toolContent 取 file 工具读到的正文。 +func toolContent(t *testing.T, val any) string { + t.Helper() + m, ok := val.(map[string]any) + if !ok { + t.Fatalf("读取结果不是对象: %#v", val) + } + content, _ := m["content"].(string) + return content +} diff --git a/plugins/tools/io/file_claims_test.go b/plugins/tools/io/file_claims_test.go new file mode 100644 index 0000000..4664508 --- /dev/null +++ b/plugins/tools/io/file_claims_test.go @@ -0,0 +1,208 @@ +package io + +import ( + "os" + "path/filepath" + "testing" +) + +// 本文件是**文件级互斥的机制级回归**(声明面单元见 file_targets_test.go,夹具见 +// file_claims_rig_test.go):把**真实插件**注册进真实的 tool.Dispatcher,用可控闸门把 +// 「一路持有占用」变成确定性事件(占用登记在 Execute 之前、释放在其后,因此停在闸门里就 +// 等于持有),逐条钉住主人口径: +// +// - 同一文件的两个读**并行放行**(检索类并行不该被互斥拖住); +// - 读与写、写与写**判忙**,且回灌给模型的文案带文件名与文件状态; +// - 不同文件**放行**; +// - 未实现 FileTargeter 的工具**完全不受影响**(占用表 0 键)。 +// +// 这一层走的是派发器真实的登记 / 释放路径与真实的进程内占用表 —— 声明面一错就红。 + +// TestFileClaims_TwoReadsOfTheSameFileRunParallel 钉住①:同一文件的两个读并行放行 +// (主人「优先考虑性能而不是预算」的口径)。 +func TestFileClaims_TwoReadsOfTheSameFileRunParallel(t *testing.T) { + t.Parallel() + rig := newClaimRig(t) + file := rig.seed(t, "notes.txt", "hello from disk") + holder := rig.gated(t, "file", "file-read-holder") + first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "read", "path": file}) + awaitEnter(t, holder.entered) + + val, err := invokeLeg(rig.d, "turn-b", "file", map[string]any{"action": "read", "path": file}) + if err != nil { + t.Fatalf("同一文件的两个读必须允许并行,得到 %v", err) + } + if content := toolContent(t, val); content != "hello from disk" { + t.Fatalf("第二路读到的正文 = %q", content) + } + // 两路登记的是**同一个键**(同类别、同路径):这才叫「同一个文件的读读并行」, + // 而不是两路各自登记了不同的键。 + targets := rig.rec.targets() + if len(targets) != 2 || targets[0] != targets[1] { + t.Fatalf("两路读应登记同一个目标键,得到 %#v", targets) + } + if got := rig.rec.liveCount(); got != 1 { + t.Fatalf("第二路退场后应只剩持锁那一路的键,实际 %d 个", got) + } + + holder.release() + if res := awaitDone(t, first); res.err != nil { + t.Fatalf("持锁那一路应正常完成: %v", res.err) + } + assertNoClaims(t, rig.rec, 2) +} + +// TestFileClaims_ReadIsBusyWhileAnotherLegWrites 钉住②:读与写同一文件 ⇒ 判忙,且面向 +// 模型的文案带文件名与文件状态(谁在写、本次读不了);持锁那一路退场后立刻可再进。 +func TestFileClaims_ReadIsBusyWhileAnotherLegWrites(t *testing.T) { + t.Parallel() + rig := newClaimRig(t) + file := rig.seed(t, "notes.txt", "v1") + holder := rig.gated(t, "file", "file-write-holder") + first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "v2"}) + awaitEnter(t, holder.entered) + + _, err := invokeLeg(rig.d, "turn-b", "file", map[string]any{"action": "read", "path": file}) + assertBusy(t, err, "notes.txt", "正在被另一路写入", "本次读取无法进行") + + holder.release() + if res := awaitDone(t, first); res.err != nil { + t.Fatalf("持锁那一路应正常完成: %v", res.err) + } + if data, err := os.ReadFile(file); err != nil || string(data) != "v2" { + t.Fatalf("持锁那一路的写入没有落盘: %q, %v", data, err) + } + if _, err := invokeLeg(rig.d, "turn-c", "file", map[string]any{"action": "read", "path": file}); err != nil { + t.Fatalf("前一路退场后必须已释放占用,得到 %v", err) + } + // 三次尝试:持锁那一路的写、被判忙的那次读、退场后的第二次读。 + assertNoClaims(t, rig.rec, 3) +} + +// TestFileClaims_WriteVsWriteIsBusy 钉住②(写写):第二路写同一文件被判忙、**根本没有执行** +// (闸门未被进入),文件里留下的是持锁那一路的内容。 +func TestFileClaims_WriteVsWriteIsBusy(t *testing.T) { + t.Parallel() + rig := newClaimRig(t) + file := rig.seed(t, "notes.txt", "seed") + holder := rig.gated(t, "file", "file-write-holder") + peer := rig.gated(t, "file", "file-write-peer") + first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "held"}) + awaitEnter(t, holder.entered) + + _, err := invokeLeg(rig.d, "turn-b", peer.Name(), map[string]any{"action": "write", "path": file, "content": "peer"}) + assertBusy(t, err, "notes.txt", "正在被另一路写入", "本次写入无法进行") + select { + case <-peer.entered: + t.Fatal("被判忙的调用绝不该执行") + default: + } + + holder.release() + if res := awaitDone(t, first); res.err != nil { + t.Fatalf("持锁那一路应正常完成: %v", res.err) + } + if data, err := os.ReadFile(file); err != nil || string(data) != "held" { + t.Fatalf("文件内容应只剩持锁那一路写的 held: %q, %v", data, err) + } + // 两次尝试:持锁那一路的写,以及第二路被判忙的写(判忙那一路也问过表,只是没占到键)。 + assertNoClaims(t, rig.rec, 2) +} + +// TestFileClaims_CrossToolReadOfAWrittenFileIsBusy 钉住②(跨工具):file 正在写一个文件时, +// image-crop 读**同一个文件**同样判忙 —— 这正是文件级互斥要盖住的场景(读到的会是撕开的 +// 内容),也是「同一份路径口径被两个工具共用」的证据。 +func TestFileClaims_CrossToolReadOfAWrittenFileIsBusy(t *testing.T) { + t.Parallel() + rig := newClaimRig(t) + file := rig.seed(t, "notes.txt", "v1") + holder := rig.gated(t, "file", "file-write-holder") + first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "v2"}) + awaitEnter(t, holder.entered) + + _, err := invokeLeg(rig.d, "turn-b", "image-crop", map[string]any{ + "image": file, "x": 0, "y": 0, "width": 1, "height": 1, + }) + assertBusy(t, err, "notes.txt", "正在被另一路写入", "本次读取无法进行") + + holder.release() + if res := awaitDone(t, first); res.err != nil { + t.Fatalf("持锁那一路应正常完成: %v", res.err) + } + // 两次尝试:持锁那一路的写,以及 image-crop 读同一文件被判忙的那次。 + assertNoClaims(t, rig.rec, 2) +} + +// TestFileClaims_DifferentFilesRunParallel 钉住③:不是同一个文件 ⇒ 放行(且真的写成功), +// 占用表里两路各占一个键、退场后清空。 +func TestFileClaims_DifferentFilesRunParallel(t *testing.T) { + t.Parallel() + rig := newClaimRig(t) + held := rig.seed(t, "a.txt", "a") + other := filepath.Join(rig.dir, "b.txt") + holder := rig.gated(t, "file", "file-write-holder") + first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": held, "content": "held"}) + awaitEnter(t, holder.entered) + + val, err := invokeLeg(rig.d, "turn-b", "file", map[string]any{"action": "write", "path": other, "content": "bee"}) + if err != nil { + t.Fatalf("不同文件必须允许并行操作,得到 %v", err) + } + if m, ok := val.(map[string]any); !ok || m["size"] != len("bee") { + t.Fatalf("第二路的结果异常: %#v", val) + } + if data, err := os.ReadFile(other); err != nil || string(data) != "bee" { + t.Fatalf("第二路的写入没有落盘: %q, %v", data, err) + } + if got := rig.rec.liveCount(); got != 1 { + t.Fatalf("第二路退场后应只剩持锁那一路的键,实际 %d 个", got) + } + + holder.release() + if res := awaitDone(t, first); res.err != nil { + t.Fatalf("持锁那一路应正常完成: %v", res.err) + } + assertNoClaims(t, rig.rec, 2) +} + +// TestFileClaims_ToolWithoutDeclarationIsUnaffected 钉住④:未实现 FileTargeter 的工具完全 +// 不受影响 —— (a) 它一次都不碰占用表;(b) 真实插件里不碰文件的 web-search 在别的路持有 +// 写占用时照常调用,不判忙、不登记。 +func TestFileClaims_ToolWithoutDeclarationIsUnaffected(t *testing.T) { + t.Parallel() + rig := newClaimRig(t) + + // (a) 两路并发调用一个只实现 sdk.Tool 的工具:都成功,占用表 0 键、登记 0 次。 + plain := &plainTool{name: "plain-tool"} + if err := rig.d.Register(plain); err != nil { + t.Fatalf("Register %q: %v", plain.Name(), err) + } + for _, leg := range []string{"turn-a", "turn-b"} { + val, err := invokeLeg(rig.d, leg, plain.Name(), map[string]any{"echo": "hi"}) + if err != nil { + t.Fatalf("未声明目标的工具不该受影响(leg=%s): %v", leg, err) + } + if echo, _ := val.(string); echo != "hi" { + t.Fatalf("结果 = %#v", val) + } + } + assertNoClaims(t, rig.rec, 0) + + // (b) 另一路持有某个文件的写占用时,未声明目标的真实工具照常可调用。 + file := rig.seed(t, "notes.txt", "v1") + holder := rig.gated(t, "file", "file-write-holder") + first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "v2"}) + awaitEnter(t, holder.entered) + + _, err := invokeLeg(rig.d, "turn-b", "web-search", map[string]any{"query": "x"}) + assertNotBusy(t, err) + if got := rig.rec.claimAttempts(); got != 1 { + t.Fatalf("只有持锁那一路该问占用表,实际问了 %d 次", got) + } + + holder.release() + if res := awaitDone(t, first); res.err != nil { + t.Fatalf("持锁那一路应正常完成: %v", res.err) + } + assertNoClaims(t, rig.rec, 1) +} diff --git a/plugins/tools/io/file_targets.go b/plugins/tools/io/file_targets.go new file mode 100644 index 0000000..0a4c3bb --- /dev/null +++ b/plugins/tools/io/file_targets.go @@ -0,0 +1,122 @@ +package io + +import ( + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" +) + +// 本文件给**真正碰文件的工具**实现文件级互斥的目标声明(tool.FileTargeter,机制见 +// internal/runtime/tool/fileclaims.go 与 design/15 §2.5.7):占用表要知道「本次调用 +// 会碰哪个文件、是读还是写」,而这件事只有工具自己知道 —— file 工具的 path 语义随 +// action 变(read/list/search 是读,write 是写),image-crop 的 image 只有「本地路径」 +// 这一种输入才碰磁盘。声明集中在这一个文件里(而不是散进各工具文件),是为了让 +// 「哪些工具参与互斥」在一处可审计:先前占用表恒空的缺口,正是「机制已就绪、没有 +// 任何工具声明」。 +// +// 体检结论(plugins/tools/**,逐文件核对 import 与 os/filepath 用法): +// - file:按路径读写/列目录/检索,**唯一会改文件的工具** ⇒ 实现(见下)。 +// - image-crop:会按路径读本地源图(URL 与 data URI 不落盘)⇒ 实现(读)。 +// - document-read:只读 /extracted 下的 sidecar,**不实现** —— sidecar 的 +// 路径布局(子目录名与后缀)是 internal/runtime/documents 的未导出实现,在插件里 +// 再拼一遍等于把布局抄成第二份真相,日后布局一改就静默失配;而且该包的写侧是 +// 「临时文件 + rename」原子替换,读侧读不到半截文件,这条互斥要挡的撕读风险本就 +// 不存在(经**工具**写 sidecar 的唯一路径是 file 工具直指该路径,那是越权目标, +// 本该被沙箱与模型侧拦住,不靠占用表兜)。 +// - http / web-fetch / web-search / knowledge:只走网络与内存库,不接任何路径。 +// - basic/**(calculator、datetime、json、markdown、random、text 等):纯计算与 +// 格式化,整个包没有任何 os/filepath 调用。 +// +// 两个实现都必须是**纯函数**(FileTargeter 的约定):只做词法解析(Clean/Abs/roots +// 归属判定),不触盘、不阻塞 —— 声明在每次工具调用**之前**被调用一次。 + +// file 工具的 action 词表。Execute 的 switch、Parameters 的 enum 与目标声明共用这 +// 一份常量,避免声明面与执行面对 action 各写一遍而漂移(声明错了方向,互斥要么漏挡 +// 要么误挡)。 +const ( + actionRead = "read" + actionWrite = "write" + actionList = "list" + actionSearch = "search" +) + +// 编译期钉住:会碰文件的工具都实现了声明面。少一个,占用表就少一份覆盖 —— 这正是 +// 本文件要防的那类静默缺口,因此用编译期断言而不是运行时检查。 +var ( + _ tool.FileTargeter = (*fileTool)(nil) + _ tool.FileTargeter = (*cropTool)(nil) +) + +// FileTargets 实现 tool.FileTargeter:file 工具本次要碰的就是参数里的 path(search +// 缺 path 时是主 root,与 Execute 的缺省同一口径),操作类别由 action 决定。 +// +// 路径取沙箱**解析前的声明值**(f.resolve 的产物:绝对、clean、已确认留在 roots +// 内),而不是模型原样给的相对路径:占用表的词法规范化是按进程当前目录求绝对路径, +// 而工具的真实落点是按 roots 求的,两者不一致就会出现「同一个文件两个键」而放行 +// 并行。沙箱会拒绝的路径一律不声明 —— 那种调用根本碰不到任何文件,声明了只会误挡。 +func (f *fileTool) FileTargets(args any) []tool.FileTarget { + m, ok := args.(map[string]any) + if !ok { + // 参数不是对象 ⇒ Execute 直接拒绝,碰不到文件。 + return nil + } + action := argString(m, "action") + path, ok := f.declaredPath(action, argString(m, "path")) + if !ok { + return nil + } + return []tool.FileTarget{{Op: fileTargetOp(action), Path: path}} +} + +// declaredPath 返回本次调用真正会碰的那个路径(词法层),报告是否有一个值得声明的 +// 目标。空 path 只有 search 有意义(缺省基准是主 root),其余 action 会被 Execute +// 拒绝;沙箱拒绝的路径也不声明。 +func (f *fileTool) declaredPath(action, p string) (string, bool) { + if strings.TrimSpace(p) == "" { + if action != actionSearch { + return "", false + } + base, err := f.searchBase(p) + if err != nil { + return "", false + } + p = base + } + clean, err := f.resolve(p) + if err != nil { + return "", false + } + return clean, true +} + +// fileTargetOp 把 action 映射成互斥用的操作类别。**未知/缺失的 action 一律按写处理** +// —— 方向必须选保守的那一侧:宁可多挡一次让模型稍后重试,也不放过两路并发写。执行面 +// 对未知 action 会拒绝(schema 的 enum 由分发器校验),但声明面跑在它**之前**,不能 +// 假设参数一定合法。 +func fileTargetOp(action string) tool.FileOp { + switch action { + case actionRead, actionList, actionSearch: + return tool.FileOpRead + default: + return tool.FileOpWrite + } +} + +// FileTargets 实现 tool.FileTargeter:image-crop 只读源图,且只有「本地路径」这一种 +// 输入会碰文件系统(http(s) URL 走网络、data URI 是内存里的一段 base64)。路径同样 +// 取沙箱解析前的声明值;沙箱会拒的路径不声明。 +func (c *cropTool) FileTargets(args any) []tool.FileTarget { + m, ok := args.(map[string]any) + if !ok || c.file == nil { + return nil + } + src := strings.TrimSpace(argString(m, "image")) + if src == "" || cropSourceKindOf(src) != cropSourceFile { + return nil + } + clean, err := c.file.resolve(src) + if err != nil { + return nil + } + return []tool.FileTarget{{Op: tool.FileOpRead, Path: clean}} +} diff --git a/plugins/tools/io/file_targets_test.go b/plugins/tools/io/file_targets_test.go new file mode 100644 index 0000000..a1eeb5f --- /dev/null +++ b/plugins/tools/io/file_targets_test.go @@ -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) + } +}