From fc83a07841f2e6be6d148a89e1e2d60ae157b855 Mon Sep 17 00:00:00 2001 From: tangchaojun Date: Wed, 3 Jun 2026 11:15:23 +0800 Subject: [PATCH 1/3] fix(local): follow symlink dirs in glob when requested --- adk/backend/local/local.go | 161 ++++++++++++++++++++++++++----- adk/backend/local/local_test.go | 162 ++++++++++++++++++++++++++++++++ 2 files changed, 298 insertions(+), 25 deletions(-) diff --git a/adk/backend/local/local.go b/adk/backend/local/local.go index 23a096048..ff7f4bf48 100644 --- a/adk/backend/local/local.go +++ b/adk/backend/local/local.go @@ -27,6 +27,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "runtime/debug" "sort" "strconv" @@ -685,38 +686,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 shouldFollowSymlinks(req) { + 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) @@ -734,6 +742,109 @@ func (s *Local) GlobInfo(ctx context.Context, req *filesystem.GlobInfoRequest) ( return files, nil } +func shouldFollowSymlinks(req *filesystem.GlobInfoRequest) bool { + if req == nil { + return false + } + + v := reflect.ValueOf(req).Elem() + field := v.FieldByName("FollowSymlinks") + return field.IsValid() && field.Kind() == reflect.Bool && field.Bool() +} + +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) diff --git a/adk/backend/local/local_test.go b/adk/backend/local/local_test.go index 62ea4d8ef..6f43467b9 100644 --- a/adk/backend/local/local_test.go +++ b/adk/backend/local/local_test.go @@ -23,6 +23,8 @@ import ( "fmt" "os" "path/filepath" + "reflect" + "runtime" "strings" "testing" "time" @@ -30,6 +32,7 @@ import ( "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 { @@ -38,6 +41,16 @@ func setupTestDir(t *testing.T) string { return dir } +func enableFollowSymlinksForTest(t *testing.T, req *filesystem.GlobInfoRequest) { + t.Helper() + + field := reflect.ValueOf(req).Elem().FieldByName("FollowSymlinks") + if !field.IsValid() { + t.Skip("requires github.com/cloudwego/eino with GlobInfoRequest.FollowSymlinks") + } + field.SetBool(true) +} + func TestLsInfo(t *testing.T) { ctx := context.Background() s, err := NewBackend(ctx, &Config{}) @@ -362,6 +375,155 @@ 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 requested", 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"} + enableFollowSymlinksForTest(t, req) + files, err := s.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 requested", 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"} + enableFollowSymlinksForTest(t, req) + files, err := s.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"} + enableFollowSymlinksForTest(t, req) + files, err := s.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"} + enableFollowSymlinksForTest(t, req) + files, err := s.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"} + enableFollowSymlinksForTest(t, req) + files, err := s.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"} + enableFollowSymlinksForTest(t, req) + files, err := s.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) From f1d7447a36dff9f4629bd5ef241f3ae4ad3e4af2 Mon Sep 17 00:00:00 2001 From: tangchaojun Date: Wed, 3 Jun 2026 15:47:43 +0800 Subject: [PATCH 2/3] refactor(local): configure symlink glob traversal --- adk/backend/local/README.md | 4 ++++ adk/backend/local/README_zh.md | 4 ++++ adk/backend/local/local.go | 24 ++++++++++------------ adk/backend/local/local_test.go | 35 ++++++++++----------------------- 4 files changed, 28 insertions(+), 39 deletions(-) diff --git a/adk/backend/local/README.md b/adk/backend/local/README.md index 5f8c14e5f..41ce9571b 100644 --- a/adk/backend/local/README.md +++ b/adk/backend/local/README.md @@ -64,6 +64,10 @@ type Config struct { // Recommended for production use to prevent command injection ValidateCommand func(string) error + // Optional: follow symlink directories during GlobInfo traversal. + // Returned paths stay relative to the requested path. + 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 diff --git a/adk/backend/local/README_zh.md b/adk/backend/local/README_zh.md index 344f47db2..4b1b32804 100644 --- a/adk/backend/local/README_zh.md +++ b/adk/backend/local/README_zh.md @@ -64,6 +64,10 @@ type Config struct { // 建议在生产环境中使用以防止命令注入 ValidateCommand func(string) error + // 可选:GlobInfo 遍历时跟随软链目录。 + // 返回路径仍保持为相对请求路径的逻辑路径。 + FollowSymlinkDirsInGlob bool + // 可选:MultiModalRead 的图片/PDF/DPI 限制。 // 字段为 0 或负数时使用默认值;超过硬上限时会被静默截断到上限。 MultiModalRead MultiModalReadConfig diff --git a/adk/backend/local/local.go b/adk/backend/local/local.go index ff7f4bf48..890ff71df 100644 --- a/adk/backend/local/local.go +++ b/adk/backend/local/local.go @@ -27,7 +27,6 @@ import ( "os" "os/exec" "path/filepath" - "reflect" "runtime/debug" "sort" "strconv" @@ -126,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). @@ -135,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 @@ -162,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 } @@ -687,7 +693,7 @@ func (s *Local) GlobInfo(ctx context.Context, req *filesystem.GlobInfoRequest) ( var matches []string var err error - if shouldFollowSymlinks(req) { + if s.followSymlinkDirsInGlob { err = walkDirFollowSymlinks(ctx, path, req.Pattern, func(relPath string) { matches = append(matches, relPath) }) @@ -742,16 +748,6 @@ func (s *Local) GlobInfo(ctx context.Context, req *filesystem.GlobInfoRequest) ( return files, nil } -func shouldFollowSymlinks(req *filesystem.GlobInfoRequest) bool { - if req == nil { - return false - } - - v := reflect.ValueOf(req).Elem() - field := v.FieldByName("FollowSymlinks") - return field.IsValid() && field.Kind() == reflect.Bool && field.Bool() -} - func walkDirFollowSymlinks(ctx context.Context, root string, pattern string, onMatch func(string)) error { realRoot, err := filepath.EvalSymlinks(root) if err != nil { diff --git a/adk/backend/local/local_test.go b/adk/backend/local/local_test.go index 6f43467b9..e51f5b915 100644 --- a/adk/backend/local/local_test.go +++ b/adk/backend/local/local_test.go @@ -23,7 +23,6 @@ import ( "fmt" "os" "path/filepath" - "reflect" "runtime" "strings" "testing" @@ -41,16 +40,6 @@ func setupTestDir(t *testing.T) string { return dir } -func enableFollowSymlinksForTest(t *testing.T, req *filesystem.GlobInfoRequest) { - t.Helper() - - field := reflect.ValueOf(req).Elem().FieldByName("FollowSymlinks") - if !field.IsValid() { - t.Skip("requires github.com/cloudwego/eino with GlobInfoRequest.FollowSymlinks") - } - field.SetBool(true) -} - func TestLsInfo(t *testing.T) { ctx := context.Background() s, err := NewBackend(ctx, &Config{}) @@ -326,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) @@ -396,7 +387,7 @@ func TestGlobInfo(t *testing.T) { assert.Empty(t, files) }) - t.Run("glob follows symlink directory when requested", func(t *testing.T) { + 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") } @@ -412,14 +403,13 @@ func TestGlobInfo(t *testing.T) { assert.NoError(t, os.Symlink(target, filepath.Join(dir, "linked-skill"))) req := &filesystem.GlobInfoRequest{Path: dir, Pattern: "*/SKILL.md"} - enableFollowSymlinksForTest(t, req) - files, err := s.GlobInfo(ctx, req) + 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 requested", func(t *testing.T) { + 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") } @@ -435,8 +425,7 @@ func TestGlobInfo(t *testing.T) { assert.NoError(t, os.Symlink(dir, linkPath)) req := &filesystem.GlobInfoRequest{Path: linkPath, Pattern: "*/SKILL.md"} - enableFollowSymlinksForTest(t, req) - files, err := s.GlobInfo(ctx, req) + files, err := followSymlinkBackend.GlobInfo(ctx, req) assert.NoError(t, err) require.Len(t, files, 1) assert.Equal(t, "skill/SKILL.md", files[0].Path) @@ -458,8 +447,7 @@ func TestGlobInfo(t *testing.T) { assert.NoError(t, os.Symlink(targetFile, filepath.Join(dir, "skill", "SKILL.md"))) req := &filesystem.GlobInfoRequest{Path: dir, Pattern: "*/SKILL.md"} - enableFollowSymlinksForTest(t, req) - files, err := s.GlobInfo(ctx, req) + files, err := followSymlinkBackend.GlobInfo(ctx, req) assert.NoError(t, err) require.Len(t, files, 1) assert.Equal(t, "skill/SKILL.md", files[0].Path) @@ -475,8 +463,7 @@ func TestGlobInfo(t *testing.T) { assert.NoError(t, os.Symlink(filepath.Join(dir, "missing"), filepath.Join(dir, "broken"))) req := &filesystem.GlobInfoRequest{Path: dir, Pattern: "*/SKILL.md"} - enableFollowSymlinksForTest(t, req) - files, err := s.GlobInfo(ctx, req) + files, err := followSymlinkBackend.GlobInfo(ctx, req) assert.NoError(t, err) assert.Empty(t, files) }) @@ -495,8 +482,7 @@ func TestGlobInfo(t *testing.T) { assert.NoError(t, os.Symlink(skillDir, filepath.Join(skillDir, "loop"))) req := &filesystem.GlobInfoRequest{Path: dir, Pattern: "**/SKILL.md"} - enableFollowSymlinksForTest(t, req) - files, err := s.GlobInfo(ctx, req) + files, err := followSymlinkBackend.GlobInfo(ctx, req) assert.NoError(t, err) require.Len(t, files, 1) assert.Equal(t, "skill/SKILL.md", files[0].Path) @@ -516,8 +502,7 @@ func TestGlobInfo(t *testing.T) { assert.NoError(t, os.Symlink(target, filepath.Join(dir, "alias"))) req := &filesystem.GlobInfoRequest{Path: dir, Pattern: "*/SKILL.md"} - enableFollowSymlinksForTest(t, req) - files, err := s.GlobInfo(ctx, req) + files, err := followSymlinkBackend.GlobInfo(ctx, req) assert.NoError(t, err) require.Len(t, files, 2) assert.Equal(t, "alias/SKILL.md", files[0].Path) From c857b6d4e32e44c7158635463bef6f01ae3565e5 Mon Sep 17 00:00:00 2001 From: tangchaojun Date: Wed, 3 Jun 2026 16:26:49 +0800 Subject: [PATCH 3/3] docs(local): trim symlink glob description --- adk/backend/local/README.md | 1 - adk/backend/local/README_zh.md | 1 - 2 files changed, 2 deletions(-) diff --git a/adk/backend/local/README.md b/adk/backend/local/README.md index 41ce9571b..2919568cb 100644 --- a/adk/backend/local/README.md +++ b/adk/backend/local/README.md @@ -65,7 +65,6 @@ type Config struct { ValidateCommand func(string) error // Optional: follow symlink directories during GlobInfo traversal. - // Returned paths stay relative to the requested path. FollowSymlinkDirsInGlob bool // Optional: image/PDF/DPI limits for MultiModalRead. diff --git a/adk/backend/local/README_zh.md b/adk/backend/local/README_zh.md index 4b1b32804..a9db7bbca 100644 --- a/adk/backend/local/README_zh.md +++ b/adk/backend/local/README_zh.md @@ -65,7 +65,6 @@ type Config struct { ValidateCommand func(string) error // 可选:GlobInfo 遍历时跟随软链目录。 - // 返回路径仍保持为相对请求路径的逻辑路径。 FollowSymlinkDirsInGlob bool // 可选:MultiModalRead 的图片/PDF/DPI 限制。