You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user