diff --git a/server/server_mgmt.go b/server/server_mgmt.go index 5d3e6d1..b851ca7 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -1530,10 +1530,11 @@ func skillRelPath(file string) (string, string) { if strings.HasPrefix(file, "/") || strings.Contains(file, "//") { return "", "invalid path: must be relative with no empty segments" } - // 4. Normalize and final safety re-check after Clean. - // filepath.Clean removes redundant separators and resolves single dots. - clean := filepath.Clean(file) - if clean == "." || filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") { + // 4. Normalize as a URL/API path, not as a host filesystem path. API paths + // always use '/', including when ARTEX runs on Windows. Filesystem calls + // below accept this canonical form through filepath.Join. + clean := path.Clean(file) + if clean == "." || path.IsAbs(clean) || strings.HasPrefix(clean, "..") { return "", "invalid path" } return clean, "" @@ -1554,6 +1555,10 @@ func walkSkillFiles(root string) ([]string, error) { if relErr != nil || rel == "." { return nil } + // The file list is an HTTP API contract consumed by a frontend that splits + // paths on '/'. filepath.Rel uses '\\' on Windows, so normalize before the + // value leaves the backend. + rel = filepath.ToSlash(rel) if d.IsDir() { entries = append(entries, rel+"/") } else { diff --git a/server/skill_upload_test.go b/server/skill_upload_test.go index 81756b2..563a89e 100644 --- a/server/skill_upload_test.go +++ b/server/skill_upload_test.go @@ -64,6 +64,33 @@ func TestSkillRelPath(t *testing.T) { } } +func TestWalkSkillFilesUsesSlashSeparatedAPIPaths(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "scripts", "helpers"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "scripts", "helpers", "run.py"), []byte("pass\n"), 0o644); err != nil { + t.Fatal(err) + } + + entries, err := walkSkillFiles(root) + if err != nil { + t.Fatal(err) + } + want := []string{"scripts/", "scripts/helpers/", "scripts/helpers/run.py"} + if len(entries) != len(want) { + t.Fatalf("walkSkillFiles() = %q, want %q", entries, want) + } + for i := range want { + if entries[i] != want[i] { + t.Errorf("walkSkillFiles()[%d] = %q, want %q", i, entries[i], want[i]) + } + if strings.Contains(entries[i], `\`) { + t.Errorf("walkSkillFiles()[%d] contains a Windows separator: %q", i, entries[i]) + } + } +} + type nopWriteCloser struct{ io.Writer } func (nopWriteCloser) Close() error { return nil }