Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions adk/backend/local/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ type Config struct {
// Recommended for production use to prevent command injection
ValidateCommand func(string) error

// Optional: follow symlink directories during GlobInfo traversal.
FollowSymlinkDirsInGlob bool

// Optional: image/PDF/DPI limits for MultiModalRead.
// Zero/negative fields fall back to defaults; values above hard-caps are silently clamped.
MultiModalRead MultiModalReadConfig
Expand Down
3 changes: 3 additions & 0 deletions adk/backend/local/README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ type Config struct {
// 建议在生产环境中使用以防止命令注入
ValidateCommand func(string) error

// 可选:GlobInfo 遍历时跟随软链目录。
FollowSymlinkDirsInGlob bool

// 可选:MultiModalRead 的图片/PDF/DPI 限制。
// 字段为 0 或负数时使用默认值;超过硬上限时会被静默截断到上限。
MultiModalRead MultiModalReadConfig
Expand Down
161 changes: 134 additions & 27 deletions adk/backend/local/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ var errReadAllBytesTooLarge = errors.New("file exceeds max allowed size")
type Config struct {
ValidateCommand func(string) error

// FollowSymlinkDirsInGlob controls whether GlobInfo traverses symlink directories.
// Returned paths remain relative to the requested path and are not canonicalized.
FollowSymlinkDirsInGlob bool

// MultiModalRead overrides default size/page/DPI limits used by
// Local.MultiModalRead. Optional; zero-value fields fall back to
// package defaults (see MultiModalReadConfig field comments).
Expand All @@ -134,6 +138,8 @@ type Config struct {
type Local struct {
validateCommand func(string) error

followSymlinkDirsInGlob bool

// multiModalReadCfg carries already-resolved (defaults applied, hard-caps
// enforced) limits used by MultiModalRead. Every field is guaranteed > 0.
multiModalReadCfg MultiModalReadConfig
Expand Down Expand Up @@ -161,8 +167,9 @@ func NewBackend(_ context.Context, cfg *Config) (*Local, error) {
}

return &Local{
validateCommand: validateCommand,
multiModalReadCfg: resolveMultiModalReadConfig(cfg.MultiModalRead),
validateCommand: validateCommand,
followSymlinkDirsInGlob: cfg.FollowSymlinkDirsInGlob,
multiModalReadCfg: resolveMultiModalReadConfig(cfg.MultiModalRead),
}, nil
}

Expand Down Expand Up @@ -685,38 +692,45 @@ func (s *Local) GlobInfo(ctx context.Context, req *filesystem.GlobInfoRequest) (
path := filepath.Clean(req.Path)

var matches []string
err := filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
var err error
if s.followSymlinkDirsInGlob {
err = walkDirFollowSymlinks(ctx, path, req.Pattern, func(relPath string) {
matches = append(matches, relPath)
})
} else {
err = filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}

if err != nil {
if os.IsPermission(err) {
return filepath.SkipDir
if err != nil {
if os.IsPermission(err) {
return filepath.SkipDir
}
return err
}
return err
}

relPath, err := filepath.Rel(path, p)
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}
relPath, err := filepath.Rel(path, p)
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}

relPath = filepath.ToSlash(relPath)
relPath = filepath.ToSlash(relPath)

if relPath == "." {
return nil
}
if relPath == "." {
return nil
}

matched, _ := doublestar.Match(req.Pattern, relPath)
if matched {
matches = append(matches, relPath)
}
matched, _ := doublestar.Match(req.Pattern, relPath)
if matched {
matches = append(matches, relPath)
}

return nil
})
return nil
})
}

if err != nil {
return nil, fmt.Errorf("failed to walk directory: %w", err)
Expand All @@ -734,6 +748,99 @@ func (s *Local) GlobInfo(ctx context.Context, req *filesystem.GlobInfoRequest) (
return files, nil
}

func walkDirFollowSymlinks(ctx context.Context, root string, pattern string, onMatch func(string)) error {
realRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return err
}

ancestors := map[string]struct{}{
filepath.Clean(realRoot): {},
}

return walkDirFollowSymlinksRecursive(ctx, root, "", pattern, ancestors, onMatch)
}

