From 2498693f336ef8324d0fef79d80aeef8e6ffe3c7 Mon Sep 17 00:00:00 2001 From: "ark-hand[bot]" Date: Wed, 14 Oct 2026 07:19:59 +0000 Subject: [PATCH] fix(selfHosted): support file path aliases Sync-Source-Commit: 693137c1606af176f0710b80678631075c36be77 Hand-Written-Reason: No Ark-APIs provenance marker; treated as a hand-written source commit. Release-Version: 0.12.0 --- arkruntime/toolset/file.go | 78 +++++++++++++++++------------- arkruntime/toolset/toolset_test.go | 67 +++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 33 deletions(-) diff --git a/arkruntime/toolset/file.go b/arkruntime/toolset/file.go index 8e1edb7..5b01577 100644 --- a/arkruntime/toolset/file.go +++ b/arkruntime/toolset/file.go @@ -29,13 +29,34 @@ type ReadTool struct { limits Limits } +type filePathRequest struct { + FilePath string `json:"file_path"` + Path string `json:"path"` + File string `json:"file"` +} + +func (r filePathRequest) resolvedPath() (string, error) { + path := "" + for _, candidate := range []string{r.FilePath, r.Path, r.File} { + if candidate == "" { + continue + } + if path != "" && path != candidate { + return "", errors.New("file_path, path, and file must not conflict") + } + path = candidate + } + if path == "" { + return "", errors.New("file_path is required") + } + return path, nil +} + type readRequest struct { - FilePath string `json:"file_path"` - Path string `json:"path"` - File string `json:"file"` - ViewRange []int `json:"view_range,omitempty"` - Offset *int `json:"offset,omitempty"` - Limit *int `json:"limit,omitempty"` + filePathRequest + ViewRange []int `json:"view_range,omitempty"` + Offset *int `json:"offset,omitempty"` + Limit *int `json:"limit,omitempty"` } // NewReadTool 创建 read 工具。 @@ -193,23 +214,6 @@ func (t *ReadTool) Execute(ctx context.Context, input json.RawMessage) Result { return TextResult(b.String()) } -func (r readRequest) resolvedPath() (string, error) { - path := "" - for _, candidate := range []string{r.FilePath, r.Path, r.File} { - if candidate == "" { - continue - } - if path != "" && path != candidate { - return "", errors.New("file_path, path, and file must not conflict") - } - path = candidate - } - if path == "" { - return "", errors.New("file_path is required") - } - return path, nil -} - func detectReadMedia(path string) (string, string, error) { f, err := os.Open(path) if err != nil { @@ -266,8 +270,8 @@ func (t *WriteTool) Name() string { return "write" } // Execute 执行 write。 func (t *WriteTool) Execute(_ context.Context, input json.RawMessage) Result { var req struct { - FilePath string `json:"file_path"` - Content string `json:"content"` + filePathRequest + Content string `json:"content"` } if err := decodeInput(input, &req); err != nil { return ErrorResult(err.Error()) @@ -275,14 +279,18 @@ func (t *WriteTool) Execute(_ context.Context, input json.RawMessage) Result { if t.limits.MaxInputFileBytes > 0 && int64(len(req.Content)) > t.limits.MaxInputFileBytes { return ErrorResult(fmt.Sprintf("content too large: %d bytes", len(req.Content))) } - host, err := t.resolver.ResolveForWrite(req.FilePath) + path, err := req.resolvedPath() + if err != nil { + return ErrorResult(err.Error()) + } + host, err := t.resolver.ResolveForWrite(path) if err != nil { return ErrorResult(err.Error()) } if err := os.MkdirAll(filepath.Dir(host), 0o755); err != nil { return ErrorResult(err.Error()) } - verified, err := t.resolver.ResolveForWrite(req.FilePath) + verified, err := t.resolver.ResolveForWrite(path) if err != nil { return ErrorResult(err.Error()) } @@ -292,7 +300,7 @@ func (t *WriteTool) Execute(_ context.Context, input json.RawMessage) Result { if err := writeFileAtomically(host, []byte(req.Content), 0o600); err != nil { return ErrorResult(err.Error()) } - return TextResult(fmt.Sprintf("wrote %d bytes to %s", len(req.Content), req.FilePath)) + return TextResult(fmt.Sprintf("wrote %d bytes to %s", len(req.Content), path)) } // EditTool 实现 edit 工具。 @@ -312,7 +320,7 @@ func (t *EditTool) Name() string { return "edit" } // Execute 执行 edit。 func (t *EditTool) Execute(_ context.Context, input json.RawMessage) Result { var req struct { - FilePath string `json:"file_path"` + filePathRequest OldString string `json:"old_string"` NewString string `json:"new_string"` ReplaceAll bool `json:"replace_all,omitempty"` @@ -323,11 +331,15 @@ func (t *EditTool) Execute(_ context.Context, input json.RawMessage) Result { if req.OldString == "" { return ErrorResult("old_string must not be empty") } - host, err := t.resolver.ResolveExisting(req.FilePath) + path, err := req.resolvedPath() + if err != nil { + return ErrorResult(err.Error()) + } + host, err := t.resolver.ResolveExisting(path) if err != nil { return ErrorResult(err.Error()) } - writable, err := t.resolver.ResolveForWrite(req.FilePath) + writable, err := t.resolver.ResolveForWrite(path) if err != nil { return ErrorResult(err.Error()) } @@ -364,7 +376,7 @@ func (t *EditTool) Execute(_ context.Context, input json.RawMessage) Result { if int64(len(next)) > t.limits.MaxInputFileBytes && t.limits.MaxInputFileBytes > 0 { return ErrorResult(fmt.Sprintf("edited file too large: %d bytes", len(next))) } - verified, err := t.resolver.ResolveForWrite(req.FilePath) + verified, err := t.resolver.ResolveForWrite(path) if err != nil { return ErrorResult(err.Error()) } @@ -374,7 +386,7 @@ func (t *EditTool) Execute(_ context.Context, input json.RawMessage) Result { if err := writeFileAtomically(host, []byte(next), info.Mode().Perm()); err != nil { return ErrorResult(err.Error()) } - return TextResult(fmt.Sprintf("replaced %d occurrence(s) in %s", count, req.FilePath)) + return TextResult(fmt.Sprintf("replaced %d occurrence(s) in %s", count, path)) } func writeFileAtomically(target string, data []byte, mode os.FileMode) error { diff --git a/arkruntime/toolset/toolset_test.go b/arkruntime/toolset/toolset_test.go index 60d02ca..56972b3 100644 --- a/arkruntime/toolset/toolset_test.go +++ b/arkruntime/toolset/toolset_test.go @@ -5,6 +5,7 @@ package toolset import ( "context" "encoding/base64" + "fmt" "os" "path/filepath" "strings" @@ -277,6 +278,72 @@ func TestReadKeepsLegacyPathAliases(t *testing.T) { } } +func TestFileToolsAcceptPathAliases(t *testing.T) { + for _, key := range []string{"file_path", "path", "file"} { + t.Run(key, func(t *testing.T) { + root := t.TempDir() + set, err := NewDefault(Options{Workdir: root}) + if err != nil { + t.Fatal(err) + } + defer set.Close() + + writeInput := fmt.Sprintf(`{%q:"example.txt","content":"old"}`, key) + if res := set.Execute(context.Background(), "write", []byte(writeInput)); res.IsError { + t.Fatalf("write(%s) result = %+v", writeInput, res) + } + readInput := fmt.Sprintf(`{%q:"example.txt"}`, key) + if res := set.Execute(context.Background(), "read", []byte(readInput)); res.IsError { + t.Fatalf("read(%s) result = %+v", readInput, res) + } + editInput := fmt.Sprintf( + `{%q:"example.txt","old_string":"old","new_string":"new"}`, + key, + ) + if res := set.Execute(context.Background(), "edit", []byte(editInput)); res.IsError { + t.Fatalf("edit(%s) result = %+v", editInput, res) + } + data, err := os.ReadFile(filepath.Join(root, "example.txt")) + if err != nil || string(data) != "new" { + t.Fatalf("edited data=%q err=%v", data, err) + } + }) + } +} + +func TestFileToolsValidatePathAliases(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "example.txt"), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + set, err := NewDefault(Options{Workdir: root}) + if err != nil { + t.Fatal(err) + } + defer set.Close() + + for _, tool := range []string{"read", "write", "edit"} { + same := set.Execute(context.Background(), tool, []byte( + `{"file_path":"example.txt","path":"example.txt","file":"example.txt","content":"old","old_string":"old","new_string":"new"}`, + )) + if same.IsError { + t.Fatalf("%s with matching aliases result = %+v", tool, same) + } + conflict := set.Execute(context.Background(), tool, []byte( + `{"file_path":"example.txt","path":"other.txt","content":"new","old_string":"old","new_string":"new"}`, + )) + if !conflict.IsError || conflict.Content[0].Text != "file_path, path, and file must not conflict" { + t.Fatalf("%s conflicting aliases result = %+v", tool, conflict) + } + missing := set.Execute(context.Background(), tool, []byte( + `{"content":"new","old_string":"old","new_string":"new"}`, + )) + if !missing.IsError || missing.Content[0].Text != "file_path is required" { + t.Fatalf("%s missing path result = %+v", tool, missing) + } + } +} + func TestReadReturnsImageAsBase64ContentBlock(t *testing.T) { root := t.TempDir() data := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 300<<10)...)