Ignore cyclic skill links on Windows
- commit
- 00f04d7af3ec0c9194ffb7d34bc8280fdbc3760a
- author
- patriceckhart · · 2026-09-28 04:37 UTC
- parent
- f9a6e9f
- signature
- unsigned
2 files · +23 -1
M packages/agent/skills/skills.go +9 -1
| @@ -33,6 +33,7 @@ import ( | |||
| 33 | 33 | "io/fs" | |
| 34 | 34 | "os" | |
| 35 | 35 | "path/filepath" | |
| 36 | + | "runtime" | |
| 36 | 37 | "sort" | |
| 37 | 38 | "strings" | |
| 38 | 39 | "syscall" | |
| @@ -248,7 +249,7 @@ func scanSource(source Source) ([]*Skill, []error) { | |||
| 248 | 249 | if statErr != nil { | |
| 249 | 250 | // Dangling and cyclic links are not skills. Anything | |
| 250 | 251 | // else (permissions, I/O) is worth reporting. | |
| 251 | - | if !errors.Is(statErr, fs.ErrNotExist) && !errors.Is(statErr, syscall.ELOOP) { | |
| 252 | + | if !errors.Is(statErr, fs.ErrNotExist) && !symlinkLoopError(statErr) { | |
| 252 | 253 | errs = append(errs, fmt.Errorf("%s: %w", path, statErr)) | |
| 253 | 254 | } | |
| 254 | 255 | continue | |
| @@ -287,6 +288,13 @@ func scanSource(source Source) ([]*Skill, []error) { | |||
| 287 | 288 | return out, errs | |
| 288 | 289 | } | |
| 289 | 290 | ||
| 291 | + | func symlinkLoopError(err error) bool { | |
| 292 | + | // Windows reports cyclic links as ERROR_CANT_RESOLVE_FILENAME (1921), | |
| 293 | + | // rather than ELOOP. syscall does not export a name for this error. | |
| 294 | + | return errors.Is(err, syscall.ELOOP) || | |
| 295 | + | (runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(1921))) | |
| 296 | + | } | |
| 297 | + | ||
| 290 | 298 | // deriveName builds a skill name for a SKILL.md file that carries no | |
| 291 | 299 | // frontmatter `name`, from its directory path relative to the source root. | |
| 292 | 300 | func deriveName(root, path string) (string, error) { | |
| @@ -33,6 +33,7 @@ import ( | |||||
| 33 | "io/fs" | 33 | "io/fs" | ||
| 34 | "os" | 34 | "os" | ||
| 35 | "path/filepath" | 35 | "path/filepath" | ||
| 36 | + | "runtime" | |||
| 36 | "sort" | 37 | "sort" | ||
| 37 | "strings" | 38 | "strings" | ||
| 38 | "syscall" | 39 | "syscall" | ||
| @@ -248,7 +249,7 @@ func scanSource(source Source) ([]*Skill, []error) { | |||||
| 248 | if statErr != nil { | 249 | if statErr != nil { | ||
| 249 | // Dangling and cyclic links are not skills. Anything | 250 | // Dangling and cyclic links are not skills. Anything | ||
| 250 | // else (permissions, I/O) is worth reporting. | 251 | // else (permissions, I/O) is worth reporting. | ||
| 251 | - | if !errors.Is(statErr, fs.ErrNotExist) && !errors.Is(statErr, syscall.ELOOP) { | 252 | + | if !errors.Is(statErr, fs.ErrNotExist) && !symlinkLoopError(statErr) { |
| 252 | errs = append(errs, fmt.Errorf("%s: %w", path, statErr)) | 253 | errs = append(errs, fmt.Errorf("%s: %w", path, statErr)) | ||
| 253 | } | 254 | } | ||
| 254 | continue | 255 | continue | ||
| @@ -287,6 +288,13 @@ func scanSource(source Source) ([]*Skill, []error) { | |||||
| 287 | return out, errs | 288 | return out, errs | ||
| 288 | } | 289 | } | ||
| 289 | 290 | ||||
| 291 | + | func symlinkLoopError(err error) bool { | |||
| 292 | + | // Windows reports cyclic links as ERROR_CANT_RESOLVE_FILENAME (1921), | |||
| 293 | + | // rather than ELOOP. syscall does not export a name for this error. | |||
| 294 | + | return errors.Is(err, syscall.ELOOP) || | |||
| 295 | + | (runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(1921))) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 290 | // deriveName builds a skill name for a SKILL.md file that carries no | 298 | // deriveName builds a skill name for a SKILL.md file that carries no | ||
| 291 | // frontmatter `name`, from its directory path relative to the source root. | 299 | // frontmatter `name`, from its directory path relative to the source root. | ||
| 292 | func deriveName(root, path string) (string, error) { | 300 | func deriveName(root, path string) (string, error) { | ||
M packages/agent/skills/skills_test.go +14 -0
| @@ -1,10 +1,12 @@ | |||
| 1 | 1 | package skills | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "runtime" | |
| 7 | 8 | "strings" | |
| 9 | + | "syscall" | |
| 8 | 10 | "testing" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| @@ -246,6 +248,18 @@ func TestDiscoverSourcesIgnoresCyclicSymlink(t *testing.T) { | |||
| 246 | 248 | } | |
| 247 | 249 | } | |
| 248 | 250 | ||
| 251 | + | func TestSymlinkLoopError(t *testing.T) { | |
| 252 | + | if !symlinkLoopError(fmt.Errorf("stat: %w", syscall.ELOOP)) { | |
| 253 | + | t.Error("ELOOP should be ignored") | |
| 254 | + | } | |
| 255 | + | if runtime.GOOS == "windows" && !symlinkLoopError(fmt.Errorf("stat: %w", syscall.Errno(1921))) { | |
| 256 | + | t.Error("Windows cyclic-link error should be ignored") | |
| 257 | + | } | |
| 258 | + | if symlinkLoopError(fmt.Errorf("stat: %w", os.ErrPermission)) { | |
| 259 | + | t.Error("permission errors must still be reported") | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 249 | 263 | func mustEvalSymlinks(t *testing.T, path string) string { | |
| 250 | 264 | t.Helper() | |
| 251 | 265 | resolved, err := filepath.EvalSymlinks(path) | |
| @@ -1,10 +1,12 @@ | |||||
| 1 | package skills | 1 | package skills | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "os" | 5 | "os" | ||
| 5 | "path/filepath" | 6 | "path/filepath" | ||
| 6 | "runtime" | 7 | "runtime" | ||
| 7 | "strings" | 8 | "strings" | ||
| 9 | + | "syscall" | |||
| 8 | "testing" | 10 | "testing" | ||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| @@ -246,6 +248,18 @@ func TestDiscoverSourcesIgnoresCyclicSymlink(t *testing.T) { | |||||
| 246 | } | 248 | } | ||
| 247 | } | 249 | } | ||
| 248 | 250 | ||||
| 251 | + | func TestSymlinkLoopError(t *testing.T) { | |||
| 252 | + | if !symlinkLoopError(fmt.Errorf("stat: %w", syscall.ELOOP)) { | |||
| 253 | + | t.Error("ELOOP should be ignored") | |||
| 254 | + | } | |||
| 255 | + | if runtime.GOOS == "windows" && !symlinkLoopError(fmt.Errorf("stat: %w", syscall.Errno(1921))) { | |||
| 256 | + | t.Error("Windows cyclic-link error should be ignored") | |||
| 257 | + | } | |||
| 258 | + | if symlinkLoopError(fmt.Errorf("stat: %w", os.ErrPermission)) { | |||
| 259 | + | t.Error("permission errors must still be reported") | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | ||||
| 249 | func mustEvalSymlinks(t *testing.T, path string) string { | 263 | func mustEvalSymlinks(t *testing.T, path string) string { | ||
| 250 | t.Helper() | 264 | t.Helper() | ||
| 251 | resolved, err := filepath.EvalSymlinks(path) | 265 | resolved, err := filepath.EvalSymlinks(path) | ||