fix: match Cursor project slug normalization (#3926)

This commit is contained in:
Carlos Florêncio
2026-09-25 13:52:53 +01:00
committed by GitHub
parent 3aa3233c78
commit 719a755a45
6 changed files with 222 additions and 11 deletions
@@ -33,9 +33,24 @@ type cursorMCPAuthSnapshot struct {
// DeriveCursorProjectSlug derives Cursor's project directory name from a path.
func DeriveCursorProjectSlug(workspacePath string) string {
normalizedPath := strings.ReplaceAll(workspacePath, `\`, "/")
slug := strings.NewReplacer("/", "-", ".", "-", "_", "-", ":", "-").Replace(normalizedPath)
return strings.Trim(slug, "-")
var slug strings.Builder
previousWasSeparator := true
for _, char := range workspacePath {
if !isCursorSlugASCIIAlphaNumeric(char) {
previousWasSeparator = true
continue
}
if slug.Len() > 0 && previousWasSeparator {
slug.WriteByte('-')
}
slug.WriteRune(char)
previousWasSeparator = false
}
return slug.String()
}
func isCursorSlugASCIIAlphaNumeric(char rune) bool {
return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9'
}
// AggregateCursorMCPAuth publishes the latest valid project auth snapshot.
@@ -204,10 +219,21 @@ func cursorAuthExcludedProjectSlugs(workspaceRoots []string) ([]string, error) {
return nil, errors.New("cursor MCP auth exclusion root has an empty project slug")
}
slugs = append(slugs, slug)
legacySlug := deriveLegacyCursorProjectSlug(canonicalRoot)
if legacySlug != "" && legacySlug != slug {
slugs = append(slugs, legacySlug)
}
}
return slugs, nil
}
// deriveLegacyCursorProjectSlug keeps pre-normalization task projects out of credential aggregation.
func deriveLegacyCursorProjectSlug(workspacePath string) string {
normalizedPath := strings.ReplaceAll(workspacePath, `\`, "/")
slug := strings.NewReplacer("/", "-", ".", "-", "_", "-", ":", "-").Replace(normalizedPath)
return strings.Trim(slug, "-")
}
func isCursorTaskProjectSlug(projectSlug string, taskRootSlugs []string) bool {
for _, rootSlug := range taskRootSlugs {
if projectSlug == rootSlug || strings.HasPrefix(projectSlug, rootSlug+"-") {
@@ -18,11 +18,12 @@ func TestDeriveCursorProjectSlug(t *testing.T) {
path string
want string
}{
{name: "path separators and punctuation", path: "/work/my.repo_name", want: "work-my-repo-name"},
{name: "windows drive path", path: `C:\Users\Alice\my_repo.v2`, want: "C--Users-Alice-my-repo-v2"},
{name: "Cursor task dot-folder path", path: "/Users/cfl12/.kandev/tasks/hello_hj2srhm6/master", want: "Users-cfl12-kandev-tasks-hello-hj2srhm6-master"},
{name: "windows drive path", path: `C:\Users\Alice\my_repo.v2`, want: "C-Users-Alice-my-repo-v2"},
{name: "windows network path", path: `\\server\share\project`, want: "server-share-project"},
{name: "keep internal dash runs", path: "/work//a__b", want: "work--a--b"},
{name: "punctuation runs collapse", path: "---/work...__///a!!b---", want: "work-a-b"},
{name: "trim boundary dashes", path: "./work/", want: "work"},
{name: "non-ASCII characters", path: "/work/café/项目/naïve", want: "work-caf-na-ve"},
{name: "empty result", path: "/...___", want: ""},
}
for _, tt := range tests {
@@ -38,7 +39,7 @@ func TestCursorMCPAuthWorktree(t *testing.T) {
requireSymlinkSupport(t)
root := t.TempDir()
repository := filepath.Join(root, "repository")
worktree := filepath.Join(root, "worktrees", "feature")
worktree := filepath.Join(root, ".kandev", "tasks", "hello_hj2srhm6", "master")
if err := os.MkdirAll(repository, 0o755); err != nil {
t.Fatal(err)
}
@@ -68,7 +69,13 @@ func TestCursorMCPAuthWorktree(t *testing.T) {
t.Fatalf("LinkCursorMCPAuth(worktree): %v", err)
}
destination := cursorAuthDestinationForTest(t, cursorHome, worktree)
canonicalRoot, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatal(err)
}
// Keep the task path suffix literal; only the randomized test root is derived.
wantProjectDirectory := filepath.Join(cursorHome, "projects", DeriveCursorProjectSlug(canonicalRoot)+"-kandev-tasks-hello-hj2srhm6-master")
destination := filepath.Join(wantProjectDirectory, cursorMCPAuthFilename)
target, err := os.Readlink(destination)
if err != nil {
t.Fatalf("Readlink(%s): %v", destination, err)
@@ -302,6 +309,36 @@ func TestAggregateCursorMCPAuthAllowsMissingExcludedRoot(t *testing.T) {
}
}
func TestAggregateCursorMCPAuthExcludesLegacyConfiguredTaskRootSlug(t *testing.T) {
cursorHome := t.TempDir()
projects := filepath.Join(cursorHome, "projects")
if err := os.MkdirAll(projects, 0o755); err != nil {
t.Fatal(err)
}
taskStorage := t.TempDir()
taskRoot := filepath.Join(taskStorage, ".tasks_dir")
if err := os.MkdirAll(filepath.Join(taskRoot, "task-123", "repository"), 0o755); err != nil {
t.Fatal(err)
}
// Before slug normalization, the separator before the dot produced two dashes.
legacyTaskSlug := DeriveCursorProjectSlug(taskStorage) + "--tasks-dir-task-123-repository"
writeCursorAuth(t, projects, legacyTaskSlug, `{"legacy-task":{"token":"excluded"}}`, time.Now())
writeCursorAuth(t, projects, "ordinary-project", `{"ordinary":{"token":"included"}}`, time.Now())
if err := AggregateCursorMCPAuth(cursorHome, taskRoot); err != nil {
t.Fatalf("AggregateCursorMCPAuth: %v", err)
}
got := readCursorAuth(t, filepath.Join(cursorHome, cursorMCPAuthUnifiedFilename))
if _, exists := got["legacy-task"]; exists {
t.Fatalf("legacy task auth was aggregated: %#v", got)
}
if _, exists := got["ordinary"]; !exists {
t.Fatalf("ordinary project auth was excluded: %#v", got)
}
}
func TestLinkCursorMCPAuthSharesCredentialByNameWithoutOriginBinding(t *testing.T) {
requireSymlinkSupport(t)
cursorHome := t.TempDir()