ci / vet-and-test (pull_request) Successful in 39s
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>
264 lines
8.3 KiB
Go
264 lines
8.3 KiB
Go
// 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
|
||
}
|
||
}
|