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

+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
}