func walkDirFollowSymlinksRecursive(ctx context.Context, dir string, relDir string, pattern string, ancestors map[string]struct{}, onMatch func(string)) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}

entries, err := os.ReadDir(dir)
if err != nil {
if os.IsPermission(err) {
return nil
}
return err
}

for _, entry := range entries {
select {
case <-ctx.Done():
return ctx.Err()
default:
}

childPath := filepath.Join(dir, entry.Name())
relPath := filepath.ToSlash(filepath.Join(relDir, entry.Name()))

matched, _ := doublestar.Match(pattern, relPath)
if matched {
onMatch(relPath)
}

if entry.IsDir() {
if err := walkChildDirFollowSymlinks(ctx, childPath, relPath, pattern, ancestors, onMatch); err != nil {
return err
}
continue
}

if entry.Type()&os.ModeSymlink == 0 {
continue
}

info, err := os.Stat(childPath)
if err != nil {
if os.IsNotExist(err) {
continue
}
return err
}
if !info.IsDir() {
continue
}

if err := walkChildDirFollowSymlinks(ctx, childPath, relPath, pattern, ancestors, onMatch); err != nil {
return err
}
}

return nil
}

func walkChildDirFollowSymlinks(ctx context.Context, dir string, relDir string, pattern string, ancestors map[string]struct{}, onMatch func(string)) error {
realDir, err := filepath.EvalSymlinks(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}

realDir = filepath.Clean(realDir)
if _, ok := ancestors[realDir]; ok {
return nil
}

ancestors[realDir] = struct{}{}
err = walkDirFollowSymlinksRecursive(ctx, dir, relDir, pattern, ancestors, onMatch)
delete(ancestors, realDir)
return err
}

func (s *Local) Write(ctx context.Context, req *filesystem.WriteRequest) error {
path := filepath.Clean(req.FilePath)

Expand Down
147 changes: 147 additions & 0 deletions adk/backend/local/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"

"github.com/cloudwego/eino/adk/filesystem"
"github.com/gen2brain/go-fitz"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func setupTestDir(t *testing.T) string {
Expand Down Expand Up @@ -313,6 +315,8 @@ func TestGlobInfo(t *testing.T) {
ctx := context.Background()
s, err := NewBackend(ctx, &Config{})
assert.NoError(t, err)
followSymlinkBackend, err := NewBackend(ctx, &Config{FollowSymlinkDirsInGlob: true})
assert.NoError(t, err)

t.Run("glob successfully", func(t *testing.T) {
dir := setupTestDir(t)
Expand Down Expand Up @@ -362,6 +366,149 @@ func TestGlobInfo(t *testing.T) {
assert.ElementsMatch(t, expected, actual)
})

t.Run("glob does not follow symlink directory by default", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink tests are not reliable on Windows")
}

dir := setupTestDir(t)
defer os.RemoveAll(dir)
targetRoot := setupTestDir(t)
defer os.RemoveAll(targetRoot)

target := filepath.Join(targetRoot, "linked-skill")
assert.NoError(t, os.MkdirAll(target, 0755))
assert.NoError(t, os.WriteFile(filepath.Join(target, "SKILL.md"), []byte(""), 0644))
assert.NoError(t, os.Symlink(target, filepath.Join(dir, "linked-skill")))

req := &filesystem.GlobInfoRequest{Path: dir, Pattern: "*/SKILL.md"}
files, err := s.GlobInfo(ctx, req)
assert.NoError(t, err)
assert.Empty(t, files)
})

t.Run("glob follows symlink directory when configured", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink tests are not reliable on Windows")
}

dir := setupTestDir(t)
defer os.RemoveAll(dir)
targetRoot := setupTestDir(t)
defer os.RemoveAll(targetRoot)

target := filepath.Join(targetRoot, "linked-skill")
assert.NoError(t, os.MkdirAll(target, 0755))
assert.NoError(t, os.WriteFile(filepath.Join(target, "SKILL.md"), []byte(""), 0644))
assert.NoError(t, os.Symlink(target, filepath.Join(dir, "linked-skill")))

req := &filesystem.GlobInfoRequest{Path: dir, Pattern: "*/SKILL.md"}
files, err := followSymlinkBackend.GlobInfo(ctx, req)
assert.NoError(t, err)
require.Len(t, files, 1)
assert.Equal(t, "linked-skill/SKILL.md", files[0].Path)
})

