This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+8 -1
View File
@@ -43,9 +43,16 @@ type FsError struct {
type FsHandler struct {
Read *FsReadHandler
Write *FsWriteHandler
List *FsListHandler
Grep *GrepHandler
}
// NewFsHandler constructs a composite FsHandler with default sub-handlers.
func NewFsHandler() *FsHandler {
return &FsHandler{Read: NewFsReadHandler(), Write: NewFsWriteHandler()}
return &FsHandler{
Read: NewFsReadHandler(),
Write: NewFsWriteHandler(),
List: NewFsListHandler(),
Grep: NewGrepHandler(),
}
}
+88
View File
@@ -0,0 +1,88 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"io/fs"
"os"
"sort"
)
// DefaultListMaxEntries caps the number of directory entries returned to bound
// payload size for large directories.
const DefaultListMaxEntries = 1000
// FsListHandler handles fs/list: a bounded directory listing inside the worktree.
type FsListHandler struct {
MaxEntries int
}
// NewFsListHandler constructs an FsListHandler with default caps.
func NewFsListHandler() *FsListHandler { return &FsListHandler{MaxEntries: DefaultListMaxEntries} }
func (h *FsListHandler) maxEntries() int {
if h.MaxEntries > 0 {
return h.MaxEntries
}
return DefaultListMaxEntries
}
type fsListParams struct {
SessionID string `json:"sessionId"`
Path string `json:"path"`
}
type fsDirEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
}
type fsListResult struct {
Entries []fsDirEntry `json:"entries"`
Truncated bool `json:"truncated"`
}
// Handle lists the directory at sess.CwdPath/path. Returns FsError for path
// escape (-32001) or read failure. Results are sorted (dirs first, then name).
func (h *FsListHandler) Handle(_ context.Context, sess SessionContext, paramsJSON json.RawMessage) FsResult {
var p fsListParams
if err := json.Unmarshal(paramsJSON, &p); err != nil {
return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}}
}
abs, err := resolveSafePath(sess.CwdPath, p.Path)
if err != nil {
return FsResult{Err: &FsError{Code: -32001, Message: err.Error()}}
}
dirents, err := os.ReadDir(abs)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return FsResult{Err: &FsError{Code: -32603, Message: "directory not found"}}
}
return FsResult{Err: &FsError{Code: -32603, Message: "list: " + err.Error()}}
}
max := h.maxEntries()
out := fsListResult{Entries: make([]fsDirEntry, 0, len(dirents))}
for _, de := range dirents {
if len(out.Entries) >= max {
out.Truncated = true
break
}
e := fsDirEntry{Name: de.Name(), IsDir: de.IsDir()}
if info, ierr := de.Info(); ierr == nil && !de.IsDir() {
e.Size = info.Size()
}
out.Entries = append(out.Entries, e)
}
sort.SliceStable(out.Entries, func(i, j int) bool {
if out.Entries[i].IsDir != out.Entries[j].IsDir {
return out.Entries[i].IsDir // dirs first
}
return out.Entries[i].Name < out.Entries[j].Name
})
res, _ := json.Marshal(out)
return FsResult{OK: res}
}
@@ -0,0 +1,82 @@
package handlers_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
)
func TestFsList_HappyPath(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.txt"), []byte("hi"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(tmp, "sub"), 0o755))
h := handlers.NewFsListHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "path": "."}))
require.Nil(t, res.Err)
var out struct {
Entries []struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
} `json:"entries"`
Truncated bool `json:"truncated"`
}
require.NoError(t, json.Unmarshal(res.OK, &out))
require.Len(t, out.Entries, 2)
// Dirs sort first.
assert.Equal(t, "sub", out.Entries[0].Name)
assert.True(t, out.Entries[0].IsDir)
assert.Equal(t, "a.txt", out.Entries[1].Name)
assert.Equal(t, int64(2), out.Entries[1].Size)
}
func TestFsList_PathOutsideWorktree(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
h := handlers.NewFsListHandler()
sess := handlers.SessionContext{CwdPath: tmp}
outside := filepath.Join(filepath.VolumeName(tmp)+string(filepath.Separator), "etc")
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "path": outside}))
require.NotNil(t, res.Err)
assert.Equal(t, -32001, res.Err.Code)
}
func TestFsList_NotFound(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
h := handlers.NewFsListHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "path": "missing"}))
require.NotNil(t, res.Err)
assert.Equal(t, -32603, res.Err.Code)
}
func TestFsList_MaxEntriesCap(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
for i := 0; i < 5; i++ {
require.NoError(t, os.WriteFile(filepath.Join(tmp, string(rune('a'+i))+".txt"), []byte("x"), 0o644))
}
h := &handlers.FsListHandler{MaxEntries: 2}
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "path": "."}))
require.Nil(t, res.Err)
var out struct {
Entries []any `json:"entries"`
Truncated bool `json:"truncated"`
}
require.NoError(t, json.Unmarshal(res.OK, &out))
require.Len(t, out.Entries, 2)
require.True(t, out.Truncated)
}
+209
View File
@@ -0,0 +1,209 @@
package handlers
import (
"bufio"
"context"
"encoding/json"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
)
// Grep caps. These bound the cost of a single grep request to avoid a DoS via a
// crafted pattern / huge tree. All are overridable on the handler.
const (
DefaultGrepMaxMatches = 200
DefaultGrepMaxFiles = 5000
DefaultGrepMaxFileSize = 1 << 20 // 1 MiB: skip larger files
DefaultGrepMaxLineLen = 1000 // truncate long matched lines
)
// GrepHandler handles fs/grep: a bounded regexp search under the worktree using a
// pure-Go walk (no shell, no git grep interpolation) confined to the pathsafe
// root. Binary files and oversize files are skipped.
type GrepHandler struct {
MaxMatches int
MaxFiles int
MaxFileSize int64
}
// NewGrepHandler constructs a GrepHandler with default caps.
func NewGrepHandler() *GrepHandler {
return &GrepHandler{
MaxMatches: DefaultGrepMaxMatches,
MaxFiles: DefaultGrepMaxFiles,
MaxFileSize: DefaultGrepMaxFileSize,
}
}
func (h *GrepHandler) maxMatches() int {
if h.MaxMatches > 0 {
return h.MaxMatches
}
return DefaultGrepMaxMatches
}
func (h *GrepHandler) maxFiles() int {
if h.MaxFiles > 0 {
return h.MaxFiles
}
return DefaultGrepMaxFiles
}
func (h *GrepHandler) maxFileSize() int64 {
if h.MaxFileSize > 0 {
return h.MaxFileSize
}
return DefaultGrepMaxFileSize
}
type fsGrepParams struct {
SessionID string `json:"sessionId"`
Pattern string `json:"pattern"`
Path string `json:"path,omitempty"` // sub-path to scope the search; defaults to cwd root
CaseSensitive bool `json:"case_sensitive,omitempty"`
}
type grepMatch struct {
File string `json:"file"` // worktree-relative
Line int `json:"line"` // 1-based
Text string `json:"text"`
}
type fsGrepResult struct {
Matches []grepMatch `json:"matches"`
Truncated bool `json:"truncated"`
}
// Handle runs a regexp search. Returns FsError for path escape (-32001), invalid
// pattern (-32602), or read failure. Match/file caps are honored.
func (h *GrepHandler) Handle(ctx context.Context, sess SessionContext, paramsJSON json.RawMessage) FsResult {
var p fsGrepParams
if err := json.Unmarshal(paramsJSON, &p); err != nil {
return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}}
}
if strings.TrimSpace(p.Pattern) == "" {
return FsResult{Err: &FsError{Code: -32602, Message: "pattern required"}}
}
expr := p.Pattern
if !p.CaseSensitive {
expr = "(?i)" + expr
}
re, err := regexp.Compile(expr)
if err != nil {
return FsResult{Err: &FsError{Code: -32602, Message: "invalid pattern: " + err.Error()}}
}
searchPath := p.Path
if searchPath == "" {
searchPath = "."
}
root, err := resolveSafePath(sess.CwdPath, searchPath)
if err != nil {
return FsResult{Err: &FsError{Code: -32001, Message: err.Error()}}
}
cwd := filepath.Clean(sess.CwdPath)
out := fsGrepResult{Matches: make([]grepMatch, 0, 16)}
maxMatches := h.maxMatches()
maxFiles := h.maxFiles()
maxSize := h.maxFileSize()
filesScanned := 0
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error {
if werr != nil {
return nil // skip unreadable entries
}
select {
case <-ctx.Done():
return filepath.SkipAll
default:
}
if d.IsDir() {
if isSkippedDir(d.Name()) {
return filepath.SkipDir
}
return nil
}
if filesScanned >= maxFiles {
out.Truncated = true
return filepath.SkipAll
}
info, ierr := d.Info()
if ierr != nil || info.Size() > maxSize {
return nil
}
filesScanned++
rel, rerr := filepath.Rel(cwd, path)
if rerr != nil {
rel = path
}
rel = filepath.ToSlash(rel)
if grepFile(path, rel, re, &out, maxMatches) {
out.Truncated = true
return filepath.SkipAll
}
return nil
})
if walkErr != nil && walkErr != filepath.SkipAll {
return FsResult{Err: &FsError{Code: -32603, Message: "grep walk: " + walkErr.Error()}}
}
res, _ := json.Marshal(out)
return FsResult{OK: res}
}
// grepFile scans one file line-by-line. Returns true when the global match cap
// was reached (caller should stop the walk).
func grepFile(abs, rel string, re *regexp.Regexp, out *fsGrepResult, maxMatches int) bool {
f, err := os.Open(abs)
if err != nil {
return false
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
lineNo := 0
for sc.Scan() {
lineNo++
line := sc.Bytes()
// Skip binary content (NUL in line).
if hasNUL(line) {
return false
}
if re.Match(line) {
text := string(line)
if len(text) > DefaultGrepMaxLineLen {
text = text[:DefaultGrepMaxLineLen] + "...[truncated]"
}
out.Matches = append(out.Matches, grepMatch{File: rel, Line: lineNo, Text: text})
if len(out.Matches) >= maxMatches {
return true
}
}
}
return false
}
func hasNUL(b []byte) bool {
for _, c := range b {
if c == 0 {
return true
}
}
return false
}
// skippedDirs are never descended into during grep.
var skippedDirs = map[string]struct{}{
".git": {}, "node_modules": {}, "vendor": {}, "dist": {}, "build": {},
".venv": {}, "__pycache__": {}, "third_party": {},
}
func isSkippedDir(name string) bool {
_, ok := skippedDirs[name]
return ok
}
+125
View File
@@ -0,0 +1,125 @@
package handlers_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
)
func TestGrep_HappyPath(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.go"), []byte("package a\n\nfunc Foo() {}\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(tmp, "b.go"), []byte("package b\n\nfunc Bar() {}\n"), 0o644))
h := handlers.NewGrepHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "func Foo"}))
require.Nil(t, res.Err)
var out struct {
Matches []struct {
File string `json:"file"`
Line int `json:"line"`
Text string `json:"text"`
} `json:"matches"`
Truncated bool `json:"truncated"`
}
require.NoError(t, json.Unmarshal(res.OK, &out))
require.Len(t, out.Matches, 1)
assert.Equal(t, "a.go", out.Matches[0].File)
assert.Equal(t, 3, out.Matches[0].Line)
assert.Contains(t, out.Matches[0].Text, "func Foo")
}
func TestGrep_CaseInsensitiveByDefault(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.go"), []byte("HELLO world\n"), 0o644))
h := handlers.NewGrepHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "hello"}))
require.Nil(t, res.Err)
var out struct {
Matches []any `json:"matches"`
}
require.NoError(t, json.Unmarshal(res.OK, &out))
require.Len(t, out.Matches, 1)
}
func TestGrep_PathOutsideWorktree(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
h := handlers.NewGrepHandler()
sess := handlers.SessionContext{CwdPath: tmp}
outside := filepath.Join(filepath.VolumeName(tmp)+string(filepath.Separator), "etc")
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "x", "path": outside}))
require.NotNil(t, res.Err)
assert.Equal(t, -32001, res.Err.Code)
}
func TestGrep_InvalidPattern(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
h := handlers.NewGrepHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "[invalid("}))
require.NotNil(t, res.Err)
assert.Equal(t, -32602, res.Err.Code)
}
func TestGrep_EmptyPattern(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
h := handlers.NewGrepHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": ""}))
require.NotNil(t, res.Err)
assert.Equal(t, -32602, res.Err.Code)
}
func TestGrep_MaxMatchesCap(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
// 10 matching lines; cap at 3.
content := "m\nm\nm\nm\nm\nm\nm\nm\nm\nm\n"
require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.txt"), []byte(content), 0o644))
h := &handlers.GrepHandler{MaxMatches: 3, MaxFiles: 100, MaxFileSize: 1 << 20}
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "m"}))
require.Nil(t, res.Err)
var out struct {
Matches []any `json:"matches"`
Truncated bool `json:"truncated"`
}
require.NoError(t, json.Unmarshal(res.OK, &out))
require.Len(t, out.Matches, 3)
require.True(t, out.Truncated)
}
func TestGrep_SkipsGitDir(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".git"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(tmp, ".git", "config"), []byte("secret pattern\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.txt"), []byte("normal pattern\n"), 0o644))
h := handlers.NewGrepHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "pattern"}))
require.Nil(t, res.Err)
var out struct {
Matches []struct {
File string `json:"file"`
} `json:"matches"`
}
require.NoError(t, json.Unmarshal(res.OK, &out))
require.Len(t, out.Matches, 1)
assert.Equal(t, "a.txt", out.Matches[0].File)
}