fix: match Cursor project slug normalization (#3926)
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user