diff --git a/adk/backend/local/README.md b/adk/backend/local/README.md index 5f8c14e5f..2919568cb 100644 --- a/adk/backend/local/README.md +++ b/adk/backend/local/README.md @@ -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 diff --git a/adk/backend/local/README_zh.md b/adk/backend/local/README_zh.md index 344f47db2..a9db7bbca 100644 --- a/adk/backend/local/README_zh.md +++ b/adk/backend/local/README_zh.md @@ -64,6 +64,9 @@ 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 23a096048..890ff71df 100644 --- a/adk/backend/local/local.go +++ b/adk/backend/local/local.go @@ -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). @@ -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 @@ -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 } @@ -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) @@ -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) diff --git a/adk/backend/local/local_test.go b/adk/backend/local/local_test.go index 62ea4d8ef..e51f5b915 100644 --- a/adk/backend/local/local_test.go +++ b/adk/backend/local/local_test.go @@ -23,6 +23,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -30,6 +31,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 { @@ -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) @@ -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)