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

+29 -3
View File
@@ -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)
+13 -2
View File
@@ -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
+21 -10
View File
@@ -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 补上,保证对外错误
+315
View File
@@ -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
}
+208
View File
@@ -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)
}
+122
View File
@@ -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:只读 <DataDir>/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}}
}
+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)
}
}