Files
Agent-System-Backend/internal/tools/fs.go
T
weichenandCursor bead03374e
ci / vet-and-test (pull_request) Successful in 39s
Rewind tracked files and fall back before the first token
Why: workspace writes had no checkpoint, and a 5xx before any output stopped the turn instead of trying the next configured model.
Evidence: GOWORK=off go test ./internal/runtime/ ./internal/runmgr/ ./internal/tools/ ./internal/settings/ ./internal/server/ ./internal/app/
Impact: models.chat.fallbacks defaults to empty, so existing runs keep their model. Rewind restores only tracked writes and appends a system note. Usage records the model that served.
Refs: feature/durable-rewind
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 17:31:56 +00:00

264 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// fs 工具族(提案 2026-09-30-file-preview-and-reserved-capabilities,裁定 2.3):
// 实现但不默认提供——Desc.Hidden=true,runtime.fsTools 开启前对模型与 /api/tools 不可见,
// registry.Execute 同门禁(fail-closed)。沙箱根 = <root>/<owner>/<conversationID>/,
// 路径规范化后必须仍在会话目录内;写文件同步登记 artifact,保住"工作区文件=工件面板可见"。
package tools
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
core "agent-system-core"
)
const (
fsMaxRead = 1 << 20 // 单次读上限 1MB
fsMaxWrite = 2 << 20 // 单次写上限 2MB
fsMaxList = 200 // list 条数上限
)
// RegisterFSTools 注册隐藏 fs 工具(read/write/list)。root 由宿主注入(空=未配置,执行时报错)。
func RegisterFSTools(r *Registry) error {
if err := r.Register(core.ToolDesc{
Name: "fs_read_file", Description: "读取会话工作区内的文本文件(≤1MB)", Source: core.SourceBuiltin, Hidden: true, ReadOnly: true,
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"工作区内相对路径"}},"required":["path"]}`),
}, execFSRead); err != nil {
return err
}
if err := r.Register(core.ToolDesc{
Name: "fs_write_file", Description: "把文本写入会话工作区文件(≤2MB),并同步登记为可下载工件", Source: core.SourceBuiltin, Hidden: true, Edits: true, Risk: "medium",
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}`),
}, execFSWrite); err != nil {
return err
}
return r.Register(core.ToolDesc{
Name: "fs_list_dir", Description: "列出会话工作区目录(一层,≤200 条)", Source: core.SourceBuiltin, Hidden: true, ReadOnly: true,
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"工作区内相对路径,空=根"}}}`),
}, execFSList)
}
// fsSandbox 返回会话沙箱目录(owner/conversation 隔离);root 未配置报错。
func fsSandbox(rc ToolExecCtx) (string, error) {
root := rc.WorkspaceRoot()
if root == "" {
return "", fmt.Errorf("fs tools: workspace root not configured")
}
return filepath.Join(root, rc.OwnerID(), rc.ConversationID()), nil
}
// fsResolve 路径安全解析:拒绝绝对路径、反斜杠与 ..,规范化后必须仍在沙箱内
// (防穿越/逃逸;`\` 一律拒绝——Linux 上虽是合法字符,但会携带 Windows 语义并破坏工件命名)。
func fsResolve(sandbox, rel string) (string, error) {
rel = strings.TrimSpace(rel)
if rel == "" {
rel = "."
}
if filepath.IsAbs(rel) {
return "", fmt.Errorf("fs tools: absolute path not allowed: %s", rel)
}
if strings.ContainsRune(rel, '\\') {
return "", fmt.Errorf("fs tools: backslash not allowed in paths: %s", rel)
}
abs := filepath.Clean(filepath.Join(sandbox, rel))
if abs != sandbox && !strings.HasPrefix(abs, sandbox+string(filepath.Separator)) {
return "", fmt.Errorf("fs tools: path escapes workspace sandbox: %s", rel)
}
return abs, nil
}
func execFSRead(rc ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
Path string `json:"path"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", err
}
sandbox, err := fsSandbox(rc)
if err != nil {
return "", err
}
abs, err := fsResolve(sandbox, a.Path)
if err != nil {
return "", err
}
st, err := os.Stat(abs)
if err != nil {
return "", fmt.Errorf("fs_read_file: %w", err)
}
if st.IsDir() {
return "", fmt.Errorf("fs_read_file: %s 是目录(用 fs_list_dir)", a.Path)
}
if st.Size() > fsMaxRead {
return "", fmt.Errorf("fs_read_file: 文件超过 1MB 上限(%d 字节)", st.Size())
}
b, err := os.ReadFile(abs)
if err != nil {
return "", err
}
if stamps, ok := rc.(fileStamps); ok {
_ = stamps.NoteFileRead(abs)
}
return string(b), nil
}
type fileStamps interface {
NoteFileRead(path string) error
CheckFileWrite(path string) error
}
func execFSWrite(rc ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", err
}
if a.Path == "" {
return "", fmt.Errorf("fs_write_file: path required")
}
if err := rejectBinaryOfficeWrite(a.Path); err != nil {
return "", err
}
if len(a.Content) > fsMaxWrite {
return "", fmt.Errorf("fs_write_file: 内容超过 2MB 上限")
}
sandbox, err := fsSandbox(rc)
if err != nil {
return "", err
}
abs, err := fsResolve(sandbox, a.Path)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return "", err
}
// 符号链接逃逸防护:已存在文件若是指向沙箱外的软链则拒绝覆写。
if fi, err := os.Lstat(abs); err == nil && fi.Mode()&os.ModeSymlink != 0 {
if real, err := filepath.EvalSymlinks(abs); err != nil || !strings.HasPrefix(real, sandbox+string(filepath.Separator)) {
return "", fmt.Errorf("fs_write_file: refusing to follow symlink out of sandbox: %s", a.Path)
}
}
if _, err := os.Stat(abs); err == nil {
if stamps, ok := rc.(fileStamps); ok {
if err := stamps.CheckFileWrite(abs); err != nil {
return "", err
}
}
}
before, existed := fsBefore(abs)
if err := os.WriteFile(abs, []byte(a.Content), 0o644); err != nil {
return "", err
}
if cp, ok := rc.(interface {
NoteFileCheckpoint(rel string, before []byte, existed bool, after []byte) error
}); ok {
_ = cp.NoteFileCheckpoint(a.Path, before, existed, []byte(a.Content))
}
// 不变式:工作区文件 = 工件面板可见(内容同步入对象库,工件自包含可下载)。
base := filepath.Base(abs)
art, err := rc.WriteArtifact(base, "file", mimeOfExt(base), []byte(a.Content))
if err != nil {
return "", fmt.Errorf("fs_write_file: written %s but artifact registration failed: %w", abs, err)
}
return fmt.Sprintf("已写入 %s(%d 字节,工件 %s)", a.Path, len(a.Content), art.ID), nil
}
func fsBefore(abs string) ([]byte, bool) {
b, err := os.ReadFile(abs)
if err != nil {
return nil, false
}
return b, true
}
func execFSList(rc ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
Path string `json:"path"`
}
if len(args) > 0 {
if err := json.Unmarshal(args, &a); err != nil {
return "", err
}
}
sandbox, err := fsSandbox(rc)
if err != nil {
return "", err
}
abs, err := fsResolve(sandbox, a.Path)
if err != nil {
return "", err
}
ents, err := os.ReadDir(abs)
if err != nil {
return "", fmt.Errorf("fs_list_dir: %w", err)
}
sort.Slice(ents, func(i, j int) bool { return ents[i].Name() < ents[j].Name() })
var sb strings.Builder
n := 0
for _, e := range ents {
if n >= fsMaxList {
fmt.Fprintf(&sb, "…(超过 %d 条截断)\n", fsMaxList)
break
}
info, err := e.Info()
size := int64(-1)
if err == nil {
size = info.Size()
}
tag := "file"
if e.IsDir() {
tag = "dir"
}
fmt.Fprintf(&sb, "%s\t%s\t%d\n", tag, e.Name(), size)
n++
}
if sb.Len() == 0 {
return "(空目录)", nil
}
return sb.String(), nil
}
func mimeOfExt(name string) string {
switch strings.ToLower(filepath.Ext(name)) {
case ".md":
return "text/markdown"
case ".json":
return "application/json"
case ".csv":
return "text/csv"
case ".html":
return "text/html"
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
case ".pdf":
return "application/pdf"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
default:
return "text/plain; charset=utf-8"
}
}
// rejectBinaryOfficeWrite 拒绝把字符串直接写成伪二进制 Office/PDF。
// 带排版的文件由 run_python 生成后再 register_artifact 登记。
func rejectBinaryOfficeWrite(name string) error {
switch strings.ToLower(filepath.Ext(name)) {
case ".doc", ".docx", ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".pdf":
return fmt.Errorf("%s 是二进制文档扩展名,不能把字符串直接写成该文件。请用 run_python 生成到工作区后,用 register_artifact 登记", filepath.Ext(name))
default:
return nil
}
}