t.Run("glob follows symlink root when configured", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink tests are not reliable on Windows")
}

dir := setupTestDir(t)
defer os.RemoveAll(dir)
linkRoot := setupTestDir(t)
defer os.RemoveAll(linkRoot)

assert.NoError(t, os.MkdirAll(filepath.Join(dir, "skill"), 0755))
assert.NoError(t, os.WriteFile(filepath.Join(dir, "skill", "SKILL.md"), []byte(""), 0644))
linkPath := filepath.Join(linkRoot, "skills")
assert.NoError(t, os.Symlink(dir, linkPath))

req := &filesystem.GlobInfoRequest{Path: linkPath, Pattern: "*/SKILL.md"}
files, err := followSymlinkBackend.GlobInfo(ctx, req)
assert.NoError(t, err)
require.Len(t, files, 1)
assert.Equal(t, "skill/SKILL.md", files[0].Path)
})

t.Run("glob keeps symlink file matches", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink tests are not reliable on Windows")
}

dir := setupTestDir(t)
defer os.RemoveAll(dir)
targetRoot := setupTestDir(t)
defer os.RemoveAll(targetRoot)

assert.NoError(t, os.MkdirAll(filepath.Join(dir, "skill"), 0755))
targetFile := filepath.Join(targetRoot, "SKILL.md")
assert.NoError(t, os.WriteFile(targetFile, []byte(""), 0644))
assert.NoError(t, os.Symlink(targetFile, filepath.Join(dir, "skill", "SKILL.md")))

req := &filesystem.GlobInfoRequest{Path: dir, Pattern: "*/SKILL.md"}
files, err := followSymlinkBackend.GlobInfo(ctx, req)
assert.NoError(t, err)
require.Len(t, files, 1)
assert.Equal(t, "skill/SKILL.md", files[0].Path)
})

t.Run("glob skips broken symlink while following symlinks", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink tests are not reliable on Windows")
}

dir := setupTestDir(t)
defer os.RemoveAll(dir)
assert.NoError(t, os.Symlink(filepath.Join(dir, "missing"), filepath.Join(dir, "broken")))

req := &filesystem.GlobInfoRequest{Path: dir, Pattern: "*/SKILL.md"}
files, err := followSymlinkBackend.GlobInfo(ctx, req)
assert.NoError(t, err)
assert.Empty(t, files)
})

t.Run("glob avoids symlink cycles", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink tests are not reliable on Windows")
}

dir := setupTestDir(t)
defer os.RemoveAll(dir)

skillDir := filepath.Join(dir, "skill")
assert.NoError(t, os.MkdirAll(skillDir, 0755))
assert.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(""), 0644))
assert.NoError(t, os.Symlink(skillDir, filepath.Join(skillDir, "loop")))

req := &filesystem.GlobInfoRequest{Path: dir, Pattern: "**/SKILL.md"}
files, err := followSymlinkBackend.GlobInfo(ctx, req)
assert.NoError(t, err)
require.Len(t, files, 1)
assert.Equal(t, "skill/SKILL.md", files[0].Path)
})

t.Run("glob preserves alias and real directory paths", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink tests are not reliable on Windows")
}

dir := setupTestDir(t)
defer os.RemoveAll(dir)

target := filepath.Join(dir, "target")
assert.NoError(t, os.MkdirAll(target, 0755))
assert.NoError(t, os.WriteFile(filepath.Join(target, "SKILL.md"), []byte(""), 0644))
assert.NoError(t, os.Symlink(target, filepath.Join(dir, "alias")))

req := &filesystem.GlobInfoRequest{Path: dir, Pattern: "*/SKILL.md"}
files, err := followSymlinkBackend.GlobInfo(ctx, req)
assert.NoError(t, err)
require.Len(t, files, 2)
assert.Equal(t, "alias/SKILL.md", files[0].Path)
assert.Equal(t, "target/SKILL.md", files[1].Path)
})

t.Run("glob with question mark", func(t *testing.T) {
dir := setupTestDir(t)
defer os.RemoveAll(dir)
Expand Down
Loading