From 3aa3233c7833c8f3c034083e10d76999af3a010d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 25 Sep 2026 10:31:58 +0100 Subject: [PATCH] feat: share local Cursor MCP authentication (#3923) --- .github/workflows/backend-tests.yml | 3 + .../agent/mcpconfig/cursor_auth_bridge.go | 463 ++++++++++++ .../mcpconfig/cursor_auth_bridge_test.go | 661 ++++++++++++++++++ .../runtime/lifecycle/cursor_mcp_auth.go | 124 ++++ .../runtime/lifecycle/cursor_mcp_auth_test.go | 484 +++++++++++++ .../runtime/lifecycle/executor_backend.go | 16 +- .../lifecycle/executor_backend_test.go | 11 + .../runtime/lifecycle/manager_execution.go | 1 + .../runtime/lifecycle/manager_interaction.go | 19 +- .../lifecycle/manager_interaction_test.go | 113 +++ .../agent/runtime/lifecycle/manager_launch.go | 9 +- .../runtime/lifecycle/manager_lifecycle.go | 1 + .../runtime/lifecycle/manager_passthrough.go | 23 +- .../lifecycle/manager_passthrough_test.go | 14 + .../runtime/lifecycle/manager_profile.go | 20 +- .../runtime/lifecycle/manager_project_mcp.go | 11 +- .../lifecycle/manager_project_mcp_test.go | 61 +- .../runtime/lifecycle/profile_resolver.go | 1 + .../lifecycle/profile_resolver_test.go | 4 + .../internal/agent/runtime/lifecycle/types.go | 14 +- .../agent/settings/controller/agent_crud.go | 36 +- .../settings/controller/agent_discovery.go | 1 + .../cursor_mcp_auth_profile_test.go | 95 +++ .../agent/settings/controller/custom_tui.go | 11 +- .../settings/controller/profile_contract.go | 2 + .../controller/profile_contract_test.go | 22 +- .../agent/settings/controller/profile_crud.go | 31 +- .../agent/settings/controller/reconciler.go | 17 +- .../internal/agent/settings/dto/dto.go | 1 + .../agent/settings/dto/profile_contract.go | 3 + .../agent/settings/handlers/handlers.go | 38 +- .../internal/agent/settings/models/models.go | 3 + .../internal/agent/settings/store/sqlite.go | 25 +- .../store/sqlite_cursor_mcp_auth_test.go | 70 ++ .../settings/store/sqlite_duplicate_test.go | 51 +- .../internal/settingscatalog/defaults.go | 7 + .../internal/settingscatalog/defaults_test.go | 10 + apps/backend/internal/worktree/manager.go | 8 + apps/web/app/actions/agents.ts | 3 + .../[agentId]/agent-save-helpers.test.ts | 81 +++ .../agents/[agentId]/agent-save-helpers.ts | 10 + .../agents/[agentId]/agent-setup-parts.tsx | 4 + .../app/settings/agents/[agentId]/page.tsx | 1 + .../settings/agent-profile-dirty.ts | 22 +- .../settings/agent-profile-page-state.test.ts | 64 ++ .../settings/agent-profile-page-state.ts | 9 + .../settings/agent-profile-page.tsx | 4 + .../agent-profile-reconciliation.test.ts | 26 + .../settings/agent-profile-reconciliation.ts | 1 + .../settings/cursor-mcp-auth-preference.tsx | 54 ++ .../settings/profile-capability-helpers.tsx | 35 +- .../settings/profile-form-fields.test.tsx | 24 + .../settings/profile-form-fields.tsx | 39 +- apps/web/e2e/helpers/api-client.ts | 16 + apps/web/e2e/helpers/cursor-mcp-auth.ts | 21 + .../tests/settings/cursor-mcp-auth.spec.ts | 59 ++ .../settings/mobile-cursor-mcp-auth.spec.ts | 49 ++ .../domains/agent-profile-normalize.test.ts | 14 + .../api/domains/agent-profile-normalize.ts | 8 + .../contract.generated.json | 16 + .../profile-contract.generated.json | 16 + apps/web/lib/types/agent-profile.ts | 3 + apps/web/lib/types/backend.ts | 1 + apps/web/src/locales/en/agents.json | 3 + apps/web/src/locales/ja/agents.json | 3 + apps/web/src/locales/pseudo/agents.json | 3 + apps/web/src/locales/pt-pt/agents.json | 3 + apps/web/src/locales/zh-cn/agents.json | 3 + apps/web/src/locales/zh-hk/agents.json | 3 + apps/web/src/locales/zh-tw/agents.json | 3 + docs/plans/cursor-mcp-oauth-bridge/plan.md | 124 ++++ .../task-01-auth-helpers.md | 82 +++ .../task-02-profile-preference.md | 132 ++++ .../task-03-launch-integration.md | 124 ++++ docs/public/agents-and-profiles.md | 11 + .../requirements/cursor-mcp-oauth-bridge.md | 81 +++ .../system-design/cursor-mcp-oauth-bridge.md | 220 ++++++ 77 files changed, 3719 insertions(+), 140 deletions(-) create mode 100644 apps/backend/internal/agent/mcpconfig/cursor_auth_bridge.go create mode 100644 apps/backend/internal/agent/mcpconfig/cursor_auth_bridge_test.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/cursor_mcp_auth.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/cursor_mcp_auth_test.go create mode 100644 apps/backend/internal/agent/settings/controller/cursor_mcp_auth_profile_test.go create mode 100644 apps/backend/internal/agent/settings/store/sqlite_cursor_mcp_auth_test.go create mode 100644 apps/web/components/settings/cursor-mcp-auth-preference.tsx create mode 100644 apps/web/e2e/helpers/cursor-mcp-auth.ts create mode 100644 apps/web/e2e/tests/settings/cursor-mcp-auth.spec.ts create mode 100644 apps/web/e2e/tests/settings/mobile-cursor-mcp-auth.spec.ts create mode 100644 docs/plans/cursor-mcp-oauth-bridge/plan.md create mode 100644 docs/plans/cursor-mcp-oauth-bridge/task-01-auth-helpers.md create mode 100644 docs/plans/cursor-mcp-oauth-bridge/task-02-profile-preference.md create mode 100644 docs/plans/cursor-mcp-oauth-bridge/task-03-launch-integration.md create mode 100644 docs/specs/agents/requirements/cursor-mcp-oauth-bridge.md create mode 100644 docs/specs/agents/system-design/cursor-mcp-oauth-bridge.md diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 087913cac0..69ce115be6 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -772,6 +772,9 @@ jobs: -run '^(TestExplicitLocalRepositoryWindowsPathSemantics|TestListDirectory.*|TestDriveRootsFromMask|TestSplitAbsForRoot)$' ./internal/task/service + - name: Test Cursor project slug path normalization + run: go test -race -v ./internal/agent/mcpconfig -run '^TestDeriveCursorProjectSlug$' + # Pure GOOS-branch logic (no syscalls), but it must actually observe # Windows to prove the unavailable branch, not just compile under it. - name: Test Windows agent-survival flag availability diff --git a/apps/backend/internal/agent/mcpconfig/cursor_auth_bridge.go b/apps/backend/internal/agent/mcpconfig/cursor_auth_bridge.go new file mode 100644 index 0000000000..177f848f53 --- /dev/null +++ b/apps/backend/internal/agent/mcpconfig/cursor_auth_bridge.go @@ -0,0 +1,463 @@ +package mcpconfig + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +const ( + cursorMCPAuthFilename = "mcp-auth.json" + cursorMCPAuthUnifiedFilename = "kandev-mcp-auth-unified.json" + cursorTaskProjectMarker = "kandev-tasks" +) + +var cursorMCPAuthMutex sync.Mutex + +type cursorMCPAuthSource struct { + path string + modTime int64 + servers map[string]json.RawMessage +} + +type cursorMCPAuthSnapshot struct { + data []byte + hasSources bool +} + +// 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, "-") +} + +// AggregateCursorMCPAuth publishes the latest valid project auth snapshot. +// It leaves the existing master unchanged when no eligible source is valid. +func AggregateCursorMCPAuth(cursorHome string, excludedWorkspaceRoots ...string) error { + cursorMCPAuthMutex.Lock() + defer cursorMCPAuthMutex.Unlock() + + snapshot, err := aggregateCursorMCPAuth(cursorHome, excludedWorkspaceRoots...) + if err != nil || !snapshot.hasSources { + return err + } + return publishCursorMCPAuth(cursorHome, snapshot.data) +} + +// LinkCursorMCPAuth refreshes the shared snapshot and links a canonical +// workspace's Cursor project auth file to it when at least one source is valid. +func LinkCursorMCPAuth(workspacePath, cursorHome string, excludedWorkspaceRoots ...string) error { + cursorMCPAuthMutex.Lock() + defer cursorMCPAuthMutex.Unlock() + return linkCursorMCPAuth(workspacePath, cursorHome, excludedWorkspaceRoots...) +} + +// PrepareCursorMCPAuth applies the enabled or disabled bridge behavior for a +// local Cursor launch. +func PrepareCursorMCPAuth(workspacePath, cursorHome string, enabled bool, excludedWorkspaceRoots ...string) error { + cursorMCPAuthMutex.Lock() + defer cursorMCPAuthMutex.Unlock() + if !enabled { + return disableCursorMCPAuth(workspacePath, cursorHome) + } + return linkCursorMCPAuth(workspacePath, cursorHome, excludedWorkspaceRoots...) +} + +func linkCursorMCPAuth(workspacePath, cursorHome string, excludedWorkspaceRoots ...string) error { + workspaceRoots := append([]string{workspacePath}, excludedWorkspaceRoots...) + snapshot, err := aggregateCursorMCPAuth(cursorHome, workspaceRoots...) + if err != nil { + return err + } + if !snapshot.hasSources { + return nil + } + masterPath, err := cursorMasterPath(cursorHome) + if err != nil { + return err + } + if err := publishCursorMCPAuth(cursorHome, snapshot.data); err != nil { + return err + } + destination, err := cursorProjectAuthPath(workspacePath, cursorHome, true) + if err != nil { + return err + } + return linkCursorAuthFile(destination, masterPath) +} + +func disableCursorMCPAuth(workspacePath, cursorHome string) error { + destination, err := cursorProjectAuthPath(workspacePath, cursorHome, false) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + masterPath, err := cursorMasterPath(cursorHome) + if err != nil { + return err + } + info, err := os.Lstat(destination) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil || info.Mode()&os.ModeSymlink == 0 { + if err != nil { + return err + } + return nil + } + target, err := os.Readlink(destination) + if err != nil { + return err + } + if !samePath(resolveLinkTarget(filepath.Dir(destination), target), masterPath) { + return nil + } + return os.Remove(destination) +} + +func aggregateCursorMCPAuth(cursorHome string, excludedWorkspaceRoots ...string) (cursorMCPAuthSnapshot, error) { + projectsPath := filepath.Join(cursorHome, "projects") + projectsInfo, err := os.Lstat(projectsPath) + if errors.Is(err, os.ErrNotExist) { + return cursorMCPAuthSnapshot{}, nil + } + if err != nil { + return cursorMCPAuthSnapshot{}, err + } + if projectsInfo.Mode()&os.ModeSymlink != 0 || !projectsInfo.IsDir() { + return cursorMCPAuthSnapshot{}, errors.New("cursor projects path is not a regular directory") + } + entries, err := os.ReadDir(projectsPath) + if err != nil { + return cursorMCPAuthSnapshot{}, err + } + excludedSlugs, err := cursorAuthExcludedProjectSlugs(excludedWorkspaceRoots) + if err != nil { + return cursorMCPAuthSnapshot{}, err + } + sources := collectCursorMCPAuthSources(projectsPath, entries, excludedSlugs) + if len(sources) == 0 { + return cursorMCPAuthSnapshot{}, nil + } + sort.Slice(sources, func(i, j int) bool { + if sources[i].modTime != sources[j].modTime { + return sources[i].modTime > sources[j].modTime + } + return sources[i].path < sources[j].path + }) + servers := make(map[string]json.RawMessage) + for _, source := range sources { + for name, value := range source.servers { + if _, exists := servers[name]; !exists { + servers[name] = value + } + } + } + data, err := json.MarshalIndent(servers, "", " ") + if err != nil { + return cursorMCPAuthSnapshot{}, err + } + return cursorMCPAuthSnapshot{data: append(data, '\n'), hasSources: true}, nil +} + +func collectCursorMCPAuthSources(projectsPath string, entries []os.DirEntry, excludedSlugs []string) []cursorMCPAuthSource { + sources := make([]cursorMCPAuthSource, 0, len(entries)) + for _, entry := range entries { + if strings.Contains(entry.Name(), cursorTaskProjectMarker) || isCursorTaskProjectSlug(entry.Name(), excludedSlugs) { + continue + } + projectPath := filepath.Join(projectsPath, entry.Name()) + projectInfo, err := os.Lstat(projectPath) + if err != nil || !projectInfo.IsDir() { + continue + } + source, ok := readCursorMCPAuthSource(filepath.Join(projectPath, cursorMCPAuthFilename)) + if ok { + sources = append(sources, source) + } + } + return sources +} + +func cursorAuthExcludedProjectSlugs(workspaceRoots []string) ([]string, error) { + slugs := make([]string, 0, len(workspaceRoots)) + for _, root := range workspaceRoots { + if root == "" { + continue + } + canonicalRoot, err := canonicalPathIncludingMissingSuffix(root) + if err != nil { + return nil, errors.New("could not resolve Cursor MCP auth exclusion root") + } + slug := DeriveCursorProjectSlug(canonicalRoot) + if slug == "" { + return nil, errors.New("cursor MCP auth exclusion root has an empty project slug") + } + slugs = append(slugs, slug) + } + return slugs, nil +} + +func isCursorTaskProjectSlug(projectSlug string, taskRootSlugs []string) bool { + for _, rootSlug := range taskRootSlugs { + if projectSlug == rootSlug || strings.HasPrefix(projectSlug, rootSlug+"-") { + return true + } + } + return false +} + +func readCursorMCPAuthSource(path string) (cursorMCPAuthSource, bool) { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + return cursorMCPAuthSource{}, false + } + file, err := os.Open(path) + if err != nil { + return cursorMCPAuthSource{}, false + } + defer func() { _ = file.Close() }() + openedInfo, err := file.Stat() + if err != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) { + return cursorMCPAuthSource{}, false + } + data, err := io.ReadAll(file) + if err != nil { + return cursorMCPAuthSource{}, false + } + servers, valid := decodeCursorMCPAuth(data) + if !valid { + return cursorMCPAuthSource{}, false + } + return cursorMCPAuthSource{path: path, modTime: info.ModTime().UnixNano(), servers: servers}, true +} + +func decodeCursorMCPAuth(data []byte) (map[string]json.RawMessage, bool) { + trimmed := bytes.TrimSpace(data) + if len(trimmed) < 2 || trimmed[0] != '{' { + return nil, false + } + var root map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &root); err != nil || root == nil { + return nil, false + } + for _, server := range root { + if !isJSONObject(server) { + return nil, false + } + } + return root, true +} + +func isJSONObject(data []byte) bool { + trimmed := bytes.TrimSpace(data) + if len(trimmed) < 2 || trimmed[0] != '{' { + return false + } + var object map[string]json.RawMessage + return json.Unmarshal(trimmed, &object) == nil && object != nil +} + +func publishCursorMCPAuth(cursorHome string, data []byte) error { + masterPath, err := cursorMasterPath(cursorHome) + if err != nil { + return err + } + if err := ensureReplaceableMaster(masterPath); err != nil { + return err + } + tempFile, err := os.CreateTemp(cursorHome, ".kandev-mcp-auth-*") + if err != nil { + return err + } + tempPath := tempFile.Name() + defer func() { _ = os.Remove(tempPath) }() + if err := tempFile.Chmod(0o600); err != nil { + _ = tempFile.Close() + return err + } + if _, err := tempFile.Write(data); err != nil { + _ = tempFile.Close() + return err + } + if err := tempFile.Close(); err != nil { + return err + } + if err := ensureReplaceableMaster(masterPath); err != nil { + return err + } + return os.Rename(tempPath, masterPath) +} + +func ensureReplaceableMaster(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return errors.New("cursor unified auth path is not a regular file") + } + return nil +} + +func cursorProjectAuthPath(workspacePath, cursorHome string, create bool) (string, error) { + canonicalPath, err := canonicalWorkspacePath(workspacePath) + if err != nil { + return "", err + } + slug := DeriveCursorProjectSlug(canonicalPath) + if slug == "" { + return "", errors.New("cursor project path has an empty slug") + } + projectsPath := filepath.Join(cursorHome, "projects") + projectsInfo, err := os.Lstat(projectsPath) + if err != nil { + return "", err + } + if projectsInfo.Mode()&os.ModeSymlink != 0 || !projectsInfo.IsDir() { + return "", errors.New("cursor projects path is not a regular directory") + } + projectPath, err := cursorProjectDirectoryPath(projectsPath, slug, create) + if err != nil { + return "", err + } + return filepath.Join(projectPath, cursorMCPAuthFilename), nil +} + +func cursorProjectDirectoryPath(projectsPath, slug string, create bool) (string, error) { + projectPath := filepath.Join(projectsPath, slug) + projectInfo, err := os.Lstat(projectPath) + if errors.Is(err, os.ErrNotExist) && create { + if err := os.Mkdir(projectPath, 0o755); err != nil && !errors.Is(err, os.ErrExist) { + return "", err + } + projectInfo, err = os.Lstat(projectPath) + } + if err != nil { + return "", err + } + if projectInfo.Mode()&os.ModeSymlink != 0 || !projectInfo.IsDir() { + return "", errors.New("cursor project path is not a regular directory") + } + return projectPath, nil +} + +func canonicalWorkspacePath(workspacePath string) (string, error) { + absolutePath, err := filepath.Abs(workspacePath) + if err != nil { + return "", err + } + return filepath.EvalSymlinks(absolutePath) +} + +// canonicalPathIncludingMissingSuffix resolves symlinked ancestors when a task root has been removed. +func canonicalPathIncludingMissingSuffix(path string) (string, error) { + absolutePath, err := filepath.Abs(path) + if err != nil { + return "", err + } + for existingPath := absolutePath; ; existingPath = filepath.Dir(existingPath) { + resolvedPath, resolveErr := filepath.EvalSymlinks(existingPath) + if resolveErr == nil { + suffix, relErr := filepath.Rel(existingPath, absolutePath) + if relErr != nil { + return "", relErr + } + return filepath.Join(resolvedPath, suffix), nil + } + if !errors.Is(resolveErr, os.ErrNotExist) { + return "", resolveErr + } + parent := filepath.Dir(existingPath) + if parent == existingPath { + return "", resolveErr + } + } +} + +func cursorMasterPath(cursorHome string) (string, error) { + absoluteHome, err := filepath.Abs(cursorHome) + if err != nil { + return "", err + } + return filepath.Join(absoluteHome, cursorMCPAuthUnifiedFilename), nil +} + +func linkCursorAuthFile(destination, masterPath string) error { + info, err := os.Lstat(destination) + if errors.Is(err, os.ErrNotExist) { + return os.Symlink(masterPath, destination) + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink == 0 { + return nil + } + target, err := os.Readlink(destination) + if err != nil { + return err + } + if samePath(resolveLinkTarget(filepath.Dir(destination), target), masterPath) { + return nil + } + return replaceCursorAuthSymlink(destination, masterPath) +} + +func replaceCursorAuthSymlink(destination, masterPath string) error { + file, err := os.CreateTemp(filepath.Dir(destination), ".kandev-mcp-auth-link-*") + if err != nil { + return err + } + tempPath := file.Name() + if err := file.Close(); err != nil { + _ = os.Remove(tempPath) + return err + } + if err := os.Remove(tempPath); err != nil { + return err + } + defer func() { _ = os.Remove(tempPath) }() + if err := os.Symlink(masterPath, tempPath); err != nil { + return err + } + info, err := os.Lstat(destination) + if errors.Is(err, os.ErrNotExist) { + return os.Rename(tempPath, destination) + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink == 0 { + return nil + } + return os.Rename(tempPath, destination) +} + +func resolveLinkTarget(linkDirectory, target string) string { + if !filepath.IsAbs(target) { + target = filepath.Join(linkDirectory, target) + } + absolute, err := filepath.Abs(target) + if err != nil { + return filepath.Clean(target) + } + return filepath.Clean(absolute) +} + +func samePath(left, right string) bool { + return filepath.Clean(left) == filepath.Clean(right) +} diff --git a/apps/backend/internal/agent/mcpconfig/cursor_auth_bridge_test.go b/apps/backend/internal/agent/mcpconfig/cursor_auth_bridge_test.go new file mode 100644 index 0000000000..28927dc284 --- /dev/null +++ b/apps/backend/internal/agent/mcpconfig/cursor_auth_bridge_test.go @@ -0,0 +1,661 @@ +package mcpconfig + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +func TestDeriveCursorProjectSlug(t *testing.T) { + tests := []struct { + name string + 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: "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: "trim boundary dashes", path: "./work/", want: "work"}, + {name: "empty result", path: "/...___", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := DeriveCursorProjectSlug(tt.path); got != tt.want { + t.Fatalf("DeriveCursorProjectSlug(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestCursorMCPAuthWorktree(t *testing.T) { + requireSymlinkSupport(t) + root := t.TempDir() + repository := filepath.Join(root, "repository") + worktree := filepath.Join(root, "worktrees", "feature") + if err := os.MkdirAll(repository, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, repository, "init") + runGit(t, repository, "config", "user.name", "Kandev Tests") + runGit(t, repository, "config", "user.email", "tests@example.invalid") + if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repository, "add", "README.md") + runGit(t, repository, "commit", "-m", "fixture") + if err := os.MkdirAll(filepath.Dir(worktree), 0o755); err != nil { + t.Fatal(err) + } + runGit(t, repository, "worktree", "add", "-b", "feature", worktree) + + cursorHome := t.TempDir() + source := filepath.Join(cursorHome, "projects", "other-project", cursorMCPAuthFilename) + if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil { + t.Fatal(err) + } + sourceBefore := []byte(`{"figma":{"token":"opaque-test-token"}}`) + if err := os.WriteFile(source, sourceBefore, 0o600); err != nil { + t.Fatal(err) + } + if err := LinkCursorMCPAuth(worktree, cursorHome); err != nil { + t.Fatalf("LinkCursorMCPAuth(worktree): %v", err) + } + + destination := cursorAuthDestinationForTest(t, cursorHome, worktree) + target, err := os.Readlink(destination) + if err != nil { + t.Fatalf("Readlink(%s): %v", destination, err) + } + wantTarget := filepath.Join(cursorHome, cursorMCPAuthUnifiedFilename) + if target != wantTarget { + t.Fatalf("worktree link target = %q, want %q", target, wantTarget) + } + masterData, err := os.ReadFile(wantTarget) + if err != nil { + t.Fatalf("read unified auth file: %v", err) + } + if string(masterData) != "{\n \"figma\": {\n \"token\": \"opaque-test-token\"\n }\n}\n" { + t.Fatalf("unexpected unified auth JSON: %s", masterData) + } + sourceAfter, err := os.ReadFile(source) + if err != nil { + t.Fatalf("read source auth file: %v", err) + } + if !reflect.DeepEqual(sourceAfter, sourceBefore) { + t.Fatalf("source auth file changed: got %s, want %s", sourceAfter, sourceBefore) + } +} + +func runGit(t *testing.T, repository string, args ...string) { + t.Helper() + commandArgs := append([]string{"-C", repository}, args...) + command := exec.Command("git", commandArgs...) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, output) + } +} + +func requireSymlinkSupport(t *testing.T) { + t.Helper() + root := t.TempDir() + target := filepath.Join(root, "target") + if err := os.WriteFile(target, []byte("target"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(root, "link")); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } +} + +func TestAggregateCursorMCPAuth(t *testing.T) { + requireSymlinkSupport(t) + cursorHome := t.TempDir() + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(projects, 0o755); err != nil { + t.Fatal(err) + } + baseTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + writeCursorAuth(t, projects, "older", `{"shared":{"account":"old"},"older-only":{"scope":"read"}}`, baseTime) + writeCursorAuth(t, projects, "newer", `{"shared":{"account":"new","unknown":{"kept":true}},"newer-only":{"opaque":[1,2]}}`, baseTime.Add(time.Minute)) + writeCursorAuth(t, projects, "a-tie", `{"tie":{"winner":"a"}}`, baseTime.Add(2*time.Minute)) + writeCursorAuth(t, projects, "z-tie", `{"tie":{"winner":"z"}}`, baseTime.Add(2*time.Minute)) + writeCursorAuth(t, projects, "kandev-tasks-17", `{"task-secret":{"token":"excluded"}}`, baseTime.Add(3*time.Minute)) + writeCursorAuth(t, projects, "invalid", `{"valid":{"ok":true},"invalid":"not-an-object"}`, baseTime.Add(4*time.Minute)) + writeCursorAuth(t, projects, "malformed", `{"broken":`, baseTime.Add(5*time.Minute)) + + externalProjects := t.TempDir() + if err := os.Mkdir(filepath.Join(externalProjects, "symlink-target"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(externalProjects, "symlink-target", "mcp-auth.json"), []byte(`{"symlinked":{"token":"excluded"}}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(externalProjects, "symlink-target"), filepath.Join(projects, "symlink-project")); err != nil { + t.Fatal(err) + } + linkedFile := writeCursorAuth(t, projects, "linked-file", `{"linked":{"token":"excluded"}}`, baseTime.Add(6*time.Minute)) + if err := os.Remove(linkedFile); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(projects, "older", "mcp-auth.json"), linkedFile); err != nil { + t.Fatal(err) + } + + if err := AggregateCursorMCPAuth(cursorHome); err != nil { + t.Fatalf("AggregateCursorMCPAuth: %v", err) + } + got := readCursorAuth(t, filepath.Join(cursorHome, cursorMCPAuthUnifiedFilename)) + want := map[string]map[string]any{ + "shared": {"account": "new", "unknown": map[string]any{"kept": true}}, + "older-only": {"scope": "read"}, + "newer-only": {"opaque": []any{float64(1), float64(2)}}, + "tie": {"winner": "a"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("aggregated auth = %#v, want %#v", got, want) + } + info, err := os.Stat(filepath.Join(cursorHome, cursorMCPAuthUnifiedFilename)) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Fatalf("unified file mode = %04o, want 0600", mode) + } +} + +func TestAggregateCursorMCPAuth_NoSourcesAndProtectedMaster(t *testing.T) { + requireSymlinkSupport(t) + t.Run("no valid source leaves stale master unchanged", func(t *testing.T) { + cursorHome := t.TempDir() + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(projects, 0o755); err != nil { + t.Fatal(err) + } + master := filepath.Join(cursorHome, cursorMCPAuthUnifiedFilename) + before := []byte(`{"stale":{"token":"do-not-link"}}`) + if err := os.WriteFile(master, before, 0o600); err != nil { + t.Fatal(err) + } + writeCursorAuth(t, projects, "bad", `not-json`, time.Now()) + if err := AggregateCursorMCPAuth(cursorHome); err != nil { + t.Fatalf("AggregateCursorMCPAuth: %v", err) + } + after, err := os.ReadFile(master) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(after, before) { + t.Fatalf("stale master changed: %s", after) + } + }) + + t.Run("symlinked projects root is rejected", func(t *testing.T) { + cursorHome := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(cursorHome, "projects")); err != nil { + t.Fatal(err) + } + if err := AggregateCursorMCPAuth(cursorHome); err == nil { + t.Fatal("expected symlinked projects root to be rejected") + } + }) + + for _, kind := range []string{"symlink", "directory"} { + t.Run("protected master "+kind, func(t *testing.T) { + cursorHome := t.TempDir() + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(projects, 0o755); err != nil { + t.Fatal(err) + } + writeCursorAuth(t, projects, "source", `{"server":{"token":"x"}}`, time.Now()) + master := filepath.Join(cursorHome, cursorMCPAuthUnifiedFilename) + if kind == "symlink" { + target := filepath.Join(cursorHome, "old-master") + if err := os.WriteFile(target, []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, master); err != nil { + t.Fatal(err) + } + } else if err := os.Mkdir(master, 0o700); err != nil { + t.Fatal(err) + } + if err := AggregateCursorMCPAuth(cursorHome); err == nil { + t.Fatal("expected protected master to be rejected") + } + }) + } +} + +func TestAggregateCursorMCPAuthExcludesConfiguredTaskRoot(t *testing.T) { + cursorHome := t.TempDir() + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(projects, 0o755); err != nil { + t.Fatal(err) + } + + taskRoot := filepath.Join(t.TempDir(), "custom-task-storage") + taskWorkspace := filepath.Join(taskRoot, "task-123", "repository") + if err := os.MkdirAll(taskWorkspace, 0o755); err != nil { + t.Fatal(err) + } + absoluteTaskWorkspace, err := filepath.Abs(taskWorkspace) + if err != nil { + t.Fatal(err) + } + canonicalTaskWorkspace, err := filepath.EvalSymlinks(absoluteTaskWorkspace) + if err != nil { + t.Fatal(err) + } + writeCursorAuth(t, projects, DeriveCursorProjectSlug(canonicalTaskWorkspace), `{"task-secret":{"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["task-secret"]; exists { + t.Fatalf("configured task auth was aggregated: %#v", got) + } + if _, exists := got["ordinary"]; !exists { + t.Fatalf("ordinary project auth was excluded: %#v", got) + } +} + +func TestAggregateCursorMCPAuthAllowsMissingExcludedRoot(t *testing.T) { + cursorHome := t.TempDir() + projects := filepath.Join(cursorHome, "projects") + writeCursorAuth(t, projects, "ordinary-project", `{"ordinary":{"token":"included"}}`, time.Now()) + missingTaskRoot := filepath.Join(t.TempDir(), "not-created-yet") + taskWorkspace := filepath.Join(missingTaskRoot, "task-123", "repository") + absoluteExistingParent, err := filepath.Abs(filepath.Dir(missingTaskRoot)) + if err != nil { + t.Fatal(err) + } + canonicalExistingParent, err := filepath.EvalSymlinks(absoluteExistingParent) + if err != nil { + t.Fatal(err) + } + relativeTaskWorkspace, err := filepath.Rel(filepath.Dir(missingTaskRoot), taskWorkspace) + if err != nil { + t.Fatal(err) + } + canonicalTaskWorkspace := filepath.Join(canonicalExistingParent, relativeTaskWorkspace) + writeCursorAuth(t, projects, DeriveCursorProjectSlug(canonicalTaskWorkspace), `{"stale-task":{"token":"excluded"}}`, time.Now()) + + if err := AggregateCursorMCPAuth(cursorHome, missingTaskRoot); err != nil { + t.Fatalf("AggregateCursorMCPAuth with an absent task root: %v", err) + } + got := readCursorAuth(t, filepath.Join(cursorHome, cursorMCPAuthUnifiedFilename)) + if _, exists := got["stale-task"]; exists { + t.Fatalf("stale task auth under the absent root was aggregated: %#v", got) + } + if _, exists := got["ordinary"]; !exists { + t.Fatalf("ordinary auth was not published: %#v", got) + } +} + +func TestLinkCursorMCPAuthSharesCredentialByNameWithoutOriginBinding(t *testing.T) { + requireSymlinkSupport(t) + cursorHome := t.TempDir() + projects := filepath.Join(cursorHome, "projects") + writeCursorAuth(t, projects, "trusted-source", `{"calendar":{"access_token":"synthetic-token"}}`, time.Now()) + + workspace := t.TempDir() + projectConfig := filepath.Join(workspace, ".cursor", "mcp.json") + if err := os.MkdirAll(filepath.Dir(projectConfig), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(projectConfig, []byte(`{"mcpServers":{"calendar":{"url":"https://untrusted.invalid"}}}`), 0o600); err != nil { + t.Fatal(err) + } + + if err := LinkCursorMCPAuth(workspace, cursorHome); err != nil { + t.Fatalf("LinkCursorMCPAuth: %v", err) + } + got := readCursorAuth(t, cursorAuthDestinationForTest(t, cursorHome, workspace)) + if got["calendar"]["access_token"] != "synthetic-token" { + t.Fatalf("same-name auth was not copied across project origins: %#v", got) + } +} + +func TestLinkCursorMCPAuth(t *testing.T) { + requireSymlinkSupport(t) + t.Run("workspace under symlinked parent uses canonical project slug", func(t *testing.T) { + cursorHome := t.TempDir() + projects := filepath.Join(cursorHome, "projects") + writeCursorAuth(t, projects, "source", `{"server":{"token":"secret"}}`, time.Now()) + + realParent := t.TempDir() + aliasParent := filepath.Join(t.TempDir(), "parent-alias") + if err := os.Symlink(realParent, aliasParent); err != nil { + t.Fatal(err) + } + workspace := filepath.Join(aliasParent, "worktree") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatal(err) + } + workspaceAbs, err := filepath.Abs(workspace) + if err != nil { + t.Fatal(err) + } + canonical, err := filepath.EvalSymlinks(workspaceAbs) + if err != nil { + t.Fatal(err) + } + if DeriveCursorProjectSlug(workspaceAbs) == DeriveCursorProjectSlug(canonical) { + t.Fatalf("test paths produced identical slugs: %q", canonical) + } + + if err := LinkCursorMCPAuth(workspace, cursorHome); err != nil { + t.Fatalf("LinkCursorMCPAuth: %v", err) + } + destination := cursorAuthDestinationForTest(t, cursorHome, workspace) + if _, err := os.Readlink(destination); err != nil { + t.Fatalf("canonical destination is not linked: %v", err) + } + rawDestination := filepath.Join(projects, DeriveCursorProjectSlug(workspaceAbs), cursorMCPAuthFilename) + if _, err := os.Lstat(rawDestination); !os.IsNotExist(err) { + t.Fatalf("non-canonical destination unexpectedly exists: %v", err) + } + }) + + t.Run("canonical workspace gets an absolute symlink", func(t *testing.T) { + cursorHome := t.TempDir() + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(projects, 0o755); err != nil { + t.Fatal(err) + } + writeCursorAuth(t, projects, "source", `{"server":{"opaque":{"token":"secret"}}}`, time.Now()) + workspace := t.TempDir() + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(workspace, alias); err != nil { + t.Fatal(err) + } + projectPath := filepath.Dir(cursorAuthDestinationForTest(t, cursorHome, workspace)) + if err := os.Mkdir(projectPath, 0o700); err != nil { + t.Fatal(err) + } + if err := LinkCursorMCPAuth(alias, cursorHome); err != nil { + t.Fatalf("LinkCursorMCPAuth: %v", err) + } + canonical, err := filepath.EvalSymlinks(workspace) + if err != nil { + t.Fatal(err) + } + destination := filepath.Join(projects, DeriveCursorProjectSlug(canonical), "mcp-auth.json") + got, err := os.Readlink(destination) + if err != nil { + t.Fatal(err) + } + master, err := filepath.Abs(filepath.Join(cursorHome, cursorMCPAuthUnifiedFilename)) + if err != nil { + t.Fatal(err) + } + if got != master { + t.Fatalf("link target = %q, want %q", got, master) + } + info, err := os.Stat(filepath.Dir(destination)) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != 0o700 { + t.Fatalf("existing project directory mode = %04o, want unchanged 0700", mode) + } + }) + + t.Run("protected regular destination is unchanged", func(t *testing.T) { + cursorHome := t.TempDir() + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(projects, 0o755); err != nil { + t.Fatal(err) + } + writeCursorAuth(t, projects, "source", `{"server":{"token":"new"}}`, time.Now()) + workspace := t.TempDir() + destination := cursorAuthDestinationForTest(t, cursorHome, workspace) + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + t.Fatal(err) + } + before := []byte("user-owned-auth-bytes\n") + if err := os.WriteFile(destination, before, 0o640); err != nil { + t.Fatal(err) + } + if err := LinkCursorMCPAuth(workspace, cursorHome); err != nil { + t.Fatalf("LinkCursorMCPAuth: %v", err) + } + after, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(after, before) { + t.Fatalf("regular destination changed: %q", after) + } + }) + + t.Run("protected directory destination is unchanged", func(t *testing.T) { + cursorHome := t.TempDir() + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(projects, 0o755); err != nil { + t.Fatal(err) + } + writeCursorAuth(t, projects, "source", `{"server":{"token":"new"}}`, time.Now()) + workspace := t.TempDir() + destination := cursorAuthDestinationForTest(t, cursorHome, workspace) + if err := os.MkdirAll(destination, 0o750); err != nil { + t.Fatal(err) + } + if err := LinkCursorMCPAuth(workspace, cursorHome); err != nil { + t.Fatalf("LinkCursorMCPAuth: %v", err) + } + info, err := os.Lstat(destination) + if err != nil || !info.IsDir() { + t.Fatalf("protected destination changed: info=%v err=%v", info, err) + } + }) + + t.Run("no valid source does not link stale master", func(t *testing.T) { + cursorHome := t.TempDir() + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(projects, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cursorHome, cursorMCPAuthUnifiedFilename), []byte(`{"stale":{}}`), 0o600); err != nil { + t.Fatal(err) + } + writeCursorAuth(t, projects, "invalid", `{"server":false}`, time.Now()) + workspace := t.TempDir() + if err := LinkCursorMCPAuth(workspace, cursorHome); err != nil { + t.Fatalf("LinkCursorMCPAuth: %v", err) + } + destination := cursorAuthDestinationForTest(t, cursorHome, workspace) + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("destination should remain absent, stat error = %v", err) + } + }) + + t.Run("existing bridge link remains when refresh has no valid source", func(t *testing.T) { + cursorHome := t.TempDir() + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(projects, 0o755); err != nil { + t.Fatal(err) + } + master := filepath.Join(cursorHome, cursorMCPAuthUnifiedFilename) + if err := os.WriteFile(master, []byte(`{"stale":{"token":"synthetic"}}`), 0o600); err != nil { + t.Fatal(err) + } + writeCursorAuth(t, projects, "invalid", `not-json`, time.Now()) + workspace := t.TempDir() + destination := cursorAuthDestinationForTest(t, cursorHome, workspace) + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(master, destination); err != nil { + t.Fatal(err) + } + + if err := LinkCursorMCPAuth(workspace, cursorHome); err != nil { + t.Fatalf("LinkCursorMCPAuth: %v", err) + } + got, err := os.Readlink(destination) + if err != nil { + t.Fatalf("existing bridge link was removed: %v", err) + } + if got != master { + t.Fatalf("existing link target = %q, want %q", got, master) + } + }) +} + +func cursorAuthDestinationForTest(t *testing.T, cursorHome, workspacePath string) string { + t.Helper() + absolutePath, err := filepath.Abs(workspacePath) + if err != nil { + t.Fatal(err) + } + canonicalPath, err := filepath.EvalSymlinks(absolutePath) + if err != nil { + t.Fatal(err) + } + return filepath.Join(cursorHome, "projects", DeriveCursorProjectSlug(canonicalPath), cursorMCPAuthFilename) +} + +func TestDisableCursorMCPAuth(t *testing.T) { + requireSymlinkSupport(t) + for _, targetKind := range []string{"bridge absolute", "bridge relative", "unrelated"} { + t.Run(targetKind, func(t *testing.T) { + cursorHome := t.TempDir() + workspace := t.TempDir() + destination := cursorAuthDestinationForTest(t, cursorHome, workspace) + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + t.Fatal(err) + } + master := filepath.Join(cursorHome, cursorMCPAuthUnifiedFilename) + if err := os.WriteFile(master, []byte(`{"kept":{}}`), 0o600); err != nil { + t.Fatal(err) + } + target := master + remove := true + switch targetKind { + case "bridge relative": + target, _ = filepath.Rel(filepath.Dir(destination), master) + case "unrelated": + target = filepath.Join(cursorHome, "other.json") + if err := os.WriteFile(target, []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + remove = false + } + if err := os.Symlink(target, destination); err != nil { + t.Fatal(err) + } + if err := disableCursorMCPAuth(workspace, cursorHome); err != nil { + t.Fatalf("disableCursorMCPAuth: %v", err) + } + _, err := os.Lstat(destination) + if remove && !os.IsNotExist(err) { + t.Fatalf("matching link remains or stat failed: %v", err) + } + if !remove && err != nil { + t.Fatalf("unrelated link was removed: %v", err) + } + if _, err := os.Stat(master); err != nil { + t.Fatalf("master was removed: %v", err) + } + }) + } + + t.Run("regular file is preserved", func(t *testing.T) { + cursorHome := t.TempDir() + workspace := t.TempDir() + destination := cursorAuthDestinationForTest(t, cursorHome, workspace) + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + t.Fatal(err) + } + before := []byte("keep this regular file") + if err := os.WriteFile(destination, before, 0o600); err != nil { + t.Fatal(err) + } + if err := disableCursorMCPAuth(workspace, cursorHome); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(after, before) { + t.Fatalf("regular file changed: %q", after) + } + }) +} + +func TestCursorMCPAuthConcurrentPreparation(t *testing.T) { + requireSymlinkSupport(t) + cursorHome := t.TempDir() + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(projects, 0o755); err != nil { + t.Fatal(err) + } + writeCursorAuth(t, projects, "source", `{"server":{"opaque":{"token":"value"}}}`, time.Now()) + workspace := t.TempDir() + const workers = 12 + var wg sync.WaitGroup + errs := make(chan error, workers) + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + errs <- LinkCursorMCPAuth(workspace, cursorHome) + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("concurrent link: %v", err) + } + } + master := filepath.Join(cursorHome, cursorMCPAuthUnifiedFilename) + if got := readCursorAuth(t, master); !reflect.DeepEqual(got, map[string]map[string]any{"server": {"opaque": map[string]any{"token": "value"}}}) { + t.Fatalf("concurrent snapshot = %#v", got) + } + destination := cursorAuthDestinationForTest(t, cursorHome, workspace) + if target, err := os.Readlink(destination); err != nil || !strings.HasSuffix(target, cursorMCPAuthUnifiedFilename) { + t.Fatalf("concurrent destination target = %q, err = %v", target, err) + } +} + +func writeCursorAuth(t *testing.T, projects, name, content string, modTime time.Time) string { + t.Helper() + dir := filepath.Join(projects, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "mcp-auth.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, modTime, modTime); err != nil { + t.Fatal(err) + } + return path +} + +func readCursorAuth(t *testing.T, path string) map[string]map[string]any { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var got map[string]map[string]any + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("invalid auth JSON: %v", err) + } + return got +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/cursor_mcp_auth.go b/apps/backend/internal/agent/runtime/lifecycle/cursor_mcp_auth.go new file mode 100644 index 0000000000..9a6d3096f2 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/cursor_mcp_auth.go @@ -0,0 +1,124 @@ +package lifecycle + +import ( + "errors" + "os" + "path/filepath" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/mcpconfig" + "github.com/kandev/kandev/internal/task/models" +) + +func (m *Manager) prepareCursorMCPAuth( + execution *AgentExecution, + profileInfo *AgentProfileInfo, + executorType string, + strategy mcpconfig.PassthroughMCPStrategy, +) error { + if execution == nil || execution.WorkspacePath == "" || !isCursorMCPAuthStrategy(strategy) { + return nil + } + if !isCursorMCPAuthLocalExecutor(executorType) { + return nil + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return nil + } + if runtimeHome, exists := execution.RuntimeEnvironment()["HOME"]; exists && !sameCursorMCPAuthHome(home, runtimeHome) { + return nil + } + if profileInfo == nil { + return errors.New("could not resolve Cursor MCP authentication preference") + } + + if !profileInfo.CursorMCPAuthEnabled { + if err := mcpconfig.PrepareCursorMCPAuth(execution.WorkspacePath, filepath.Join(home, ".cursor"), false); err != nil { + return errors.New("failed to remove shared Cursor MCP credentials") + } + return nil + } + + excludedRoots, err := m.cursorMCPAuthTaskRoots() + if err != nil { + m.logCursorMCPAuthPreparationFailure(execution, "task_root_unavailable") + return nil + } + if err := mcpconfig.PrepareCursorMCPAuth(execution.WorkspacePath, filepath.Join(home, ".cursor"), true, excludedRoots...); err != nil { + m.logCursorMCPAuthPreparationFailure(execution, "prepare_failed") + } + return nil +} + +func (m *Manager) cursorMCPAuthTaskRoots() ([]string, error) { + roots := make([]string, 0, 2) + if m.dataDir != "" { + roots = append(roots, filepath.Join(m.dataDir, "tasks")) + } + if m.worktreeMgr == nil { + return roots, nil + } + taskRoot, err := m.worktreeMgr.TasksBasePath() + if err != nil { + return nil, err + } + if taskRoot != "" { + roots = append(roots, taskRoot) + } + return roots, nil +} + +func (m *Manager) logCursorMCPAuthPreparationFailure(execution *AgentExecution, reason string) { + if m.logger == nil { + return + } + m.logger.Warn("could not prepare local Cursor MCP credentials", + zap.String("reason", reason), + zap.String("agent_id", execution.AgentID)) +} + +func isCursorMCPAuthStrategy(strategy mcpconfig.PassthroughMCPStrategy) bool { + switch strategy.(type) { + case mcpconfig.CursorStrategy, *mcpconfig.CursorStrategy: + return true + default: + return false + } +} + +func isCursorMCPAuthLocalExecutor(executorType string) bool { + switch models.ExecutorType(executorType) { + case models.ExecutorTypeLocal, models.ExecutorTypeWorktree, legacyExecutorTypeLocalPC: + return true + default: + return false + } +} + +func sameCursorMCPAuthHome(left, right string) bool { + if left == "" || right == "" { + return false + } + leftAbs, err := filepath.Abs(left) + if err != nil { + return false + } + rightAbs, err := filepath.Abs(right) + if err != nil { + return false + } + if filepath.Clean(leftAbs) == filepath.Clean(rightAbs) { + return true + } + leftInfo, err := os.Stat(leftAbs) + if err != nil { + return false + } + rightInfo, err := os.Stat(rightAbs) + if err != nil { + return false + } + return os.SameFile(leftInfo, rightInfo) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/cursor_mcp_auth_test.go b/apps/backend/internal/agent/runtime/lifecycle/cursor_mcp_auth_test.go new file mode 100644 index 0000000000..819ec423a9 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/cursor_mcp_auth_test.go @@ -0,0 +1,484 @@ +package lifecycle + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/mcpconfig" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" +) + +func TestPrepareCursorMCPAuthEligibility(t *testing.T) { + tests := []struct { + name string + executor string + profile *AgentProfileInfo + strategy mcpconfig.PassthroughMCPStrategy + homeOverride string + seedLink bool + wantLink bool + wantErr bool + }{ + { + name: "local Cursor strategy", + executor: string(models.ExecutorTypeLocal), + profile: &AgentProfileInfo{CursorMCPAuthEnabled: true}, + strategy: mcpconfig.CursorStrategy{}, + wantLink: true, + }, + { + name: "disabled profile removes owned link", + executor: string(models.ExecutorTypeWorktree), + profile: &AgentProfileInfo{CursorMCPAuthEnabled: false}, + strategy: mcpconfig.CursorStrategy{}, + seedLink: true, + }, + { + name: "remote execution is excluded", + executor: string(models.ExecutorTypeMockRemote), + profile: &AgentProfileInfo{CursorMCPAuthEnabled: true}, + strategy: mcpconfig.CursorStrategy{}, + }, + { + name: "unknown execution locality is excluded", + profile: &AgentProfileInfo{CursorMCPAuthEnabled: true}, + strategy: mcpconfig.CursorStrategy{}, + }, + { + name: "other MCP strategy is excluded", + executor: string(models.ExecutorTypeLocal), + profile: &AgentProfileInfo{CursorMCPAuthEnabled: true}, + strategy: mcpconfig.PiStrategy{}, + }, + { + name: "unresolved profile fails closed for local Cursor", + executor: string(models.ExecutorTypeLocal), + strategy: mcpconfig.CursorStrategy{}, + seedLink: true, + wantLink: true, + wantErr: true, + }, + { + name: "unresolved preference remains a no-op for remote execution", + executor: string(models.ExecutorTypeMockRemote), + strategy: mcpconfig.CursorStrategy{}, + seedLink: true, + wantLink: true, + }, + { + name: "different runtime home is excluded", + executor: string(models.ExecutorTypeLocal), + profile: &AgentProfileInfo{CursorMCPAuthEnabled: true}, + strategy: mcpconfig.CursorStrategy{}, + homeOverride: filepath.Join(t.TempDir(), "other-home"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + workspace := t.TempDir() + execution := &AgentExecution{WorkspacePath: workspace} + if tc.homeOverride != "" { + execution.setRuntimeEnvironment(map[string]string{"HOME": tc.homeOverride}) + } + cursorHome := filepath.Join(home, ".cursor") + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(filepath.Join(projects, "other-project"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projects, "other-project", "mcp-auth.json"), []byte(`{"mcp":{"token":"secret"}}`), 0o600); err != nil { + t.Fatal(err) + } + + destination := cursorMCPAuthDestinationForTest(t, projects, workspace) + if tc.seedLink { + if err := mcpconfig.LinkCursorMCPAuth(workspace, cursorHome); err != nil { + t.Fatalf("create existing link: %v", err) + } + } + + mgr := newTestManager(t) + err := mgr.prepareCursorMCPAuth(execution, tc.profile, tc.executor, tc.strategy) + if tc.wantErr { + if err == nil { + t.Fatal("prepareCursorMCPAuth returned nil for unresolved local Cursor preference") + } + } else if err != nil { + t.Fatalf("prepareCursorMCPAuth: %v", err) + } + + _, err = os.Lstat(destination) + gotLink := err == nil + if gotLink != tc.wantLink { + t.Fatalf("destination exists = %v, want %v (err=%v)", gotLink, tc.wantLink, err) + } + }) + } +} + +func cursorMCPAuthDestinationForTest(t *testing.T, projects, workspace string) string { + t.Helper() + absPath, err := filepath.Abs(workspace) + if err != nil { + t.Fatal(err) + } + canonicalPath, err := filepath.EvalSymlinks(absPath) + if err != nil { + t.Fatal(err) + } + return filepath.Join(projects, mcpconfig.DeriveCursorProjectSlug(canonicalPath), "mcp-auth.json") +} + +type cursorMCPAuthProfileResolution struct { + profile *AgentProfileInfo + err error +} + +type cursorMCPAuthSequenceResolver struct { + results []cursorMCPAuthProfileResolution + calls int +} + +func (r *cursorMCPAuthSequenceResolver) ResolveProfile(context.Context, string) (*AgentProfileInfo, error) { + if r.calls >= len(r.results) { + return nil, fmt.Errorf("unexpected profile resolver call %d", r.calls+1) + } + result := r.results[r.calls] + r.calls++ + return result.profile, result.err +} + +func TestResumePassthroughSessionAppliesCursorOptOutBeforeStartingProcess(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-backed process assertion is not available on Windows") + } + home := t.TempDir() + t.Setenv("HOME", home) + mgr, runner, execution := newPassthroughRunnerManager(t) + t.Cleanup(func() { + if execution.PassthroughProcessID != "" { + _ = runner.Stop(context.Background(), execution.PassthroughProcessID) + } + }) + execution.ExecutorType = string(models.ExecutorTypeLocal) + + workspace := execution.WorkspacePath + projects := filepath.Join(home, ".cursor", "projects") + source := filepath.Join(projects, "source-project", "mcp-auth.json") + if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(source, []byte(`{"figma":{"token":"opaque"}}`), 0o600); err != nil { + t.Fatal(err) + } + if err := mcpconfig.LinkCursorMCPAuth(workspace, filepath.Join(home, ".cursor")); err != nil { + t.Fatalf("seed existing bridge link: %v", err) + } + destination := cursorMCPAuthDestinationForTest(t, projects, workspace) + if _, err := os.Readlink(destination); err != nil { + t.Fatalf("expected existing bridge link: %v", err) + } + + const agentName = "cursor-auth-fail-closed-test" + if err := mgr.registry.Register(&testAgent{ + id: agentName, + StandardPassthrough: agents.StandardPassthrough{Cfg: agents.PassthroughConfig{ + Supported: true, + PassthroughCmd: agents.NewCommand("sh", "-c", "sleep 30"), + MCPStrategy: mcpconfig.CursorStrategy{}, + }}, + }); err != nil { + t.Fatalf("register Cursor terminal test agent: %v", err) + } + profile := &AgentProfileInfo{ + ProfileID: execution.AgentProfileID, + AgentName: agentName, + CursorMCPAuthEnabled: false, + } + // The intermediate error models a transient duplicate launch-profile lookup. + // The following success lets the old path reach PTY startup after it has + // discarded the disabled preference; the combined lookup must avoid that. + resolver := &cursorMCPAuthSequenceResolver{results: []cursorMCPAuthProfileResolution{ + {profile: profile}, + {err: fmt.Errorf("transient second profile lookup failure")}, + {profile: profile}, + }} + mgr.profileResolver = resolver + + err := mgr.ResumePassthroughSession(context.Background(), execution.SessionID) + if err == nil || !strings.Contains(err.Error(), "transient second profile lookup failure") { + t.Fatalf("ResumePassthroughSession error = %v, want profile environment lookup failure", err) + } + if resolver.calls != 2 { + t.Fatalf("profile resolver calls = %d, want 2 (one launch-profile lookup and one environment lookup)", resolver.calls) + } + if execution.PassthroughProcessID != "" { + t.Fatalf("passthrough process started despite profile environment lookup failure: %s", execution.PassthroughProcessID) + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("resolved opt-out was not applied before launch failure: %v", err) + } +} + +func TestPrepareCursorMCPAuthDoesNotCreateMissingCursorHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + mgr := newTestManager(t) + if err := mgr.prepareCursorMCPAuth( + &AgentExecution{WorkspacePath: t.TempDir()}, + &AgentProfileInfo{CursorMCPAuthEnabled: true}, + string(models.ExecutorTypeLocal), + mcpconfig.CursorStrategy{}, + ); err != nil { + t.Fatalf("prepareCursorMCPAuth: %v", err) + } + if _, err := os.Lstat(filepath.Join(home, ".cursor")); !os.IsNotExist(err) { + t.Fatalf("missing Cursor home was created or produced an unexpected error: %v", err) + } +} + +func TestApplyPassthroughMCPPreparesCursorAuth(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + workspace := t.TempDir() + cursorProjects := filepath.Join(home, ".cursor", "projects") + if err := os.MkdirAll(filepath.Join(cursorProjects, "other-project"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cursorProjects, "other-project", "mcp-auth.json"), []byte(`{"figma":{"token":"opaque"}}`), 0o600); err != nil { + t.Fatal(err) + } + + mgr := newTestManager(t) + execution := &AgentExecution{ + ID: "exec-terminal-cursor", + AgentProfileID: "profile-cursor-enabled", + WorkspacePath: workspace, + ExecutorType: string(models.ExecutorTypeWorktree), + standalonePort: 45678, + } + _, err := mgr.applyPassthroughMCP( + context.Background(), execution, + agents.PassthroughConfig{MCPStrategy: mcpconfig.CursorStrategy{}}, nil, + &AgentProfileInfo{CursorMCPAuthEnabled: true}, + ) + if err != nil { + t.Fatalf("applyPassthroughMCP: %v", err) + } + + destination := cursorMCPAuthDestinationForTest(t, cursorProjects, workspace) + if _, err := os.Readlink(destination); err != nil { + t.Fatalf("terminal Cursor auth link was not prepared: %v", err) + } +} + +func TestResumePassthroughCommandPreparesCursorAuth(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + workspace := t.TempDir() + cursorProjects := filepath.Join(home, ".cursor", "projects") + if err := os.MkdirAll(filepath.Join(cursorProjects, "source-project"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cursorProjects, "source-project", "mcp-auth.json"), []byte(`{"figma":{"token":"opaque"}}`), 0o600); err != nil { + t.Fatal(err) + } + + terminalAgent := &testAgent{ + id: "cursor-terminal", + StandardPassthrough: agents.StandardPassthrough{Cfg: agents.PassthroughConfig{ + Supported: true, + PassthroughCmd: agents.NewCommand("cursor"), + MCPStrategy: mcpconfig.CursorStrategy{}, + }}, + } + execution := &AgentExecution{ + ID: "exec-cursor-resume", + WorkspacePath: workspace, + ExecutorType: string(models.ExecutorTypeLocal), + standalonePort: 45678, + } + resolved := &resolvedPassthrough{ + agentConfig: terminalAgent, + agent: terminalAgent, + pt: terminalAgent.PassthroughConfig(), + profile: &AgentProfileInfo{CursorMCPAuthEnabled: true}, + } + + mgr := newTestManager(t) + if _, err := mgr.resumePassthroughCommand(context.Background(), execution, resolved, true); err != nil { + t.Fatalf("resumePassthroughCommand: %v", err) + } + destination := cursorMCPAuthDestinationForTest(t, cursorProjects, workspace) + if _, err := os.Readlink(destination); err != nil { + t.Fatalf("resumed terminal Cursor auth link was not prepared: %v", err) + } +} + +func TestPrepareCursorMCPAuthFailurePolicy(t *testing.T) { + t.Run("enabled publication failure does not block project MCP setup", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.MkdirAll(filepath.Join(home, ".cursor"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".cursor", "projects"), []byte("blocked"), 0o600); err != nil { + t.Fatal(err) + } + mgr := newTestManager(t) + agentConfig, ok := mgr.registry.Get("cursor-acp") + if !ok { + t.Fatal("cursor-acp agent missing from test registry") + } + err := mgr.materializeRuntimeProjectMCP( + context.Background(), + &AgentExecution{ID: "exec-cursor", WorkspacePath: t.TempDir()}, + agentConfig, + &AgentProfileInfo{CursorMCPAuthEnabled: true}, + string(models.ExecutorTypeLocal), + ) + if err != nil { + t.Fatalf("enabled bridge failure blocked normal project MCP setup: %v", err) + } + }) + + t.Run("disabled cleanup failure is sanitized and blocks launch", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.MkdirAll(filepath.Join(home, ".cursor"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".cursor", "projects"), []byte("blocked"), 0o600); err != nil { + t.Fatal(err) + } + mgr := newTestManager(t) + err := mgr.prepareCursorMCPAuth( + &AgentExecution{WorkspacePath: t.TempDir()}, + &AgentProfileInfo{CursorMCPAuthEnabled: false}, + string(models.ExecutorTypeLocal), + mcpconfig.CursorStrategy{}, + ) + if err == nil { + t.Fatal("expected an error when disabled cleanup cannot inspect the Cursor project path") + } + if got, want := err.Error(), "failed to remove shared Cursor MCP credentials"; got != want { + t.Fatalf("error = %q, want sanitized %q", got, want) + } + if strings.Contains(err.Error(), home) { + t.Fatalf("sanitized error contains the user home path: %q", err) + } + }) +} + +func TestMaterializeRuntimeProjectMCPPreparesCursorAuthWithoutServers(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + workspace := t.TempDir() + cursorProjects := filepath.Join(home, ".cursor", "projects") + if err := os.MkdirAll(filepath.Join(cursorProjects, "other-project"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cursorProjects, "other-project", "mcp-auth.json"), []byte(`{"figma":{"token":"opaque"}}`), 0o600); err != nil { + t.Fatal(err) + } + + mgr := newTestManager(t) + execution := &AgentExecution{ + ID: "exec-cursor-empty-mcp", + WorkspacePath: workspace, + RuntimeName: "standalone", + ExecutorType: string(models.ExecutorTypeLocal), + AgentProfileID: "profile-cursor-enabled", + } + agentConfig, ok := mgr.registry.Get("cursor-acp") + if !ok { + t.Fatal("cursor-acp agent missing from test registry") + } + + if err := mgr.materializeRuntimeProjectMCP( + context.Background(), execution, agentConfig, + &AgentProfileInfo{CursorMCPAuthEnabled: true}, string(models.ExecutorTypeLocal), + ); err != nil { + t.Fatalf("materializeRuntimeProjectMCP: %v", err) + } + + destination := cursorMCPAuthDestinationForTest(t, cursorProjects, workspace) + target, err := os.Readlink(destination) + if err != nil { + t.Fatalf("Cursor auth link was not prepared before the empty-server return: %v", err) + } + if target != filepath.Join(home, ".cursor", "kandev-mcp-auth-unified.json") { + t.Fatalf("link target = %q", target) + } +} + +func TestSameCursorMCPAuthHomeRecognizesSymlinkAlias(t *testing.T) { + actualHome := t.TempDir() + alias := filepath.Join(t.TempDir(), "home-alias") + if err := os.Symlink(actualHome, alias); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + if !sameCursorMCPAuthHome(actualHome, alias) { + t.Fatalf("sameCursorMCPAuthHome(%q, %q) = false, want true", actualHome, alias) + } +} + +func TestPrepareCursorMCPAuthExcludesConfiguredTaskWorktrees(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cursorProjects := filepath.Join(home, ".cursor", "projects") + taskRoot := filepath.Join(t.TempDir(), "custom-task-storage") + worktreeManager, err := worktree.NewManager(worktree.Config{TasksBasePath: taskRoot}, nil, nil) + if err != nil { + t.Fatal(err) + } + + taskWorkspace := filepath.Join(taskRoot, "task-123", "repository") + if err := os.MkdirAll(taskWorkspace, 0o755); err != nil { + t.Fatal(err) + } + taskAuthPath := cursorMCPAuthDestinationForTest(t, cursorProjects, taskWorkspace) + if err := os.MkdirAll(filepath.Dir(taskAuthPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(taskAuthPath, []byte(`{"task-secret":{"token":"excluded"}}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(cursorProjects, "ordinary-project"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cursorProjects, "ordinary-project", "mcp-auth.json"), []byte(`{"ordinary":{"token":"included"}}`), 0o600); err != nil { + t.Fatal(err) + } + + mgr := newTestManager(t) + mgr.worktreeMgr = worktreeManager + if err := mgr.prepareCursorMCPAuth( + &AgentExecution{WorkspacePath: t.TempDir(), ExecutorType: string(models.ExecutorTypeLocal)}, + &AgentProfileInfo{CursorMCPAuthEnabled: true}, + string(models.ExecutorTypeLocal), + mcpconfig.CursorStrategy{}, + ); err != nil { + t.Fatalf("prepareCursorMCPAuth: %v", err) + } + + master, err := os.ReadFile(filepath.Join(home, ".cursor", "kandev-mcp-auth-unified.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(master), "task-secret") { + t.Fatalf("configured task auth leaked into shared file: %s", master) + } + if !strings.Contains(string(master), "ordinary") { + t.Fatalf("ordinary project auth was excluded: %s", master) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index 54f2eb5263..15c734edb5 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -194,6 +194,7 @@ const ( MetadataKeyRemoteContributions = "remote_contributions" MetadataKeyContributionDestinations = "contribution_destinations" MetadataKeyComparisonTargets = "comparison_targets" + MetadataKeyExecutorType = "executor_type" MetadataKeyIsRemote = "is_remote" MetadataKeyRemoteAuthHome = "remote_auth_target_home" MetadataKeyAgentConfigBundles = "agent_config_bundles" @@ -375,7 +376,8 @@ var persistentMetadataKeys = map[string]bool{ MetadataKeyKubernetesInventoryState: true, // Executor type marker - MetadataKeyIsRemote: true, + MetadataKeyExecutorType: true, + MetadataKeyIsRemote: true, // Executor profile / auth config MetadataKeyCleanupScript: true, @@ -591,7 +593,10 @@ type RemoteInstanceRefresher interface { // ExecutorCreateRequest contains parameters for creating an agentctl instance. type ExecutorCreateRequest struct { - InstanceID string + InstanceID string + // ExecutorType is retained in execution metadata so recovered sessions can + // safely re-check host-local filesystem eligibility. + ExecutorType string TaskID string TaskTitle string SessionID string @@ -732,6 +737,12 @@ func (ri *ExecutorInstance) ToAgentExecution(req *ExecutorCreateRequest) *AgentE for k, v := range ri.Metadata { metadata[k] = v } + executorType := req.ExecutorType + if executorType != "" { + metadata[MetadataKeyExecutorType] = executorType + } else { + delete(metadata, MetadataKeyExecutorType) + } workspacePath := ri.WorkspacePath if workspacePath == "" { @@ -750,6 +761,7 @@ func (ri *ExecutorInstance) ToAgentExecution(req *ExecutorCreateRequest) *AgentE execution := &AgentExecution{ ID: ri.InstanceID, + ExecutorType: executorType, RunID: req.Env["KANDEV_RUN_ID"], TaskID: req.TaskID, SessionID: req.SessionID, diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend_test.go index f25917f788..f54e527666 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/task/models" "github.com/stretchr/testify/require" ) @@ -149,6 +150,16 @@ func TestToAgentExecutionCapturesDefensiveRuntimeEnvironment(t *testing.T) { require.Equal(t, "/tmp/kandev-shim:/usr/bin", execution.RuntimeEnvironment()["PATH"]) } +func TestToAgentExecutionCarriesExecutorTypeForLocalityChecks(t *testing.T) { + execution := (&ExecutorInstance{InstanceID: "execution"}).ToAgentExecution(&ExecutorCreateRequest{ + ExecutorType: string(models.ExecutorTypeWorktree), + }) + + require.Equal(t, string(models.ExecutorTypeWorktree), execution.ExecutorType) + require.Equal(t, string(models.ExecutorTypeWorktree), execution.metadataString(MetadataKeyExecutorType)) + require.True(t, ShouldPersistMetadataKey(MetadataKeyExecutorType)) +} + func TestToAgentExecutionCapturesRunID(t *testing.T) { execution := (&ExecutorInstance{InstanceID: "execution"}).ToAgentExecution(&ExecutorCreateRequest{ Env: map[string]string{"KANDEV_RUN_ID": "run-1"}, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go index 02268d692d..38abad48b1 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go @@ -1035,6 +1035,7 @@ func (m *Manager) prepareExecutionCreateRequest( preparation := &executionCreatePreparation{ request: &ExecutorCreateRequest{ InstanceID: executionID, + ExecutorType: info.ExecutorType, TaskID: taskID, SessionID: info.SessionID, TaskEnvironmentID: info.TaskEnvironmentID, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index 49034cb602..f60ce1cbb8 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -1389,6 +1389,11 @@ func (m *Manager) restartAgentProcess( if err != nil { return err } + if runtime := preparation.agentConfig.Runtime(); runtime != nil { + if err := m.prepareCursorMCPAuth(execution, preparation.profileInfo, execution.ExecutorType, runtime.ProjectMCPStrategy); err != nil { + return err + } + } // 1. Close WebSocket streams (updates + workspace). Use per-stream Close // methods rather than client.Close — the latter is a terminal drain @@ -1490,6 +1495,7 @@ func (m *Manager) stopAgentProcessForRestart(ctx context.Context, execution *Age type agentRestartPreparation struct { agentConfig agents.Agent + profileInfo *AgentProfileInfo commands agentCommands runtimeConfig models.SessionRuntimeConfig } @@ -1505,11 +1511,11 @@ func (m *Manager) prepareAgentRestart( zap.String("task_id", execution.TaskID), zap.String("session_id", execution.SessionID)) - agentConfig, err := m.getAgentConfigForExecution(execution) + agentConfig, profileInfo, err := m.getAgentConfigAndProfileForExecution(ctx, execution) if err != nil { return agentRestartPreparation{}, fmt.Errorf("failed to get agent config for restart: %w", err) } - commands, err := m.buildFreshAgentCommand(ctx, execution, agentConfig) + commands, err := m.buildFreshAgentCommandWithProfile(ctx, execution, agentConfig, profileInfo) if err != nil { return agentRestartPreparation{}, fmt.Errorf("failed to rebuild agent command for restart: %w", err) } @@ -1521,6 +1527,7 @@ func (m *Manager) prepareAgentRestart( } return agentRestartPreparation{ agentConfig: agentConfig, + profileInfo: profileInfo, commands: commands, runtimeConfig: runtimeConfig, }, nil @@ -2714,7 +2721,15 @@ func (m *Manager) buildFreshAgentCommand(ctx context.Context, execution *AgentEx } profileInfo = pi } + return m.buildFreshAgentCommandWithProfile(ctx, execution, agentConfig, profileInfo) +} +func (m *Manager) buildFreshAgentCommandWithProfile( + ctx context.Context, + execution *AgentExecution, + agentConfig agents.Agent, + profileInfo *AgentProfileInfo, +) (agentCommands, error) { model := "" autoApprove := false permissionValues := make(map[string]bool) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go index 59606f4b5e..891f97ee87 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go @@ -7,6 +7,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "slices" "sync" "testing" @@ -17,6 +19,7 @@ import ( "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/executor" + "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/runtime/activity" agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agentctl/server/process" @@ -581,6 +584,116 @@ func TestManager_RestartAgentProcess_Success(t *testing.T) { } } +func TestManager_RestartAgentProcessAppliesCursorAuthPreference(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + workspace := t.TempDir() + cursorHome := filepath.Join(home, ".cursor") + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(filepath.Join(projects, "source-project"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projects, "source-project", "mcp-auth.json"), []byte(`{"figma":{"token":"opaque"}}`), 0o600); err != nil { + t.Fatal(err) + } + if err := mcpconfig.LinkCursorMCPAuth(workspace, cursorHome); err != nil { + t.Fatalf("create existing bridge link: %v", err) + } + + mgr := newTestManager(t) + mgr.profileResolver = &restartProfileResolver{profile: &AgentProfileInfo{ + ProfileID: "cursor-profile", + AgentName: "cursor-acp", + CursorMCPAuthEnabled: false, + }} + mock := newRestartMockAgentctlServer(t, false, false) + mock.newModelState = restartDefaultModelState() + client := createTestClient(t, mock.server.URL) + t.Cleanup(client.Close) + + execution := &AgentExecution{ + ID: "exec-cursor-restart", + TaskID: "task-1", + SessionID: "session-cursor-restart", + AgentProfileID: "cursor-profile", + ExecutorType: "local", + ACPSessionID: "old-session", + AgentCommand: "cursor-acp", + Status: v1.AgentStatusRunning, + WorkspacePath: workspace, + agentctl: client, + promptDoneCh: make(chan PromptCompletionSignal, 1), + } + require.NoError(t, mgr.executionStore.Add(execution)) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + require.NoError(t, mgr.RestartAgentProcess(ctx, execution.ID)) + + destination := cursorMCPAuthDestinationForTest(t, projects, workspace) + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("disabled preference was not applied before process restart, lstat err=%v", err) + } +} + +func TestManager_PromptOnRunningCursorSessionKeepsLoadedAuthLink(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + workspace := t.TempDir() + cursorHome := filepath.Join(home, ".cursor") + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(filepath.Join(projects, "source-project"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projects, "source-project", "mcp-auth.json"), []byte(`{"figma":{"token":"opaque"}}`), 0o600); err != nil { + t.Fatal(err) + } + if err := mcpconfig.LinkCursorMCPAuth(workspace, cursorHome); err != nil { + t.Fatalf("create existing bridge link: %v", err) + } + + mgr := newTestManager(t) + mgr.profileResolver = &restartProfileResolver{profile: &AgentProfileInfo{ + ProfileID: "cursor-profile", + AgentName: "cursor-acp", + CursorMCPAuthEnabled: false, + }} + mock := newRestartMockAgentctlServer(t, false, false) + client := createTestClient(t, mock.server.URL) + t.Cleanup(client.Close) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + require.NoError(t, client.StreamUpdates(ctx, func(agentctl.AgentEvent) {}, nil, nil)) + + execution := &AgentExecution{ + ID: "exec-cursor-existing", + TaskID: "task-1", + SessionID: "session-cursor-existing", + AgentProfileID: "cursor-profile", + ExecutorType: "local", + ACPSessionID: "existing-session", + AgentCommand: "cursor-acp", + AgentID: "cursor-acp", + Status: v1.AgentStatusRunning, + WorkspacePath: workspace, + sessionInitialized: true, + agentctl: client, + promptDoneCh: make(chan PromptCompletionSignal, 1), + } + require.NoError(t, mgr.executionStore.Add(execution)) + + if _, err := mgr.PromptAgent(ctx, execution.ID, "continue current session", nil, true); err != nil { + t.Fatalf("PromptAgent: %v", err) + } + destination := cursorMCPAuthDestinationForTest(t, projects, workspace) + if _, err := os.Readlink(destination); err != nil { + t.Fatalf("running session auth link was changed by a prompt: %v", err) + } + if !slices.Contains(mock.getWSActions(), "agent.prompt") { + t.Fatalf("follow-up prompt did not reach running Cursor session: %v", mock.getWSActions()) + } +} + func TestManager_RestartAgentProcess_InvalidReplacementLeavesCurrentProcessUntouched(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index 3bb0df28f9..d94951355d 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -1057,6 +1057,7 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st execReq := &ExecutorCreateRequest{ InstanceID: executionID, + ExecutorType: reqWithWorktree.ExecutorType, TaskID: reqWithWorktree.TaskID, TaskTitle: reqWithWorktree.TaskTitle, SessionID: launchInventorySessionID(reqWithWorktree), @@ -1524,7 +1525,11 @@ func (m *Manager) promoteWorkspaceExecution(ctx context.Context, execution *Agen } execution.IsPassthrough = req.IsPassthrough if !req.IsPassthrough { - if err := m.materializeRuntimeProjectMCP(sharedCtx, execution, agentConfig); err != nil { + executorType := req.ExecutorType + if executorType == "" { + executorType = execution.ExecutorType + } + if err := m.materializeRuntimeProjectMCP(sharedCtx, execution, agentConfig, profileInfo, executorType); err != nil { execution.AgentCommand = "" execution.ContinueCommand = "" execution.AgentArgs = nil @@ -1718,7 +1723,7 @@ func (m *Manager) launchInternal(ctx context.Context, req *LaunchRequest) (*Agen return nil, err } if !reqWithWorktree.IsPassthrough { - if err := m.materializeRuntimeProjectMCP(ctx, execution, agentConfig); err != nil { + if err := m.materializeRuntimeProjectMCP(ctx, execution, agentConfig, profileInfo, reqWithWorktree.ExecutorType); err != nil { m.rollbackLaunchExecution(ctx, rt, execInstance, execution, "project MCP materialization failed") return nil, err } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go index 157d0aa292..56095606fa 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go @@ -212,6 +212,7 @@ func (m *Manager) Start(ctx context.Context) error { // execution-profile column, carried onto ri by // buildRecoveredInstances -- never the adopted instance. AgentProfileID: ri.AgentProfileID, + ExecutorType: getMetadataString(ri.Metadata, MetadataKeyExecutorType), ContainerID: ri.ContainerID, ContainerIP: ri.ContainerIP, WorkspacePath: ri.WorkspacePath, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go index 2cdaa40a8d..3286576402 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go @@ -256,7 +256,7 @@ type resolvedPassthrough struct { // resolvePassthroughAgent loads the agent config and profile for a passthrough execution. // Shared by passthroughAgentCommand, freshPassthroughCommand, and ResumePassthroughSession. func (m *Manager) resolvePassthroughAgent(ctx context.Context, execution *AgentExecution) (*resolvedPassthrough, error) { - agentConfig, err := m.getAgentConfigForExecution(execution) + agentConfig, profileInfo, err := m.getAgentConfigAndProfileForExecution(ctx, execution) if err != nil { return nil, fmt.Errorf("failed to get agent config: %w", err) } @@ -266,10 +266,6 @@ func (m *Manager) resolvePassthroughAgent(ctx context.Context, execution *AgentE return nil, fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID()) } - var profileInfo *AgentProfileInfo - if m.profileResolver != nil && execution.AgentProfileID != "" { - profileInfo, _ = m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID) - } if err := validatePassthroughProvider(profileInfo); err != nil { return nil, err } @@ -392,10 +388,19 @@ func safePassthroughMCPConfigName(value string) string { // strategy's env vars on the execution (merged later in buildPassthroughEnv), // and returns the extra CLI args to append to the passthrough command. It is a // no-op for agents that declare no strategy. -func (m *Manager) applyPassthroughMCP(ctx context.Context, execution *AgentExecution, pt agents.PassthroughConfig, agentConfig agents.Agent) ([]string, error) { +func (m *Manager) applyPassthroughMCP( + ctx context.Context, + execution *AgentExecution, + pt agents.PassthroughConfig, + agentConfig agents.Agent, + profileInfo *AgentProfileInfo, +) ([]string, error) { if pt.MCPStrategy == nil { return nil, nil } + if err := m.prepareCursorMCPAuth(execution, profileInfo, execution.ExecutorType, pt.MCPStrategy); err != nil { + return nil, err + } // passthroughMCPServers always returns at least the kandev server (or an // error when the port is unavailable), so the strategy receives a non-empty // list; each strategy guards its own empty-after-filtering case. @@ -712,7 +717,7 @@ func (m *Manager) passthroughAgentCommand(ctx context.Context, execution *AgentE rt := agentConfig.Runtime() taskDescription := getTaskDescriptionFromMetadata(execution) promptForCmd := promptForPassthroughCommand(pt, taskDescription) - mcpArgs, err := m.applyPassthroughMCP(ctx, execution, pt, agentConfig) + mcpArgs, err := m.applyPassthroughMCP(ctx, execution, pt, agentConfig, profileInfo) if err != nil { return nil, agents.PassthroughConfig{}, nil, agents.Command{}, err } @@ -903,7 +908,7 @@ func (m *Manager) freshPassthroughCommand(ctx context.Context, execution *AgentE if err != nil { return agents.PassthroughConfig{}, nil, agents.Command{}, err } - mcpArgs, err := m.applyPassthroughMCP(ctx, execution, resolved.pt, resolved.agentConfig) + mcpArgs, err := m.applyPassthroughMCP(ctx, execution, resolved.pt, resolved.agentConfig, resolved.profile) if err != nil { return agents.PassthroughConfig{}, nil, agents.Command{}, err } @@ -922,7 +927,7 @@ func (m *Manager) freshPassthroughCommand(ctx context.Context, execution *AgentE } func (m *Manager) resumePassthroughCommand(ctx context.Context, execution *AgentExecution, resolved *resolvedPassthrough, useResume bool) (agents.Command, error) { - mcpArgs, err := m.applyPassthroughMCP(ctx, execution, resolved.pt, resolved.agentConfig) + mcpArgs, err := m.applyPassthroughMCP(ctx, execution, resolved.pt, resolved.agentConfig, resolved.profile) if err != nil { return agents.Command{}, err } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go index 68639e0683..bac2ba9853 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go @@ -24,12 +24,26 @@ type mockPassthroughProfileResolver struct { envVars []settingsmodels.ProfileEnvVar err error agentName string + profile *AgentProfileInfo + profiles map[string]*AgentProfileInfo + resolvedIDs []string } func (m *mockPassthroughProfileResolver) ResolveProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) { + m.resolvedIDs = append(m.resolvedIDs, profileID) if m.err != nil { return nil, m.err } + if profile := m.profiles[profileID]; profile != nil { + resolved := *profile + resolved.ProfileID = profileID + return &resolved, nil + } + if m.profile != nil { + resolved := *m.profile + resolved.ProfileID = profileID + return &resolved, nil + } return &AgentProfileInfo{ ProfileID: profileID, AgentName: m.agentName, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_profile.go b/apps/backend/internal/agent/runtime/lifecycle/manager_profile.go index caf0fda1f2..f12b4fe84b 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_profile.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_profile.go @@ -28,26 +28,34 @@ func (m *Manager) ResolveAgentProfile(ctx context.Context, profileID string) (*A // getAgentConfigForExecution retrieves the agent configuration for an execution. // The execution must have AgentCommand set (which includes the agent type). func (m *Manager) getAgentConfigForExecution(execution *AgentExecution) (agents.Agent, error) { + agentConfig, _, err := m.getAgentConfigAndProfileForExecution(context.Background(), execution) + return agentConfig, err +} + +func (m *Manager) getAgentConfigAndProfileForExecution(ctx context.Context, execution *AgentExecution) (agents.Agent, *AgentProfileInfo, error) { if execution.AgentProfileID == "" { - return nil, fmt.Errorf("execution %s has no agent profile ID", execution.ID) + return nil, nil, fmt.Errorf("execution %s has no agent profile ID", execution.ID) } if m.profileResolver == nil { - return nil, fmt.Errorf("profile resolver not configured") + return nil, nil, fmt.Errorf("profile resolver not configured") } - profileInfo, err := m.profileResolver.ResolveProfile(context.Background(), execution.AgentProfileID) + profileInfo, err := m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID) if err != nil { - return nil, fmt.Errorf("failed to resolve profile: %w", err) + return nil, nil, fmt.Errorf("failed to resolve profile: %w", err) + } + if profileInfo == nil { + return nil, nil, errors.New("failed to resolve profile: empty profile") } agentTypeName := profileInfo.AgentName agentConfig, ok := m.registry.Get(agentTypeName) if !ok { - return nil, fmt.Errorf("agent type not found: %s", agentTypeName) + return nil, nil, fmt.Errorf("agent type not found: %s", agentTypeName) } - return agentConfig, nil + return agentConfig, profileInfo, nil } // resolveMcpServers centralizes MCP resolution for a session: diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_project_mcp.go b/apps/backend/internal/agent/runtime/lifecycle/manager_project_mcp.go index c9c4dfb593..7a289909f8 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_project_mcp.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_project_mcp.go @@ -12,7 +12,13 @@ import ( // materializeRuntimeProjectMCP writes project-local MCP config for protocol-mode // agents whose underlying CLI does not consume ACP session/new mcpServers. -func (m *Manager) materializeRuntimeProjectMCP(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent) error { +func (m *Manager) materializeRuntimeProjectMCP( + ctx context.Context, + execution *AgentExecution, + agentConfig agents.Agent, + profileInfo *AgentProfileInfo, + executorType string, +) error { if execution == nil || agentConfig == nil { return nil } @@ -20,6 +26,9 @@ func (m *Manager) materializeRuntimeProjectMCP(ctx context.Context, execution *A if rt == nil || rt.ProjectMCPStrategy == nil { return nil } + if err := m.prepareCursorMCPAuth(execution, profileInfo, executorType, rt.ProjectMCPStrategy); err != nil { + return err + } servers, err := m.runtimeProjectMCPServers(ctx, execution, agentConfig) if err != nil { return err diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_project_mcp_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_project_mcp_test.go index 5cdba6d1ab..e313f6d55b 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_project_mcp_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_project_mcp_test.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/kandev/kandev/internal/agent/mcpconfig" ) func TestMaterializeRuntimeProjectMCPForPiWritesProjectFile(t *testing.T) { @@ -24,7 +26,7 @@ func TestMaterializeRuntimeProjectMCPForPiWritesProjectFile(t *testing.T) { t.Fatal("pi-acp agent missing from test registry") } - if err := mgr.materializeRuntimeProjectMCP(context.Background(), execution, agentConfig); err != nil { + if err := mgr.materializeRuntimeProjectMCP(context.Background(), execution, agentConfig, nil, ""); err != nil { t.Fatalf("materializeRuntimeProjectMCP: %v", err) } @@ -66,7 +68,7 @@ func TestMaterializeRuntimeProjectMCPForCursorWritesProjectFile(t *testing.T) { t.Fatal("cursor-acp agent missing from test registry") } - if err := mgr.materializeRuntimeProjectMCP(context.Background(), execution, agentConfig); err != nil { + if err := mgr.materializeRuntimeProjectMCP(context.Background(), execution, agentConfig, nil, ""); err != nil { t.Fatalf("materializeRuntimeProjectMCP: %v", err) } @@ -103,7 +105,7 @@ func TestMaterializeRuntimeProjectMCPSkipsWhenPortUnavailable(t *testing.T) { t.Fatal("pi-acp agent missing from test registry") } - if err := mgr.materializeRuntimeProjectMCP(context.Background(), execution, agentConfig); err != nil { + if err := mgr.materializeRuntimeProjectMCP(context.Background(), execution, agentConfig, nil, ""); err != nil { t.Fatalf("materializeRuntimeProjectMCP: %v", err) } @@ -154,3 +156,56 @@ func TestPromoteWorkspaceExecutionResetsCommandWhenProjectMCPFails(t *testing.T) t.Fatal("IsPassthrough should be reset after failed promotion") } } + +func TestPromoteWorkspaceExecutionUsesResolvedExecutionProfileForCursorAuth(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + workspace := t.TempDir() + cursorHome := filepath.Join(home, ".cursor") + projects := filepath.Join(cursorHome, "projects") + if err := os.MkdirAll(filepath.Join(projects, "source-project"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projects, "source-project", "mcp-auth.json"), []byte(`{"figma":{"token":"opaque"}}`), 0o600); err != nil { + t.Fatal(err) + } + if err := mcpconfig.LinkCursorMCPAuth(workspace, cursorHome); err != nil { + t.Fatalf("create existing bridge link: %v", err) + } + + resolver := &mockPassthroughProfileResolver{ + profiles: map[string]*AgentProfileInfo{ + "office-stable-profile": {AgentName: "cursor-acp", CursorMCPAuthEnabled: true}, + "cursor-execution-profile": {AgentName: "cursor-acp", CursorMCPAuthEnabled: false}, + }, + } + mgr := newTestManager(t) + mgr.profileResolver = resolver + execution := &AgentExecution{ + ID: "exec-1", + TaskID: "task-1", + SessionID: "session-1", + WorkspacePath: workspace, + ExecutorType: "worktree", + metadata: map[string]interface{}{}, + standalonePort: 45678, + } + req := &LaunchRequest{ + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: "office-stable-profile", + ExecutionProfileID: "cursor-execution-profile", + ExecutorType: "worktree", + } + + if err := mgr.promoteWorkspaceExecution(context.Background(), execution, req); err != nil { + t.Fatalf("promoteWorkspaceExecution: %v", err) + } + if len(resolver.resolvedIDs) != 1 || resolver.resolvedIDs[0] != "cursor-execution-profile" { + t.Fatalf("resolved profile IDs = %v, want only cursor execution profile", resolver.resolvedIDs) + } + destination := cursorMCPAuthDestinationForTest(t, projects, workspace) + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("resolved disabled execution profile kept shared link, lstat err=%v", err) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go index 6b084c00f7..b2f9bf27cb 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go +++ b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go @@ -99,6 +99,7 @@ func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID str ProviderBaseURL: profile.ProviderBaseURL, ProviderAPIKeySecretID: profile.ProviderAPIKeySecretID, CLIPassthrough: profile.CLIPassthrough, + CursorMCPAuthEnabled: profile.CursorMCPAuthEnabled, NativeSessionResume: nativeSessionResume, SupportsMCP: agent.SupportsMCP, }, nil diff --git a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver_test.go b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver_test.go index f0dc2e894f..0f30b6ee65 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver_test.go @@ -160,6 +160,7 @@ func TestStoreProfileResolver_ResolveProfile_Success(t *testing.T) { AgentID: "agent-456", Name: "My Profile", Model: "claude-3.5-sonnet", + CursorMCPAuthEnabled: true, AutoApprove: true, DangerouslySkipPermissions: false, }, nil @@ -189,6 +190,9 @@ func TestStoreProfileResolver_ResolveProfile_Success(t *testing.T) { if info.ProfileName != "My Profile" { t.Errorf("expected ProfileName 'My Profile', got '%s'", info.ProfileName) } + if !info.CursorMCPAuthEnabled { + t.Error("Cursor MCP auth preference was not resolved") + } if info.AgentID != "agent-456" { t.Errorf("expected AgentID 'agent-456', got '%s'", info.AgentID) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index e850d38835..e34588e9be 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -48,6 +48,9 @@ type AgentExecution struct { SessionID string TaskEnvironmentID string // Env owning this execution; sessions in the same task share one env WorkspaceID string + // ExecutorType preserves launch locality for features that must only access + // the backend user's host filesystem. Empty means locality is unknown. + ExecutorType string // AgentProfileID is the concrete profile used by the running CLI. The // historical name is retained inside lifecycle because profile resolution, // MCP, env, and command construction all consume this value. @@ -1394,11 +1397,12 @@ type AgentProfileInfo struct { AutoFallback bool // RequireExactModel makes the configured model an explicit identity // requirement. False preserves compatible pre-PR behavior. - RequireExactModel bool - AllowIndexing bool // Deprecated: legacy, kept so existing call sites compile; launch path reads CLIFlags. - CLIPassthrough bool - NativeSessionResume bool // Agent supports ACP session/load for resume - SupportsMCP bool + RequireExactModel bool + AllowIndexing bool // Deprecated: legacy, kept so existing call sites compile; launch path reads CLIFlags. + CLIPassthrough bool + CursorMCPAuthEnabled bool + NativeSessionResume bool // Agent supports ACP session/load for resume + SupportsMCP bool // CLIFlags is the resolved user-configurable list of CLI flags for this // profile. Passed verbatim to cliflags.Resolve at launch time. CLIFlags []settingsmodels.CLIFlag diff --git a/apps/backend/internal/agent/settings/controller/agent_crud.go b/apps/backend/internal/agent/settings/controller/agent_crud.go index f50f55cd86..ad5d7e236b 100644 --- a/apps/backend/internal/agent/settings/controller/agent_crud.go +++ b/apps/backend/internal/agent/settings/controller/agent_crud.go @@ -121,12 +121,13 @@ type CreateAgentRequest struct { } type CreateAgentProfileRequest struct { - Name string - Model string - FallbackModel string - AutoFallback bool - RequireExactModel bool - Mode string + Name string + Model string + FallbackModel string + AutoFallback bool + RequireExactModel bool + CursorMCPAuthEnabled *bool + Mode string // CLIFlags is the explicit list to persist. When nil the list is seeded // from the agent's curated PermissionSettings() catalogue (all disabled // by default) so a fresh profile opens with the agent's suggestions. @@ -271,17 +272,18 @@ func (c *Controller) createAgentProfiles(ctx context.Context, agentID, displayNa cliFlags = seedCLIFlags(agentConfig) } profile := &models.AgentProfile{ - AgentID: agentID, - Name: profileReq.Name, - AgentDisplayName: displayName, - Model: profileReq.Model, - FallbackModel: strings.TrimSpace(profileReq.FallbackModel), - AutoFallback: profileReq.AutoFallback, - RequireExactModel: profileReq.RequireExactModel, - Mode: profileReq.Mode, - CLIFlags: cliFlags, - EnvVars: envVarsFromDTO(profileReq.EnvVars), - CommandPrefix: strings.TrimSpace(profileReq.CommandPrefix), + AgentID: agentID, + Name: profileReq.Name, + AgentDisplayName: displayName, + Model: profileReq.Model, + FallbackModel: strings.TrimSpace(profileReq.FallbackModel), + AutoFallback: profileReq.AutoFallback, + RequireExactModel: profileReq.RequireExactModel, + CursorMCPAuthEnabled: cursorMCPAuthEnabled(profileReq.CursorMCPAuthEnabled), + Mode: profileReq.Mode, + CLIFlags: cliFlags, + EnvVars: envVarsFromDTO(profileReq.EnvVars), + CommandPrefix: strings.TrimSpace(profileReq.CommandPrefix), } if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { return nil, err diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery.go b/apps/backend/internal/agent/settings/controller/agent_discovery.go index 52cb89a83d..e41abb8746 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery.go @@ -474,6 +474,7 @@ func (c *Controller) createDefaultProfile(ctx context.Context, agentID string, p AllowIndexing: p.allowIndexing, DangerouslySkipPermissions: p.skipPermissions, CLIPassthrough: p.isPassthrough, + CursorMCPAuthEnabled: true, } return c.repo.CreateAgentProfile(ctx, defaultProfile) } diff --git a/apps/backend/internal/agent/settings/controller/cursor_mcp_auth_profile_test.go b/apps/backend/internal/agent/settings/controller/cursor_mcp_auth_profile_test.go new file mode 100644 index 0000000000..96f5549602 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/cursor_mcp_auth_profile_test.go @@ -0,0 +1,95 @@ +package controller + +import ( + "context" + "encoding/json" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/agent/settings/models" +) + +func TestCursorMCPAuthPreferenceCreatePatchAndDuplicate(t *testing.T) { + ctrl := newTestController(map[string]agents.Agent{"test-agent": &testAgent{ + id: "test-agent", name: "test-agent", displayName: "Test Agent", enabled: true, + }}) + st := newFakeStore() + agent := &models.Agent{ID: "agent-1", Name: "test-agent"} + st.agents[agent.ID] = agent + st.byName[agent.Name] = agent + ctrl.repo = st + + createdDefault, err := ctrl.CreateProfile(context.Background(), CreateProfileRequest{AgentID: agent.ID, Name: "default"}) + if err != nil { + t.Fatalf("create default profile: %v", err) + } + if !createdDefault.CursorMCPAuthEnabled { + t.Fatal("omitted create preference should default to true") + } + disabled := false + createdDisabled, err := ctrl.CreateProfile(context.Background(), CreateProfileRequest{ + AgentID: agent.ID, Name: "disabled", CursorMCPAuthEnabled: &disabled, + }) + if err != nil { + t.Fatalf("create disabled profile: %v", err) + } + if createdDisabled.CursorMCPAuthEnabled { + t.Fatal("explicit false create preference was lost") + } + + updatedOmitted, err := ctrl.UpdateProfile(context.Background(), UpdateProfileRequest{ID: createdDisabled.ID, Name: stringPointer("renamed")}) + if err != nil { + t.Fatalf("update unrelated field: %v", err) + } + if updatedOmitted.CursorMCPAuthEnabled { + t.Fatal("omitted patch reset the saved false preference") + } + + enabled := true + updatedEnabled, err := ctrl.UpdateProfile(context.Background(), UpdateProfileRequest{ + ID: createdDisabled.ID, CursorMCPAuthEnabled: &enabled, + }) + if err != nil { + t.Fatalf("enable preference: %v", err) + } + if !updatedEnabled.CursorMCPAuthEnabled { + t.Fatal("explicit true patch was not applied") + } + + if _, err := ctrl.UpdateProfile(context.Background(), UpdateProfileRequest{ID: createdDisabled.ID, CursorMCPAuthEnabled: &disabled}); err != nil { + t.Fatalf("disable preference before duplicate: %v", err) + } + duplicated, err := ctrl.DuplicateProfile(context.Background(), DuplicateProfileRequest{ID: createdDisabled.ID}) + if err != nil { + t.Fatalf("duplicate disabled profile: %v", err) + } + if duplicated.CursorMCPAuthEnabled { + t.Fatal("duplicate did not preserve explicit false") + } + + var omittedCreate dto.ProfileCreateRequest + if err := json.Unmarshal([]byte(`{"agent_id":"agent-1","name":"wire"}`), &omittedCreate); err != nil { + t.Fatal(err) + } + if omittedCreate.CursorMCPAuthEnabled != nil { + t.Fatal("omitted create preference should remain distinguishable") + } + var explicitFalseCreate dto.ProfileCreateRequest + if err := json.Unmarshal([]byte(`{"agent_id":"agent-1","name":"wire","cursor_mcp_auth_enabled":false}`), &explicitFalseCreate); err != nil { + t.Fatal(err) + } + if explicitFalseCreate.CursorMCPAuthEnabled == nil || *explicitFalseCreate.CursorMCPAuthEnabled { + t.Fatal("create JSON lost explicit false") + } + convertedCreate := CreateProfileRequestFromDTO(dto.ProfileCreateRequest{AgentID: agent.ID, Name: "wire", CursorMCPAuthEnabled: &disabled}) + if convertedCreate.CursorMCPAuthEnabled == nil || *convertedCreate.CursorMCPAuthEnabled { + t.Fatal("create DTO conversion lost explicit false") + } + convertedPatch := UpdateProfileRequestFromDTO(dto.ProfileUpdateRequest{ID: createdDisabled.ID, CursorMCPAuthEnabled: &disabled}) + if convertedPatch.CursorMCPAuthEnabled == nil || *convertedPatch.CursorMCPAuthEnabled { + t.Fatal("patch DTO conversion lost explicit false") + } +} + +func stringPointer(value string) *string { return &value } diff --git a/apps/backend/internal/agent/settings/controller/custom_tui.go b/apps/backend/internal/agent/settings/controller/custom_tui.go index 172aea8f8a..e4d24b74e2 100644 --- a/apps/backend/internal/agent/settings/controller/custom_tui.go +++ b/apps/backend/internal/agent/settings/controller/custom_tui.go @@ -144,11 +144,12 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT profileName = req.DisplayName } profile := &models.AgentProfile{ - AgentID: agent.ID, - Name: profileName, - AgentDisplayName: req.DisplayName, - Model: "passthrough", - CLIPassthrough: true, + AgentID: agent.ID, + Name: profileName, + AgentDisplayName: req.DisplayName, + Model: "passthrough", + CLIPassthrough: true, + CursorMCPAuthEnabled: true, } if acp { // The probe supplies a default when the operator named no model; it diff --git a/apps/backend/internal/agent/settings/controller/profile_contract.go b/apps/backend/internal/agent/settings/controller/profile_contract.go index 61443f9fd4..5236c696df 100644 --- a/apps/backend/internal/agent/settings/controller/profile_contract.go +++ b/apps/backend/internal/agent/settings/controller/profile_contract.go @@ -24,6 +24,7 @@ func CreateProfileRequestFromDTO(request dto.ProfileCreateRequest) CreateProfile ProviderKind: request.ProviderKind, ProviderBaseURL: request.ProviderBaseURL, ProviderAPIKeySecretID: request.ProviderAPIKeySecretID, + CursorMCPAuthEnabled: request.CursorMCPAuthEnabled, Dynamic: request.Dynamic, } } @@ -51,6 +52,7 @@ func UpdateProfileRequestFromDTO(request dto.ProfileUpdateRequest) UpdateProfile ProviderKind: request.ProviderKind, ProviderBaseURL: request.ProviderBaseURL, ProviderAPIKeySecretID: request.ProviderAPIKeySecretID, + CursorMCPAuthEnabled: request.CursorMCPAuthEnabled, Dynamic: request.Dynamic, Force: request.Force, } diff --git a/apps/backend/internal/agent/settings/controller/profile_contract_test.go b/apps/backend/internal/agent/settings/controller/profile_contract_test.go index 053f382065..0bf8d49a6e 100644 --- a/apps/backend/internal/agent/settings/controller/profile_contract_test.go +++ b/apps/backend/internal/agent/settings/controller/profile_contract_test.go @@ -9,17 +9,19 @@ import ( func TestProfileContractConversionsPreserveExplicitValues(t *testing.T) { model := "" autoApprove := false + cursorMCPAuthEnabled := false flags := []dto.CLIFlagDTO{} request := dto.ProfileUpdateRequest{ - ID: "profile-1", - Model: &model, - AutoApprove: &autoApprove, - CLIFlags: &flags, - Force: true, + ID: "profile-1", + Model: &model, + AutoApprove: &autoApprove, + CursorMCPAuthEnabled: &cursorMCPAuthEnabled, + CLIFlags: &flags, + Force: true, } converted := UpdateProfileRequestFromDTO(request) if converted.ID != request.ID || converted.Model == nil || *converted.Model != "" || - converted.AutoApprove == nil || *converted.AutoApprove || converted.CLIFlags == nil || + converted.AutoApprove == nil || *converted.AutoApprove || converted.CursorMCPAuthEnabled == nil || *converted.CursorMCPAuthEnabled || converted.CLIFlags == nil || len(*converted.CLIFlags) != 0 || !converted.Force { t.Fatalf("converted update = %#v, explicit values were not preserved", converted) } @@ -31,11 +33,15 @@ func TestProfileCreateContractConversionCopiesAllFields(t *testing.T) { AutoFallback: true, Mode: "plan", ConfigOptions: map[string]string{"x": "y"}, AllowIndexing: true, AutoApprove: true, CLIPassthrough: true, CLIFlags: []dto.CLIFlagDTO{{Flag: "--verbose"}}, EnvVars: []dto.ProfileEnvVarDTO{{Key: "X", Value: "Y"}}, - CommandPrefix: "prefix", + CommandPrefix: "prefix", + CursorMCPAuthEnabled: boolPointer(false), } converted := CreateProfileRequestFromDTO(request) if converted.AgentID != request.AgentID || converted.Name != request.Name || converted.CommandPrefix != request.CommandPrefix || - len(converted.CLIFlags) != 1 || len(converted.EnvVars) != 1 || !converted.AutoFallback || !converted.CLIPassthrough { + len(converted.CLIFlags) != 1 || len(converted.EnvVars) != 1 || !converted.AutoFallback || !converted.CLIPassthrough || + converted.CursorMCPAuthEnabled == nil || *converted.CursorMCPAuthEnabled { t.Fatalf("converted create = %#v, fields were not copied", converted) } } + +func boolPointer(value bool) *bool { return &value } diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index 6d350f2404..b6ba356f7e 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -25,6 +25,10 @@ import ( const dynamicProfileKind = "dynamic" +func cursorMCPAuthEnabled(value *bool) bool { + return value == nil || *value +} + type CreateProfileRequest struct { AgentID string Name string @@ -51,6 +55,7 @@ type CreateProfileRequest struct { ProviderKind string ProviderBaseURL string ProviderAPIKeySecretID string + CursorMCPAuthEnabled *bool Dynamic *dto.DynamicAgentProfileDTO } @@ -104,6 +109,7 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest AllowIndexing: req.AllowIndexing, AutoApprove: req.AutoApprove, CLIPassthrough: req.CLIPassthrough, + CursorMCPAuthEnabled: cursorMCPAuthEnabled(req.CursorMCPAuthEnabled), Enabled: true, CLIFlags: cliFlags, EnvVars: envVarsFromDTO(req.EnvVars), @@ -141,14 +147,15 @@ func (c *Controller) createDynamicProfile( return nil, err } profile := &models.AgentProfile{ - ID: uuid.NewString(), - AgentID: agent.ID, - Name: strings.TrimSpace(req.Name), - AgentDisplayName: displayName, - Enabled: true, - CLIFlags: []models.CLIFlag{}, - EnvVars: []models.ProfileEnvVar{}, - UserModified: true, + ID: uuid.NewString(), + AgentID: agent.ID, + Name: strings.TrimSpace(req.Name), + AgentDisplayName: displayName, + Enabled: true, + CursorMCPAuthEnabled: cursorMCPAuthEnabled(req.CursorMCPAuthEnabled), + CLIFlags: []models.CLIFlag{}, + EnvVars: []models.ProfileEnvVar{}, + UserModified: true, } routes, err := c.validateDynamicCandidates(ctx, profile.ID, req.Dynamic) if err != nil { @@ -365,6 +372,7 @@ type UpdateProfileRequest struct { ProviderKind *string ProviderBaseURL *string ProviderAPIKeySecretID *string + CursorMCPAuthEnabled *bool Dynamic *dto.DynamicAgentProfileDTO Force bool } @@ -378,7 +386,7 @@ func enabledOnlyUpdate(req UpdateProfileRequest) bool { req.FallbackModel == nil && req.AutoFallback == nil && req.RequireExactModel == nil && req.Mode == nil && req.ConfigOptions == nil && req.AllowIndexing == nil && req.AutoApprove == nil && req.CLIPassthrough == nil && req.CLIFlags == nil && req.EnvVars == nil && - req.CommandPrefix == nil && !req.touchesProvider() && req.Dynamic == nil + req.CommandPrefix == nil && !req.touchesProvider() && req.CursorMCPAuthEnabled == nil && req.Dynamic == nil } func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*dto.AgentProfileDTO, error) { @@ -439,6 +447,9 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest if req.CLIPassthrough != nil { profile.CLIPassthrough = *req.CLIPassthrough } + if req.CursorMCPAuthEnabled != nil { + profile.CursorMCPAuthEnabled = *req.CursorMCPAuthEnabled + } if err := validateRequireExactModelPolicy(profile.Model, profile.RequireExactModel, profile.CLIPassthrough, isDynamic); err != nil { return nil, err } @@ -732,6 +743,7 @@ func duplicateClone(source *models.AgentProfile) *models.AgentProfile { ProviderKind: source.ProviderKind, ProviderBaseURL: source.ProviderBaseURL, ProviderAPIKeySecretID: source.ProviderAPIKeySecretID, + CursorMCPAuthEnabled: source.CursorMCPAuthEnabled, UserModified: true, Enabled: source.Enabled, WorkspaceID: source.WorkspaceID, @@ -1294,6 +1306,7 @@ func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO { ProviderKind: profile.ProviderKind, ProviderBaseURL: profile.ProviderBaseURL, ProviderAPIKeySecretID: profile.ProviderAPIKeySecretID, + CursorMCPAuthEnabled: profile.CursorMCPAuthEnabled, UserModified: profile.UserModified, WorkspaceID: profile.WorkspaceID, CreatedAt: profile.CreatedAt, diff --git a/apps/backend/internal/agent/settings/controller/reconciler.go b/apps/backend/internal/agent/settings/controller/reconciler.go index 3da2330411..a492b3e5d5 100644 --- a/apps/backend/internal/agent/settings/controller/reconciler.go +++ b/apps/backend/internal/agent/settings/controller/reconciler.go @@ -390,14 +390,15 @@ func (r *ProfileReconciler) seedDefaultProfile( caps hostutility.AgentCapabilities, ) { profile := &models.AgentProfile{ - AgentID: dbAgent.ID, - Name: profileNameFromCaps(ag, caps), - AgentDisplayName: ag.DisplayName(), - Model: caps.CurrentModelID, - Mode: caps.CurrentModeID, - AllowIndexing: ag.ID() == "auggie", - CLIPassthrough: false, - UserModified: false, + AgentID: dbAgent.ID, + Name: profileNameFromCaps(ag, caps), + AgentDisplayName: ag.DisplayName(), + Model: caps.CurrentModelID, + Mode: caps.CurrentModeID, + AllowIndexing: ag.ID() == "auggie", + CLIPassthrough: false, + CursorMCPAuthEnabled: true, + UserModified: false, } if err := r.store.CreateAgentProfile(ctx, profile); err != nil { r.logReconcileError("seed default profile failed", err, diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 917fa38bcf..ba62a11f5a 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -45,6 +45,7 @@ type AgentProfileDTO struct { // ProviderAPIKeySecretID references the Kandev global secret holding the // bearer key. The value is never returned. ProviderAPIKeySecretID string `json:"provider_api_key_secret_id,omitempty"` + CursorMCPAuthEnabled bool `json:"cursor_mcp_auth_enabled"` // ProviderSupported is computed at read time: true when the profile's // agent advertises OpenAI-compatible provider support. Not persisted. ProviderSupported bool `json:"provider_supported"` diff --git a/apps/backend/internal/agent/settings/dto/profile_contract.go b/apps/backend/internal/agent/settings/dto/profile_contract.go index fe88c2bcf3..729cc1fe1e 100644 --- a/apps/backend/internal/agent/settings/dto/profile_contract.go +++ b/apps/backend/internal/agent/settings/dto/profile_contract.go @@ -26,6 +26,7 @@ type ProfileCreateRequest struct { ProviderKind string `json:"provider_kind,omitempty"` ProviderBaseURL string `json:"provider_base_url,omitempty"` ProviderAPIKeySecretID string `json:"provider_api_key_secret_id,omitempty"` + CursorMCPAuthEnabled *bool `json:"cursor_mcp_auth_enabled,omitempty"` Dynamic *DynamicAgentProfileDTO `json:"dynamic,omitempty"` } @@ -53,6 +54,7 @@ type ProfileUpdateRequest struct { ProviderKind *string `json:"provider_kind,omitempty"` ProviderBaseURL *string `json:"provider_base_url,omitempty"` ProviderAPIKeySecretID *string `json:"provider_api_key_secret_id,omitempty"` + CursorMCPAuthEnabled *bool `json:"cursor_mcp_auth_enabled,omitempty"` Dynamic *DynamicAgentProfileDTO `json:"dynamic,omitempty"` Force bool `json:"force,omitempty"` } @@ -106,6 +108,7 @@ func ProfileContractFields() []ProfileContractField { {Path: "provider_kind", JSONType: "string", Support: "read_write", Description: "Provider routing kind."}, {Path: "provider_base_url", JSONType: "string", Support: "read_write", Description: "OpenAI-compatible provider base URL."}, {Path: "provider_api_key_secret_id", JSONType: "string", Support: "read_write", Description: "Global secret reference for the provider API key.", Sensitive: true}, + {Path: "cursor_mcp_auth_enabled", JSONType: "boolean", Support: "read_write", Description: "Share local Cursor MCP credentials. Defaults to true for new profiles."}, {Path: "dynamic", JSONType: "object", Support: "read_write", Description: "Versioned dynamic routing document.", Replacement: true}, } } diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index cb443d3b15..292019bd1b 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -405,15 +405,16 @@ type createAgentRequest struct { } type createAgentProfileRequest struct { - Name string `json:"name"` - Model string `json:"model"` - FallbackModel string `json:"fallback_model,omitempty"` - AutoFallback bool `json:"auto_fallback"` - RequireExactModel bool `json:"require_exact_model"` - Mode string `json:"mode,omitempty"` - CLIFlags []dto.CLIFlagDTO `json:"cli_flags,omitempty"` - EnvVars []dto.ProfileEnvVarDTO `json:"env_vars,omitempty"` - CommandPrefix string `json:"command_prefix,omitempty"` + Name string `json:"name"` + Model string `json:"model"` + FallbackModel string `json:"fallback_model,omitempty"` + AutoFallback bool `json:"auto_fallback"` + RequireExactModel bool `json:"require_exact_model"` + CursorMCPAuthEnabled *bool `json:"cursor_mcp_auth_enabled,omitempty"` + Mode string `json:"mode,omitempty"` + CLIFlags []dto.CLIFlagDTO `json:"cli_flags,omitempty"` + EnvVars []dto.ProfileEnvVarDTO `json:"env_vars,omitempty"` + CommandPrefix string `json:"command_prefix,omitempty"` } func (h *Handlers) httpCreateAgent(c *gin.Context) { @@ -433,15 +434,16 @@ func (h *Handlers) httpCreateAgent(c *gin.Context) { return } profiles = append(profiles, controller.CreateAgentProfileRequest{ - Name: profile.Name, - Model: profile.Model, - FallbackModel: profile.FallbackModel, - AutoFallback: profile.AutoFallback, - RequireExactModel: profile.RequireExactModel, - Mode: profile.Mode, - CLIFlags: profile.CLIFlags, - EnvVars: profile.EnvVars, - CommandPrefix: profile.CommandPrefix, + Name: profile.Name, + Model: profile.Model, + FallbackModel: profile.FallbackModel, + AutoFallback: profile.AutoFallback, + RequireExactModel: profile.RequireExactModel, + CursorMCPAuthEnabled: profile.CursorMCPAuthEnabled, + Mode: profile.Mode, + CLIFlags: profile.CLIFlags, + EnvVars: profile.EnvVars, + CommandPrefix: profile.CommandPrefix, }) } resp, err := h.controller.CreateAgent(c.Request.Context(), controller.CreateAgentRequest{ diff --git a/apps/backend/internal/agent/settings/models/models.go b/apps/backend/internal/agent/settings/models/models.go index c819018eca..f383c1d7e2 100644 --- a/apps/backend/internal/agent/settings/models/models.go +++ b/apps/backend/internal/agent/settings/models/models.go @@ -103,6 +103,9 @@ type AgentProfile struct { // CLIPassthrough enables TUI-passthrough execution style. Orthogonal to ACP. CLIPassthrough bool `json:"cli_passthrough" db:"cli_passthrough"` + // CursorMCPAuthEnabled shares local Cursor MCP auth files for Cursor launches. + CursorMCPAuthEnabled bool `json:"cursor_mcp_auth_enabled" db:"cursor_mcp_auth_enabled"` + // Enabled gates the profile from new-work selection: when false, the // profile is hidden from task/session creation pickers but keeps serving // existing sessions and remains editable in settings. Independent of diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index ab5e3221c0..fcce06f4c0 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -110,6 +110,7 @@ func (r *sqliteRepository) initSchema() error { provider_kind TEXT NOT NULL DEFAULT '', provider_base_url TEXT NOT NULL DEFAULT '', provider_api_key_secret_id TEXT NOT NULL DEFAULT '', + cursor_mcp_auth_enabled INTEGER NOT NULL DEFAULT 1, FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE ); @@ -204,6 +205,7 @@ func (r *sqliteRepository) initSchema() error { _ = r.migrate.Apply("agent_profiles.provider_base_url", `ALTER TABLE agent_profiles ADD COLUMN provider_base_url TEXT NOT NULL DEFAULT ''`) _ = r.migrate.Apply("agent_profiles.provider_api_key_secret_id", `ALTER TABLE agent_profiles ADD COLUMN provider_api_key_secret_id TEXT NOT NULL DEFAULT ''`) _ = r.migrate.Apply("agent_profiles.require_exact_model", `ALTER TABLE agent_profiles ADD COLUMN require_exact_model INTEGER NOT NULL DEFAULT 0`) + _ = r.migrate.Apply("agent_profiles.cursor_mcp_auth_enabled", `ALTER TABLE agent_profiles ADD COLUMN cursor_mcp_auth_enabled INTEGER NOT NULL DEFAULT 1`) if err := r.migrate.Err(); err != nil { return fmt.Errorf("required agent settings migration: %w", err) } @@ -335,6 +337,7 @@ func (r *sqliteRepository) recreateAgentProfilesWithoutModelCheck() error { srcHasFallbackModel := columnExists(tx, "agent_profiles", "fallback_model") srcHasAutoFallback := columnExists(tx, "agent_profiles", "auto_fallback") srcHasRequireExactModel := columnExists(tx, "agent_profiles", "require_exact_model") + srcHasCursorMCPAuthEnabled := columnExists(tx, "agent_profiles", "cursor_mcp_auth_enabled") srcCols := `id, agent_id, name, agent_display_name, model, mode, migrated_from, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at` @@ -367,6 +370,10 @@ func (r *sqliteRepository) recreateAgentProfilesWithoutModelCheck() error { srcCols += ", require_exact_model" dstCols += ", require_exact_model" } + if srcHasCursorMCPAuthEnabled { + srcCols += ", cursor_mcp_auth_enabled" + dstCols += ", cursor_mcp_auth_enabled" + } if _, err := tx.Exec(`CREATE TABLE agent_profiles_new ( id TEXT PRIMARY KEY, @@ -392,6 +399,7 @@ func (r *sqliteRepository) recreateAgentProfilesWithoutModelCheck() error { fallback_model TEXT NOT NULL DEFAULT '', auto_fallback INTEGER NOT NULL DEFAULT 0, require_exact_model INTEGER NOT NULL DEFAULT 0, + cursor_mcp_auth_enabled INTEGER NOT NULL DEFAULT 1, FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE )`); err != nil { return fmt.Errorf("create new table: %w", err) @@ -1023,7 +1031,8 @@ func (r *sqliteRepository) insertAgentProfile(ctx context.Context, execer profil consecutive_failures, failure_threshold, executor_preference, budget_monthly_cents, settings, permissions, command_prefix, fallback_model, auto_fallback, - provider_kind, provider_base_url, provider_api_key_secret_id, require_exact_model + provider_kind, provider_base_url, provider_api_key_secret_id, require_exact_model, + cursor_mcp_auth_enabled ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, @@ -1035,7 +1044,8 @@ func (r *sqliteRepository) insertAgentProfile(ctx context.Context, execer profil ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ? + ?, ?, ?, ?, + ? ) `), profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, @@ -1054,6 +1064,7 @@ func (r *sqliteRepository) insertAgentProfile(ctx context.Context, execer profil dialect.BoolToInt(profile.AutoFallback), profile.ProviderKind, profile.ProviderBaseURL, profile.ProviderAPIKeySecretID, dialect.BoolToInt(profile.RequireExactModel), + dialect.BoolToInt(profile.CursorMCPAuthEnabled), ) return err } @@ -1325,7 +1336,8 @@ func (r *sqliteRepository) updateAgentProfile(ctx context.Context, execer profil executor_preference = ?, budget_monthly_cents = ?, settings = ?, permissions = ?, command_prefix = ?, fallback_model = ?, auto_fallback = ?, - provider_kind = ?, provider_base_url = ?, provider_api_key_secret_id = ?, require_exact_model = ? + provider_kind = ?, provider_base_url = ?, provider_api_key_secret_id = ?, require_exact_model = ?, + cursor_mcp_auth_enabled = ? WHERE id = ? AND deleted_at IS NULL `), profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, nullableString(profile.Mode), nullableString(profile.MigratedFrom), @@ -1344,6 +1356,7 @@ func (r *sqliteRepository) updateAgentProfile(ctx context.Context, execer profil dialect.BoolToInt(profile.AutoFallback), profile.ProviderKind, profile.ProviderBaseURL, profile.ProviderAPIKeySecretID, dialect.BoolToInt(profile.RequireExactModel), + dialect.BoolToInt(profile.CursorMCPAuthEnabled), profile.ID) if err != nil { return err @@ -1416,7 +1429,8 @@ const agentProfileSelectColumns = ` COALESCE(command_prefix, ''), COALESCE(fallback_model, ''), COALESCE(auto_fallback, 0), COALESCE(provider_kind, ''), COALESCE(provider_base_url, ''), - COALESCE(provider_api_key_secret_id, ''), COALESCE(require_exact_model, 0) + COALESCE(provider_api_key_secret_id, ''), COALESCE(require_exact_model, 0), + COALESCE(cursor_mcp_auth_enabled, 1) FROM agent_profiles` func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) { @@ -1598,6 +1612,7 @@ func scanAgentProfile(scanner interface { var failureThreshold int var autoFallback int var requireExactModel int + var cursorMCPAuthEnabled int if err := scanner.Scan( &profile.ID, &profile.AgentID, @@ -1644,6 +1659,7 @@ func scanAgentProfile(scanner interface { &profile.ProviderBaseURL, &profile.ProviderAPIKeySecretID, &requireExactModel, + &cursorMCPAuthEnabled, ); err != nil { return nil, err } @@ -1662,6 +1678,7 @@ func scanAgentProfile(scanner interface { profile.SkipIdleRuns = skipIdleRuns == 1 profile.AutoFallback = autoFallback == 1 profile.RequireExactModel = requireExactModel == 1 + profile.CursorMCPAuthEnabled = cursorMCPAuthEnabled == 1 profile.Role = models.AgentRole(role) profile.Status = models.AgentStatus(status) profile.ConfigOptions = configOptionsFromSettings(profile.Settings) diff --git a/apps/backend/internal/agent/settings/store/sqlite_cursor_mcp_auth_test.go b/apps/backend/internal/agent/settings/store/sqlite_cursor_mcp_auth_test.go new file mode 100644 index 0000000000..522c50588c --- /dev/null +++ b/apps/backend/internal/agent/settings/store/sqlite_cursor_mcp_auth_test.go @@ -0,0 +1,70 @@ +package store + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/agent/settings/models" +) + +func TestCursorMCPAuthPreferenceRoundTrip(t *testing.T) { + repo := newFreshRepo(t) + ctx := context.Background() + agent := &models.Agent{Name: "cursor-acp"} + if err := repo.CreateAgent(ctx, agent); err != nil { + t.Fatalf("create agent: %v", err) + } + + profiles := []*models.AgentProfile{ + {AgentID: agent.ID, Name: "enabled", CursorMCPAuthEnabled: true}, + {AgentID: agent.ID, Name: "disabled", CursorMCPAuthEnabled: false}, + } + for _, profile := range profiles { + if err := repo.CreateAgentProfile(ctx, profile); err != nil { + t.Fatalf("create profile %q: %v", profile.Name, err) + } + got, err := repo.GetAgentProfile(ctx, profile.ID) + if err != nil { + t.Fatalf("get profile %q: %v", profile.Name, err) + } + if got.CursorMCPAuthEnabled != profile.CursorMCPAuthEnabled { + t.Errorf("profile %q cursor_mcp_auth_enabled = %v, want %v", profile.Name, got.CursorMCPAuthEnabled, profile.CursorMCPAuthEnabled) + } + } + + profiles[0].CursorMCPAuthEnabled = false + if err := repo.UpdateAgentProfile(ctx, profiles[0]); err != nil { + t.Fatalf("disable preference: %v", err) + } + got, err := repo.GetAgentProfile(ctx, profiles[0].ID) + if err != nil { + t.Fatalf("get updated profile: %v", err) + } + if got.CursorMCPAuthEnabled { + t.Fatal("explicit false was not persisted") + } +} + +func TestMigration_LegacyProfileDefaultsCursorMCPAuthEnabled(t *testing.T) { + db := newLegacyDB(t) + _, err := db.Exec(`INSERT INTO agents (id, name, created_at, updated_at) VALUES ('a1', 'cursor-acp', datetime('now'), datetime('now'))`) + if err != nil { + t.Fatalf("seed agent: %v", err) + } + _, err = db.Exec(`INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, model, created_at, updated_at) + VALUES ('p1', 'a1', 'Cursor', 'Cursor', 'legacy-model', datetime('now'), datetime('now'))`) + if err != nil { + t.Fatalf("seed profile: %v", err) + } + repo, err := newSQLiteRepository(db, db, nil, false) + if err != nil { + t.Fatalf("migrate legacy db: %v", err) + } + profile, err := repo.GetAgentProfile(context.Background(), "p1") + if err != nil { + t.Fatalf("get migrated profile: %v", err) + } + if !profile.CursorMCPAuthEnabled { + t.Fatal("legacy profile should default to enabled") + } +} diff --git a/apps/backend/internal/agent/settings/store/sqlite_duplicate_test.go b/apps/backend/internal/agent/settings/store/sqlite_duplicate_test.go index d393f33578..c2aff167d9 100644 --- a/apps/backend/internal/agent/settings/store/sqlite_duplicate_test.go +++ b/apps/backend/internal/agent/settings/store/sqlite_duplicate_test.go @@ -26,17 +26,18 @@ func TestDuplicateAgentProfile_RoundTrip(t *testing.T) { } source := &models.AgentProfile{ - AgentID: agent.ID, - Name: "Default", - AgentDisplayName: "Test Agent", - Model: "model-1", - Mode: "plan", - AutoApprove: true, - CLIPassthrough: true, - CLIFlags: []models.CLIFlag{{Description: "Tools", Flag: "--allow-all-tools", Enabled: true}}, - EnvVars: []models.ProfileEnvVar{{Key: "FOO", Value: "bar"}}, - CommandPrefix: "greywall --", - UserModified: true, + AgentID: agent.ID, + Name: "Default", + AgentDisplayName: "Test Agent", + Model: "model-1", + Mode: "plan", + AutoApprove: true, + CLIPassthrough: true, + CLIFlags: []models.CLIFlag{{Description: "Tools", Flag: "--allow-all-tools", Enabled: true}}, + EnvVars: []models.ProfileEnvVar{{Key: "FOO", Value: "bar"}}, + CommandPrefix: "greywall --", + CursorMCPAuthEnabled: false, + UserModified: true, } if err := repo.CreateAgentProfile(ctx, source); err != nil { t.Fatalf("create source: %v", err) @@ -59,18 +60,19 @@ func TestDuplicateAgentProfile_RoundTrip(t *testing.T) { } clone := &models.AgentProfile{ - AgentID: source.AgentID, - Name: "Default Copy", - AgentDisplayName: source.AgentDisplayName, - Model: source.Model, - Mode: source.Mode, - AutoApprove: source.AutoApprove, - CLIPassthrough: source.CLIPassthrough, - CLIFlags: source.CLIFlags, - EnvVars: source.EnvVars, - CommandPrefix: source.CommandPrefix, - Enabled: false, - UserModified: true, + AgentID: source.AgentID, + Name: "Default Copy", + AgentDisplayName: source.AgentDisplayName, + Model: source.Model, + Mode: source.Mode, + AutoApprove: source.AutoApprove, + CLIPassthrough: source.CLIPassthrough, + CLIFlags: source.CLIFlags, + EnvVars: source.EnvVars, + CommandPrefix: source.CommandPrefix, + CursorMCPAuthEnabled: source.CursorMCPAuthEnabled, + Enabled: false, + UserModified: true, } copiedMcp := &models.AgentProfileMcpConfig{ Enabled: sourceMcp.Enabled, @@ -98,6 +100,9 @@ func TestDuplicateAgentProfile_RoundTrip(t *testing.T) { if got.Enabled { t.Error("duplicate enabled = true, want false (source disabled)") } + if got.CursorMCPAuthEnabled { + t.Error("duplicate reset Cursor MCP auth preference; want explicit false") + } if got.Name != "Default Copy" || got.Model != "model-1" || got.Mode != "plan" || !got.AutoApprove || !got.CLIPassthrough || got.CommandPrefix != "greywall --" || len(got.CLIFlags) != 1 || got.CLIFlags[0].Flag != "--allow-all-tools" || diff --git a/apps/backend/internal/settingscatalog/defaults.go b/apps/backend/internal/settingscatalog/defaults.go index 0593ebe817..153f2e4256 100644 --- a/apps/backend/internal/settingscatalog/defaults.go +++ b/apps/backend/internal/settingscatalog/defaults.go @@ -755,6 +755,7 @@ func profileDomain() DomainDescriptor { profileWritable("agent_profile.allow_indexing", "allow_indexing", "Allow indexing", "boolean", "Legacy compatibility permission.", false), profileWritable("agent_profile.auto_approve", "auto_approve", "Auto approve", "boolean", "Automatically approve supported agent permissions.", false), profileWritable("agent_profile.cli_passthrough", "cli_passthrough", "CLI passthrough", "boolean", "Enable CLI passthrough mode.", false), + profileWritableWithDefault("agent_profile.cursor_mcp_auth_enabled", "cursor_mcp_auth_enabled", "Share Cursor MCP credentials", "boolean", "Reuse local Cursor MCP credentials when a Cursor profile launches.", "true for new profiles"), profileWritable("agent_profile.enabled", "enabled", "Enabled", "boolean", "Allow this profile for new work.", false), profileWritable("agent_profile.cli_flags", "cli_flags", "CLI flags", "array", "Complete replacement list of validated CLI flags.", true), profileWritableSensitive("agent_profile.env_vars", "env_vars", "Environment variables", "array", "Complete replacement list. Secret values remain references.", true), @@ -810,6 +811,12 @@ func profileWritable(key, path, label, jsonType, description string, replacement return field } +func profileWritableWithDefault(key, path, label, jsonType, description, defaultBehavior string) FieldDescriptor { + field := profileWritable(key, path, label, jsonType, description, false) + field.DefaultBehavior = defaultBehavior + return field +} + func profileWritableSensitive(key, path, label, jsonType, description string, replacement bool) FieldDescriptor { field := profileWritable(key, path, label, jsonType, description, replacement) field.Sensitive = true diff --git a/apps/backend/internal/settingscatalog/defaults_test.go b/apps/backend/internal/settingscatalog/defaults_test.go index 870bc8bfc2..6698642ff7 100644 --- a/apps/backend/internal/settingscatalog/defaults_test.go +++ b/apps/backend/internal/settingscatalog/defaults_test.go @@ -33,10 +33,20 @@ func TestDefaultProfileFieldsAreWritableAndBound(t *testing.T) { if len(domain.Fields) < 14 { t.Fatalf("profile field count = %d, want complete profile contract", len(domain.Fields)) } + cursorMCPAuthFound := false for _, field := range domain.Fields { if !field.Writable || field.Validator == "" || field.Authority == "" { t.Errorf("profile field %q is not bound for writes: %#v", field.Key, field) } + if field.Key == "agent_profile.cursor_mcp_auth_enabled" { + cursorMCPAuthFound = true + if field.DefaultBehavior != "true for new profiles" { + t.Errorf("Cursor MCP auth default behavior = %q, want true for new profiles", field.DefaultBehavior) + } + } + } + if !cursorMCPAuthFound { + t.Error("Cursor MCP auth preference field is missing") } } diff --git a/apps/backend/internal/worktree/manager.go b/apps/backend/internal/worktree/manager.go index 1954febde9..09b3d539d8 100644 --- a/apps/backend/internal/worktree/manager.go +++ b/apps/backend/internal/worktree/manager.go @@ -221,6 +221,14 @@ func (m *Manager) IsEnabled() bool { return m.config.Enabled } +// TasksBasePath returns the configured task-worktree root with home expansion. +func (m *Manager) TasksBasePath() (string, error) { + if m == nil { + return "", nil + } + return m.config.ExpandedTasksBasePath() +} + // AdmitTaskRecovery prevents a task from creating a session while one of its // persisted checkouts is present but no longer has trustworthy linked-worktree // metadata. Missing paths remain eligible for ordinary materialization. diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts index 8f2db60486..2d7fc95e63 100644 --- a/apps/web/app/actions/agents.ts +++ b/apps/web/app/actions/agents.ts @@ -68,6 +68,7 @@ export async function createAgentAction(payload: { kind?: AgentProfileKind; mode?: string; cli_passthrough: boolean; + cursor_mcp_auth_enabled?: boolean; cli_flags?: CLIFlag[]; command_prefix?: string; env_vars?: ProfileEnvVar[]; @@ -113,6 +114,7 @@ export async function createAgentProfileAction( mode?: string; config_options?: Record; cli_passthrough: boolean; + cursor_mcp_auth_enabled?: boolean; cli_flags?: CLIFlag[]; command_prefix?: string; provider_kind?: string; @@ -146,6 +148,7 @@ export async function updateAgentProfileAction( allow_indexing?: boolean; auto_approve?: boolean; cli_passthrough?: boolean; + cursor_mcp_auth_enabled?: boolean; enabled?: boolean; cli_flags?: CLIFlag[]; command_prefix?: string; diff --git a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts index ced9cf46b6..e0dab26c25 100644 --- a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts +++ b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts @@ -95,6 +95,7 @@ describe("toAgentProfilePatch", () => { allow_indexing: true, auto_approve: true, cli_passthrough: true, + cursor_mcp_auth_enabled: false, cli_flags: [{ flag: ALLOW_ALL_TOOLS_FLAG, enabled: true, description: "" }], }; expect(toAgentProfilePatch(patch)).toEqual({ @@ -104,6 +105,7 @@ describe("toAgentProfilePatch", () => { allowIndexing: true, autoApprove: true, cliPassthrough: true, + cursorMcpAuthEnabled: false, cliFlags: [{ flag: ALLOW_ALL_TOOLS_FLAG, enabled: true, description: "" }], }); }); @@ -142,6 +144,12 @@ describe("isProfileDirty", () => { expect(isProfileDirty(draftFrom(baseProfile), baseProfile)).toBe(false); }); + it("returns true when only the Cursor MCP auth preference changes", () => { + expect( + isProfileDirty(draftFrom(baseProfile, { cursorMcpAuthEnabled: false }), baseProfile), + ).toBe(true); + }); + it("returns true when only mode changes", () => { const draft = draftFrom(baseProfile, { mode: "plan-mock" }); expect(isProfileDirty(draft, baseProfile)).toBe(true); @@ -464,6 +472,79 @@ describe("command prefix save payloads", () => { }); }); +describe("Cursor MCP auth preference save payloads", () => { + it("omits an unchanged preference when saving another existing profile field", async () => { + const savedProfile = { ...baseProfile, cursorMcpAuthEnabled: true }; + const savedAgent = agentWithProfiles([savedProfile]); + const draftProfile = draftFrom(savedProfile, { name: "Renamed profile" }); + const draftAgent = agentWithProfiles([draftProfile]); + const { callbacks } = createTestCallbacks(draftAgent); + vi.mocked(updateAgentProfileAction).mockResolvedValue(draftProfile); + + await saveExistingAgent(draftAgent, savedAgent, false, callbacks); + + expect(updateAgentProfileAction).toHaveBeenCalledWith( + baseProfile.id, + expect.objectContaining({ cursor_mcp_auth_enabled: undefined }), + ); + }); + + it("preserves false when updating an existing profile", async () => { + const savedProfile = { ...baseProfile, cursorMcpAuthEnabled: true }; + const savedAgent = agentWithProfiles([savedProfile]); + const draftProfile = draftFrom(savedProfile, { cursorMcpAuthEnabled: false }); + const draftAgent = agentWithProfiles([draftProfile]); + const { callbacks } = createTestCallbacks(draftAgent); + vi.mocked(updateAgentProfileAction).mockResolvedValue(draftProfile); + + await saveExistingAgent(draftAgent, savedAgent, false, callbacks); + + expect(updateAgentProfileAction).toHaveBeenCalledWith( + baseProfile.id, + expect.objectContaining({ cursor_mcp_auth_enabled: false }), + ); + }); + + it("defaults new agent profile payloads to enabled", async () => { + const draftProfile = draftFrom(baseProfile, { id: DRAFT_PROFILE_ID }); + const draftAgent = agentWithProfiles([draftProfile]); + const { callbacks } = createTestCallbacks(draftAgent); + vi.mocked(createAgentAction).mockResolvedValue( + agentWithProfiles([{ ...draftProfile, id: PERSISTED_PROFILE_ID }]), + ); + + await saveNewAgent(draftAgent, callbacks); + + expect(createAgentAction).toHaveBeenCalledWith( + expect.objectContaining({ + profiles: [expect.objectContaining({ cursor_mcp_auth_enabled: true })], + }), + ); + }); + + it("preserves false when creating an additional profile", async () => { + const savedAgent = agentWithProfiles([baseProfile]); + const newProfile = draftFrom(baseProfile, { + id: NEW_PROFILE_ID, + name: NEW_PROFILE_NAME, + cursorMcpAuthEnabled: false, + }); + const draftAgent = agentWithProfiles([baseProfile, newProfile]); + const { callbacks } = createTestCallbacks(draftAgent); + vi.mocked(createAgentProfileAction).mockResolvedValue({ + ...newProfile, + id: PERSISTED_PROFILE_ID, + }); + + await saveExistingAgent(draftAgent, savedAgent, false, callbacks); + + expect(createAgentProfileAction).toHaveBeenCalledWith( + savedAgent.id, + expect.objectContaining({ cursor_mcp_auth_enabled: false }), + ); + }); +}); + describe("fallback model save payloads", () => { it("includes fallback_model and auto_fallback when saving a dirty existing profile", async () => { const savedAgent = agentWithProfiles([baseProfile]); diff --git a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts index 61c044d60f..78804ccca0 100644 --- a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts +++ b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts @@ -45,6 +45,9 @@ export function toAgentProfilePatch(patch: Partial): Partial onRemoveProfile(profile.id)} canRemove={draftAgent.profiles.length > 1} lockPassthrough={Boolean(draftAgent.tui_config)} diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 2cc23ab3aa..ecdd5bfbc8 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -64,6 +64,7 @@ const createDraftProfile = ( model: defaultModel, ...buildDefaultPermissions(permissionSettings ?? {}), cliPassthrough: false, + cursorMcpAuthEnabled: true, cliFlags: seedDefaultCLIFlags(permissionSettings ?? {}), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/apps/web/components/settings/agent-profile-dirty.ts b/apps/web/components/settings/agent-profile-dirty.ts index dc34e20d84..b97c2f3daa 100644 --- a/apps/web/components/settings/agent-profile-dirty.ts +++ b/apps/web/components/settings/agent-profile-dirty.ts @@ -22,7 +22,14 @@ export function isProfileDirty( savedProfile: AgentProfile, permissionSettings: Record, ): boolean { - const changed = [ + return ( + hasCoreProfileFieldsChanged(draft, savedProfile) || + hasExecutionProfileFieldsChanged(draft, savedProfile, permissionSettings) + ); +} + +function hasCoreProfileFieldsChanged(draft: AgentProfile, savedProfile: AgentProfile): boolean { + return [ draft.name !== savedProfile.name, draft.model !== savedProfile.model, (draft.fallbackModel ?? "") !== (savedProfile.fallbackModel ?? ""), @@ -30,13 +37,22 @@ export function isProfileDirty( (draft.requireExactModel ?? false) !== (savedProfile.requireExactModel ?? false), (draft.mode ?? "") !== (savedProfile.mode ?? ""), !areConfigOptionsEqual(draft.configOptions, savedProfile.configOptions), + ].some(Boolean); +} + +function hasExecutionProfileFieldsChanged( + draft: AgentProfile, + savedProfile: AgentProfile, + permissionSettings: Record, +): boolean { + return [ arePermissionsDirty(draft, savedProfile, permissionSettings), draft.cliPassthrough !== savedProfile.cliPassthrough, + (draft.cursorMcpAuthEnabled ?? true) !== (savedProfile.cursorMcpAuthEnabled ?? true), (draft.enabled ?? true) !== (savedProfile.enabled ?? true), !areCLIFlagsEqual(draft.cliFlags ?? [], savedProfile.cliFlags ?? []), (draft.commandPrefix ?? "") !== (savedProfile.commandPrefix ?? ""), isProviderConfigDirty(draft, savedProfile), !areEnvVarsEqual(draft.envVars, savedProfile.envVars), - ]; - return changed.some(Boolean); + ].some(Boolean); } diff --git a/apps/web/components/settings/agent-profile-page-state.test.ts b/apps/web/components/settings/agent-profile-page-state.test.ts index 0ea8a73469..710b6fd815 100644 --- a/apps/web/components/settings/agent-profile-page-state.test.ts +++ b/apps/web/components/settings/agent-profile-page-state.test.ts @@ -1,9 +1,12 @@ +import { act, renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { updateAgentProfileAction } from "@/app/actions/agents"; import type { Agent } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; import { reconcileAgentProfileOptions, shouldSyncProfileSaveResponse, + useProfileSave, } from "./agent-profile-page-state"; vi.mock("@/app/actions/agents", () => ({ @@ -143,3 +146,64 @@ describe("shouldSyncProfileSaveResponse", () => { expect(shouldSyncProfileSaveResponse(response, baseline)).toBe(false); }); }); + +describe("useProfileSave Cursor MCP auth preference", () => { + it("sends the changed preference when saving the profile editor", async () => { + const savedProfile = { ...agent("a1", "p1").profiles[0], cursorMcpAuthEnabled: true }; + const draft = { ...savedProfile, cursorMcpAuthEnabled: false }; + vi.mocked(updateAgentProfileAction).mockResolvedValue(draft); + const syncAgentsToStore = vi.fn(); + const agents = [{ ...agent("a1", "p1"), profiles: [savedProfile] }]; + + const { result } = renderHook(() => + useProfileSave({ + agent: agents[0], + draft, + savedProfile, + setSaveStatus: vi.fn(), + markProfileSubmitted: vi.fn(), + acceptProfileSaveResponse: () => true, + settingsAgents: agents, + syncAgentsToStore, + toast: vi.fn(), + }), + ); + + await act(async () => result.current()); + + expect(updateAgentProfileAction).toHaveBeenCalledWith( + "p1", + expect.objectContaining({ cursor_mcp_auth_enabled: false }), + false, + ); + }); + + it("omits an unchanged preference from the stale profile draft", async () => { + const savedProfile = { ...agent("a1", "p1").profiles[0], cursorMcpAuthEnabled: false }; + const draft = { ...savedProfile, name: "renamed profile" }; + vi.mocked(updateAgentProfileAction).mockResolvedValue(draft); + const agents = [{ ...agent("a1", "p1"), profiles: [savedProfile] }]; + + const { result } = renderHook(() => + useProfileSave({ + agent: agents[0], + draft, + savedProfile, + setSaveStatus: vi.fn(), + markProfileSubmitted: vi.fn(), + acceptProfileSaveResponse: () => true, + settingsAgents: agents, + syncAgentsToStore: vi.fn(), + toast: vi.fn(), + }), + ); + + await act(async () => result.current()); + + expect(updateAgentProfileAction).toHaveBeenLastCalledWith( + "p1", + expect.objectContaining({ cursor_mcp_auth_enabled: undefined }), + false, + ); + }); +}); diff --git a/apps/web/components/settings/agent-profile-page-state.ts b/apps/web/components/settings/agent-profile-page-state.ts index 696cb92e84..ecf33bd499 100644 --- a/apps/web/components/settings/agent-profile-page-state.ts +++ b/apps/web/components/settings/agent-profile-page-state.ts @@ -69,6 +69,14 @@ export function shouldSyncProfileSaveResponse( return isProfileRevisionNewer(response, currentSaved); } +function changedCursorMcpAuthPreference( + draft: AgentProfile, + savedProfile: AgentProfile, +): boolean | undefined { + const draftValue = draft.cursorMcpAuthEnabled ?? true; + return draftValue === (savedProfile.cursorMcpAuthEnabled ?? true) ? undefined : draftValue; +} + export function useProfileEditorState( profile: AgentProfile, permissionSettings: Record, @@ -222,6 +230,7 @@ export function useProfileSave({ config_options: draft.configOptions ?? {}, ...permissionsToProfilePatch(draft), cli_passthrough: draft.cliPassthrough, + cursor_mcp_auth_enabled: changedCursorMcpAuthPreference(draft, savedProfile), // Omit an unchanged enabled value so a profile editor save cannot // resurrect a concurrent list-toggle response from its stale draft. enabled: diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index 88f4505bde..86061e6aa8 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -97,6 +97,7 @@ function toProfileFormData( auto_approve: permissionValues.auto_approve, allow_indexing: permissionValues.allow_indexing, cli_passthrough: profile.cliPassthrough, + cursor_mcp_auth_enabled: profile.cursorMcpAuthEnabled ?? true, cli_flags: profile.cliFlags ?? [], command_prefix: profile.commandPrefix ?? "", provider_kind: profile.providerKind ?? "", @@ -286,6 +287,9 @@ function ProfileSettingsCard({ permissionSettings={permissionSettings} passthroughConfig={passthroughConfig} agentName={agent.name} + cursorMcpAuthSupported={ + agent.name === "cursor-acp" || agent.tui_config?.mcp_strategy === "cursor" + } onModelConfigResolutionPendingChange={onModelConfigResolutionPendingChange} lockPassthrough={Boolean(agent.tui_config)} hideCustomCLIFlags diff --git a/apps/web/components/settings/agent-profile-reconciliation.test.ts b/apps/web/components/settings/agent-profile-reconciliation.test.ts index ba1b7f2340..180ba37cde 100644 --- a/apps/web/components/settings/agent-profile-reconciliation.test.ts +++ b/apps/web/components/settings/agent-profile-reconciliation.test.ts @@ -41,6 +41,15 @@ describe("sameEditableProfile", () => { ), ).toBe(false); }); + + it("treats Cursor MCP auth preference as an editable change", () => { + expect( + sameEditableProfile( + profile({ cursorMcpAuthEnabled: true }), + profile({ cursorMcpAuthEnabled: false }), + ), + ).toBe(false); + }); }); describe("reconcileAgentProfileSnapshot", () => { @@ -114,6 +123,23 @@ describe("reconcileAgentProfileSnapshot", () => { expect(result.draft.name).toBe("Edited again"); expect(result.saved.name).toBe("Submitted"); }); + + it("keeps a local Cursor MCP auth choice when a newer server snapshot conflicts", () => { + const previous = profile({ cursorMcpAuthEnabled: true }); + const draft = profile({ cursorMcpAuthEnabled: false }); + const incoming = profile({ cursorMcpAuthEnabled: true, updatedAt: UPDATED_AT }); + const result = reconcileAgentProfileSnapshot({ + previous, + incoming, + draft, + saved: previous, + conflicted: false, + }); + + expect(result.kind).toBe("external-conflict"); + expect(result.draft.cursorMcpAuthEnabled).toBe(false); + expect(result.saved.cursorMcpAuthEnabled).toBe(true); + }); }); describe("reconcileAgentProfileSnapshot provider drafts", () => { diff --git a/apps/web/components/settings/agent-profile-reconciliation.ts b/apps/web/components/settings/agent-profile-reconciliation.ts index b91cd16ab0..556896f9ce 100644 --- a/apps/web/components/settings/agent-profile-reconciliation.ts +++ b/apps/web/components/settings/agent-profile-reconciliation.ts @@ -15,6 +15,7 @@ const EDITABLE_FIELDS = [ "commandPrefix", "envVars", "cliPassthrough", + "cursorMcpAuthEnabled", "providerKind", "providerBaseUrl", "providerApiKeySecretId", diff --git a/apps/web/components/settings/cursor-mcp-auth-preference.tsx b/apps/web/components/settings/cursor-mcp-auth-preference.tsx new file mode 100644 index 0000000000..6a66924ee9 --- /dev/null +++ b/apps/web/components/settings/cursor-mcp-auth-preference.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useId } from "react"; +import { useTranslation } from "react-i18next"; +import { Checkbox } from "@kandev/ui/checkbox"; +import { SettingsFieldDescription } from "@/components/settings/settings-typography"; + +export function CursorMCPAuthPreference({ + enabled, + savedEnabled, + onChange, +}: { + enabled: boolean; + savedEnabled?: boolean; + onChange: (enabled: boolean) => void; +}) { + const { t } = useTranslation(); + const instanceId = useId(); + const labelId = `${instanceId}-cursor-mcp-auth-label`; + const descriptionId = `${instanceId}-cursor-mcp-auth-description`; + const dirty = savedEnabled !== undefined && enabled !== savedEnabled; + + return ( +
onChange(!enabled)} + > + onChange(value === true)} + onClick={(event) => event.stopPropagation()} + aria-labelledby={labelId} + aria-describedby={descriptionId} + className="mt-0.5 flex-none" + /> + + + {t("agents:cursorMcpAuthLabel")} + + + {t("agents:cursorMcpAuthDescription")} + + {!enabled && ( + + {t("agents:cursorMcpAuthDisabledNote")} + + )} + +
+ ); +} diff --git a/apps/web/components/settings/profile-capability-helpers.tsx b/apps/web/components/settings/profile-capability-helpers.tsx index 01a479cd25..b1b76131cd 100644 --- a/apps/web/components/settings/profile-capability-helpers.tsx +++ b/apps/web/components/settings/profile-capability-helpers.tsx @@ -1,4 +1,5 @@ import { Trans, useTranslation } from "react-i18next"; +import { useEffect } from "react"; import { IconTerminal2 } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { @@ -9,9 +10,41 @@ import { DialogTitle, DialogTrigger, } from "@kandev/ui/dialog"; -import type { CommandEntry, ModeEntry } from "@/lib/types/http"; +import { useProfileModelCapabilities } from "@/hooks/domains/settings/use-profile-model-capabilities"; +import { modelConfigOptions } from "@/components/settings/profile-model-config"; +import type { CommandEntry, ModeEntry, ModelConfig } from "@/lib/types/http"; import type { ProfileFormData } from "./profile-model-fields"; +type ProfileFormCapabilitySelection = { + model: string; + mode: string; + provider_kind?: string; + config_options?: Record; +}; + +export function useProfileFormCapabilities( + agentName: string, + profile: ProfileFormCapabilitySelection, + modelConfig: ModelConfig, + onChange: (patch: { config_options: Record }) => void, + onPendingChange?: (pending: boolean) => void, +) { + const result = useProfileModelCapabilities(agentName, profile, modelConfig, onChange, { + skipCapabilityProbe: profile.provider_kind === "openai_compatible", + }); + + useEffect(() => { + onPendingChange?.(result.isConfigResolutionPending); + }, [onPendingChange, result.isConfigResolutionPending]); + + return { + ...result, + configOptions: modelConfigOptions( + result.configOptions ? { ...modelConfig, config_options: result.configOptions } : modelConfig, + ), + }; +} + // An example slash command the user types verbatim — an identifier, not copy. const EXAMPLE_COMMAND = "/init"; diff --git a/apps/web/components/settings/profile-form-fields.test.tsx b/apps/web/components/settings/profile-form-fields.test.tsx index 0672e22dab..053ede9ae3 100644 --- a/apps/web/components/settings/profile-form-fields.test.tsx +++ b/apps/web/components/settings/profile-form-fields.test.tsx @@ -66,6 +66,7 @@ function renderForm( profile: ProfileFormData, config: ModelConfig = modelConfig, onChange: (patch: Partial) => void = vi.fn(), + cursorMcpAuthSupported = false, ) { return render( @@ -76,6 +77,7 @@ function renderForm( permissionSettings={{}} passthroughConfig={null} agentName="mock-agent" + cursorMcpAuthSupported={cursorMcpAuthSupported} /> , ); @@ -85,6 +87,7 @@ function renderStatefulForm( profile: ProfileFormData, config: ModelConfig, onChange: (patch: Partial) => void, + cursorMcpAuthSupported = false, ) { function StatefulForm() { const [currentProfile, setCurrentProfile] = useState(profile); @@ -99,6 +102,7 @@ function renderStatefulForm( permissionSettings={{}} passthroughConfig={null} agentName="mock-agent" + cursorMcpAuthSupported={cursorMcpAuthSupported} /> ); } @@ -141,6 +145,26 @@ describe("ProfileFormFields command prefix visibility", () => { }); }); +describe("ProfileFormFields Cursor MCP auth preference", () => { + it("does not show the preference for an unsupported profile", () => { + renderForm(formData(), modelConfig, vi.fn(), false); + expect( + screen.queryByRole("checkbox", { name: /Share local Cursor MCP credentials/ }), + ).toBeNull(); + }); + + it("shows a default-enabled checkbox and emits an explicit false value", () => { + const onChange = vi.fn(); + renderStatefulForm(formData(), modelConfig, onChange, true); + + const checkbox = screen.getByRole("checkbox", { name: /Share local Cursor MCP credentials/ }); + expect(checkbox.getAttribute("data-state")).toBe("checked"); + fireEvent.click(checkbox); + expect(onChange).toHaveBeenCalledWith({ cursor_mcp_auth_enabled: false }); + expect(screen.getByText(/Running sessions keep credentials already loaded/)).toBeTruthy(); + }); +}); + describe("ProfileFormFields no-silent-model-fallback rows", () => { it("renders the start model trigger red when the configured model is gone", () => { renderForm(formData({ model: "claude-gone" })); diff --git a/apps/web/components/settings/profile-form-fields.tsx b/apps/web/components/settings/profile-form-fields.tsx index 0afa3ad9a5..84d6e0d640 100644 --- a/apps/web/components/settings/profile-form-fields.tsx +++ b/apps/web/components/settings/profile-form-fields.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useId } from "react"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; import { IconAlertTriangle } from "@tabler/icons-react"; import type { SelectConfigOption } from "@/components/model-config-selector"; @@ -9,7 +9,6 @@ import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Skeleton } from "@kandev/ui/skeleton"; import { Switch } from "@kandev/ui/switch"; -import { useProfileModelCapabilities } from "@/hooks/domains/settings/use-profile-model-capabilities"; import { PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE, PERMISSION_KEYS, @@ -17,6 +16,7 @@ import { type PermissionKey, } from "@/lib/agent-permissions"; import { CLIFlagsField } from "@/components/settings/cli-flags-field"; +import { CursorMCPAuthPreference } from "@/components/settings/cursor-mcp-auth-preference"; import { ProfileAdvancedOptions } from "@/components/settings/profile-advanced-options"; import { ModelConfigResolutionStatus } from "@/components/settings/model-config-resolution-status"; import { @@ -28,8 +28,8 @@ import { findActiveMode, profileModeIsDirty, profileModelIsDirty, + useProfileFormCapabilities, } from "@/components/settings/profile-capability-helpers"; -import { modelConfigOptions } from "@/components/settings/profile-model-config"; import { ModelFallbackSection, ModelPicker, @@ -63,6 +63,7 @@ export type ProfileFormData = { cli_flags: CLIFlag[]; command_prefix?: string; provider_kind?: string; + cursor_mcp_auth_enabled?: boolean; } & Record; export type ProfileFormFieldsProps = { @@ -73,6 +74,7 @@ export type ProfileFormFieldsProps = { permissionSettings: Record; passthroughConfig: PassthroughConfig | null; agentName: string; + cursorMcpAuthSupported?: boolean; onRemove?: () => void; canRemove?: boolean; variant?: "default" | "compact"; @@ -463,6 +465,7 @@ export function ProfileFormFields({ permissionSettings, passthroughConfig, agentName, + cursorMcpAuthSupported = false, onRemove, canRemove = false, variant = "default", @@ -474,24 +477,20 @@ export function ProfileFormFields({ const isCompact = variant === "compact"; const { capabilities: caps, - configOptions: resolvedConfigOptions, + configOptions, configStatus, configError, configIsLoading, - isConfigResolutionPending, refreshModelConfig, refresh, - } = useProfileModelCapabilities(agentName, profile, modelConfig, onChange, { - skipCapabilityProbe: profile.provider_kind === "openai_compatible", - }); - const configOptions = modelConfigOptions( - resolvedConfigOptions ? { ...modelConfig, config_options: resolvedConfigOptions } : modelConfig, + } = useProfileFormCapabilities( + agentName, + profile, + modelConfig, + onChange, + onModelConfigResolutionPendingChange, ); - useEffect(() => { - onModelConfigResolutionPendingChange?.(isConfigResolutionPending); - }, [isConfigResolutionPending, onModelConfigResolutionPendingChange]); - return (
{!hideNameField && ( @@ -537,6 +536,18 @@ export function ProfileFormFields({ baselineProfile={baselineProfile} /> + {cursorMcpAuthSupported && ( + onChange({ cursor_mcp_auth_enabled: enabled })} + /> + )} + { + const created = await this.request<{ name: string }>("POST", "/api/v1/agents/tui", options); + const { agents } = await this.listAgents(); + const agent = agents.find((candidate) => candidate.name === created.name); + if (!agent) + throw new Error(`Custom TUI agent ${created.name} was not returned by the agent list`); + return agent; + } + /** Removes a custom agent by slug, so a spec that creates one leaves the * worker's agent list as it found it. Missing is not an error. */ async deleteCustomAgentByName(name: string): Promise { diff --git a/apps/web/e2e/helpers/cursor-mcp-auth.ts b/apps/web/e2e/helpers/cursor-mcp-auth.ts new file mode 100644 index 0000000000..240121fc67 --- /dev/null +++ b/apps/web/e2e/helpers/cursor-mcp-auth.ts @@ -0,0 +1,21 @@ +import type { ApiClient } from "./api-client"; + +export async function createCursorMcpAuthFixture(apiClient: ApiClient) { + const suffix = Date.now().toString(36); + const agent = await apiClient.createCustomTUIAgent({ + display_name: `Cursor MCP Auth ${suffix}`, + command: "echo", + model: "mock-fast", + mcp_strategy: "cursor", + }); + const profile = agent.profiles[0]; + if (!profile) throw new Error("Cursor MCP auth fixture has no seeded profile"); + + return { + agentName: agent.name, + profileId: profile.id, + async dispose() { + await apiClient.deleteCustomAgentByName(agent.name); + }, + }; +} diff --git a/apps/web/e2e/tests/settings/cursor-mcp-auth.spec.ts b/apps/web/e2e/tests/settings/cursor-mcp-auth.spec.ts new file mode 100644 index 0000000000..0eff32cb7f --- /dev/null +++ b/apps/web/e2e/tests/settings/cursor-mcp-auth.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "../../fixtures/test-base"; +import { createCursorMcpAuthFixture } from "../../helpers/cursor-mcp-auth"; + +test.describe("Cursor MCP authentication preference", () => { + test("saves, reloads, re-enables, and discards changes for a Cursor strategy profile", async ({ + testPage, + apiClient, + }) => { + test.setTimeout(60_000); + const fixture = await createCursorMcpAuthFixture(apiClient); + + try { + await testPage.goto(`/settings/agents/${fixture.agentName}/profiles/${fixture.profileId}`); + const checkbox = testPage.getByRole("checkbox", { + name: "Share local Cursor MCP credentials", + }); + await expect(checkbox).toBeVisible({ timeout: 15_000 }); + await expect(checkbox).toHaveAttribute("aria-checked", "true"); + + const save = testPage.getByRole("button", { name: /^Save( changes)?$/i }).first(); + await checkbox.press("Space"); + await expect(checkbox).toHaveAttribute("aria-checked", "false"); + await expect(save).toBeEnabled(); + await save.click(); + await expect + .poll(async () => (await apiClient.getAgentProfile(fixture.profileId)).cursorMcpAuthEnabled) + .toBe(false); + + await testPage.reload(); + await expect(checkbox).toHaveAttribute("aria-checked", "false"); + await checkbox.press("Space"); + await save.click(); + await expect + .poll(async () => (await apiClient.getAgentProfile(fixture.profileId)).cursorMcpAuthEnabled) + .toBe(true); + + await checkbox.press("Space"); + await expect(testPage.getByRole("button", { name: /^Reset$/i }).first()).toBeEnabled(); + await testPage + .getByRole("button", { name: /^Reset$/i }) + .first() + .click(); + await expect(checkbox).toHaveAttribute("aria-checked", "true"); + } finally { + await fixture.dispose(); + } + }); + + test("hides the preference for non-Cursor profiles", async ({ testPage, apiClient }) => { + const { agents } = await apiClient.listAgents(); + const agent = agents.find((candidate) => candidate.name === "mock-agent"); + expect(agent?.profiles[0]).toBeDefined(); + await testPage.goto(`/settings/agents/${agent!.name}/profiles/${agent!.profiles[0].id}`); + await expect(testPage.getByTestId("profile-name-input")).toBeVisible({ timeout: 15_000 }); + await expect( + testPage.getByRole("checkbox", { name: "Share local Cursor MCP credentials" }), + ).toHaveCount(0); + }); +}); diff --git a/apps/web/e2e/tests/settings/mobile-cursor-mcp-auth.spec.ts b/apps/web/e2e/tests/settings/mobile-cursor-mcp-auth.spec.ts new file mode 100644 index 0000000000..1c489b6d15 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-cursor-mcp-auth.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from "../../fixtures/test-base"; +import { createCursorMcpAuthFixture } from "../../helpers/cursor-mcp-auth"; + +test("phone users can toggle and save Cursor MCP authentication", async ({ + testPage, + apiClient, +}) => { + test.setTimeout(60_000); + const fixture = await createCursorMcpAuthFixture(apiClient); + + try { + await testPage.goto(`/settings/agents/${fixture.agentName}/profiles/${fixture.profileId}`); + const checkbox = testPage.getByRole("checkbox", { + name: "Share local Cursor MCP credentials", + }); + const label = testPage.getByText("Share local Cursor MCP credentials", { exact: true }); + await expect(checkbox).toBeVisible({ timeout: 15_000 }); + await expect(checkbox).toHaveAttribute("aria-checked", "true"); + + const row = testPage.getByTestId("cursor-mcp-auth-preference"); + const box = await row.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + await label.tap(); + await expect(checkbox).toHaveAttribute("aria-checked", "false"); + + const save = testPage.getByRole("button", { name: /^Save( changes)?$/i }).first(); + await save.click(); + await expect + .poll(async () => (await apiClient.getAgentProfile(fixture.profileId)).cursorMcpAuthEnabled) + .toBe(false); + + await testPage.reload(); + await expect(checkbox).toHaveAttribute("aria-checked", "false"); + await expect + .poll(() => + testPage.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ) + .toBe(true); + await testPage.getByTestId("cursor-mcp-auth-preference").scrollIntoViewIfNeeded(); + await testPage.screenshot({ + path: test.info().outputPath("cursor-mcp-auth-mobile.png"), + }); + } finally { + await fixture.dispose(); + } +}); diff --git a/apps/web/lib/api/domains/agent-profile-normalize.test.ts b/apps/web/lib/api/domains/agent-profile-normalize.test.ts index 70eb3ca04a..aa848dcc4f 100644 --- a/apps/web/lib/api/domains/agent-profile-normalize.test.ts +++ b/apps/web/lib/api/domains/agent-profile-normalize.test.ts @@ -19,6 +19,7 @@ const snakeCaseWirePayload = { cli_flags: [{ flag: "--verbose", description: "v", enabled: true }], env_vars: [sampleEnvVar], cli_passthrough: false, + cursor_mcp_auth_enabled: false, enabled: false, workspace_id: WORKSPACE_ID, user_modified: true, @@ -41,6 +42,7 @@ const expectedCamelCaseProfile = { cliFlags: [{ flag: "--verbose", description: "v", enabled: true }], envVars: [sampleEnvVar], cliPassthrough: false, + cursorMcpAuthEnabled: false, enabled: false, providerSupported: false, workspaceId: WORKSPACE_ID, @@ -66,6 +68,7 @@ describe("normalizeAgentProfile", () => { expect(result.agentDisplayName).toBe(""); // Legacy payloads without the flag are treated as enabled. expect(result.enabled).toBe(true); + expect(result.cursorMcpAuthEnabled).toBe(true); }); it("preserves the office workspace scope when it is present", () => { @@ -85,6 +88,17 @@ describe("normalizeAgentProfile", () => { expect(result.cliPassthrough).toBe(true); }); + it("preserves the Cursor MCP auth preference through the wire round trip", () => { + const result = normalizeAgentProfile({ + id: SAMPLE_ID, + name: "default", + cursor_mcp_auth_enabled: false, + }); + + expect(result.cursorMcpAuthEnabled).toBe(false); + expect(toAgentProfilePayload(result).cursor_mcp_auth_enabled).toBe(false); + }); + it("maps command_prefix to commandPrefix", () => { const result = normalizeAgentProfile({ id: SAMPLE_ID, diff --git a/apps/web/lib/api/domains/agent-profile-normalize.ts b/apps/web/lib/api/domains/agent-profile-normalize.ts index 7f45a38362..100392f9e6 100644 --- a/apps/web/lib/api/domains/agent-profile-normalize.ts +++ b/apps/web/lib/api/domains/agent-profile-normalize.ts @@ -242,6 +242,13 @@ export function normalizeAgentProfile(raw: unknown): AgentProfile { envVars: pickEnvVars(profile), cliPassthrough: pickBool(profile, "cliPassthrough", "cli_passthrough"), // Absent on legacy payloads → enabled by default. + cursorMcpAuthEnabled: pickBool( + profile, + "cursorMcpAuthEnabled", + "cursor_mcp_auth_enabled", + true, + ), + // Absent on legacy payloads → enabled by default. enabled: pickBool(profile, "enabled", "enabled", true), workspaceId: (() => { const value = pickOptionalString(profile, "workspaceId", "workspace_id"); @@ -302,6 +309,7 @@ export function toAgentProfilePayload( setPayloadField(payload, "provider_api_key_secret_id", profile.providerApiKeySecretId); setPayloadField(payload, "env_vars", profile.envVars); setPayloadField(payload, "cli_passthrough", profile.cliPassthrough); + setPayloadField(payload, "cursor_mcp_auth_enabled", profile.cursorMcpAuthEnabled); setPayloadField(payload, "enabled", profile.enabled); setPayloadField(payload, "user_modified", profile.userModified); setPayloadField(payload, "created_at", profile.createdAt); diff --git a/apps/web/lib/settings-discovery/contract.generated.json b/apps/web/lib/settings-discovery/contract.generated.json index d0001e580a..2d8cc62266 100644 --- a/apps/web/lib/settings-discovery/contract.generated.json +++ b/apps/web/lib/settings-discovery/contract.generated.json @@ -388,6 +388,21 @@ "change_timing": "future_sessions", "settings_href": "/settings/agents" }, + { + "key": "agent_profile.cursor_mcp_auth_enabled", + "field_path": "cursor_mcp_auth_enabled", + "label": "cursor_mcp_auth_enabled", + "description": "Share local Cursor MCP credentials. Defaults to true for new profiles.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, { "key": "agent_profile.dynamic", "field_path": "dynamic", @@ -6261,6 +6276,7 @@ "cli_passthrough", "command_prefix", "config_options", + "cursor_mcp_auth_enabled", "dynamic", "enabled", "env_vars", diff --git a/apps/web/lib/settings-discovery/profile-contract.generated.json b/apps/web/lib/settings-discovery/profile-contract.generated.json index a534e5c022..b834fae613 100644 --- a/apps/web/lib/settings-discovery/profile-contract.generated.json +++ b/apps/web/lib/settings-discovery/profile-contract.generated.json @@ -275,6 +275,21 @@ "change_timing": "future_sessions", "settings_href": "/settings/agents" }, + { + "key": "agent_profile.cursor_mcp_auth_enabled", + "field_path": "cursor_mcp_auth_enabled", + "label": "cursor_mcp_auth_enabled", + "description": "Share local Cursor MCP credentials. Defaults to true for new profiles.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, { "key": "agent_profile.dynamic", "field_path": "dynamic", @@ -372,6 +387,7 @@ "cli_passthrough", "command_prefix", "config_options", + "cursor_mcp_auth_enabled", "dynamic", "enabled", "env_vars", diff --git a/apps/web/lib/types/agent-profile.ts b/apps/web/lib/types/agent-profile.ts index 7753f6ebb8..2ea18eaec5 100644 --- a/apps/web/lib/types/agent-profile.ts +++ b/apps/web/lib/types/agent-profile.ts @@ -157,6 +157,8 @@ export type AgentProfile = { /** Environment variables injected when this profile starts an agent session. */ envVars?: ProfileEnvVar[]; cliPassthrough: boolean; + /** Reuse locally stored Cursor MCP OAuth credentials when this profile launches. */ + cursorMcpAuthEnabled?: boolean; /** * False hides the profile from task/session creation pickers. Existing * sessions keep running and the profile stays editable in settings. @@ -242,6 +244,7 @@ export type AgentProfilePayload = { provider_supported?: boolean; env_vars?: ProfileEnvVar[]; cli_passthrough: boolean; + cursor_mcp_auth_enabled?: boolean; enabled?: boolean; user_modified?: boolean; created_at: string; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 9ab14167eb..bca7df35cc 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -377,6 +377,7 @@ export type AgentProfilePayload = { dangerously_skip_permissions: boolean; allow_indexing: boolean; cli_passthrough?: boolean; + cursor_mcp_auth_enabled?: boolean; plan: string; created_at?: string; updated_at?: string; diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json index e634123c5a..a715a03d0e 100644 --- a/apps/web/src/locales/en/agents.json +++ b/apps/web/src/locales/en/agents.json @@ -47,6 +47,9 @@ "command": "Command", "commandPrefix": "Command prefix", "commandPrefixHelp": "Tokens prepended to the agent launch command, so it runs under a sandbox launcher (e.g. <0>{{example}}). The value is shell-tokenised. Leave empty to run the agent directly. Applies to ACP sessions only; it has no effect when the profile uses TUI passthrough.", + "cursorMcpAuthDescription": "Reuse MCP sign-ins from other local Cursor projects. Applies on the next launch.", + "cursorMcpAuthDisabledNote": "Turning this off removes the shared link on the next launch. Running sessions keep credentials already loaded.", + "cursorMcpAuthLabel": "Share local Cursor MCP credentials", "commandPreview": "Command Preview", "commandPreviewHelp": "The CLI command that will be executed based on the current settings.", "commandRequired": "Command is required", diff --git a/apps/web/src/locales/ja/agents.json b/apps/web/src/locales/ja/agents.json index 53c9c57f54..ffb684e18d 100644 --- a/apps/web/src/locales/ja/agents.json +++ b/apps/web/src/locales/ja/agents.json @@ -47,6 +47,9 @@ "command": "コマンド", "commandPrefix": "コマンド接頭辞", "commandPrefixHelp": "エージェント起動コマンドの前に付くトークン。サンドボックス起動ツール下で実行されるようになる (例: <0>{{example}})。値はシェルトークンとして分割される。空ならエージェントを直接実行。ACP セッションのみ適用。プロファイルが TUI パススルーを使う場合は無効。", + "cursorMcpAuthDescription": "ほかのローカル Cursor プロジェクトの MCP ログイン情報を再利用します。次回の起動時に適用されます。", + "cursorMcpAuthDisabledNote": "オフにすると、共有リンクは次回の起動時に削除されます。実行中のセッションでは、すでに読み込まれた認証情報が使われます。", + "cursorMcpAuthLabel": "ローカル Cursor MCP 認証情報を共有", "commandPreview": "コマンドプレビュー", "commandPreviewHelp": "現在の設定に基づいて実行される CLI コマンド。", "commandRequired": "コマンド必須", diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json index 2b9f0de645..4966fc27ba 100644 --- a/apps/web/src/locales/pseudo/agents.json +++ b/apps/web/src/locales/pseudo/agents.json @@ -47,6 +47,9 @@ "command": "Ćōḿḿàńď", "commandPrefix": "Ćōḿḿàńď ƥŕēƒĩx", "commandPrefixHelp": "Ţōķēńś ƥŕēƥēńďēď ţō ţĥē àĝēńţ ĺàũńćĥ ćōḿḿàńď, śō ĩţ ŕũńś ũńďēŕ à śàńďƀōx ĺàũńćĥēŕ (ē.ĝ. <0>{{example}}). Ţĥē vàĺũē ĩś śĥēĺĺ-ţōķēńĩśēď. Ĺēàvē ēḿƥţŷ ţō ŕũń ţĥē àĝēńţ ďĩŕēćţĺŷ. Àƥƥĺĩēś ţō ÀĆƤ śēśśĩōńś ōńĺŷ; ĩţ ĥàś ńō ēƒƒēćţ ŵĥēń ţĥē ƥŕōƒĩĺē ũśēś ŢŨĨ ƥàśśţĥŕōũĝĥ.", + "cursorMcpAuthDescription": "Ŕēũśē ḾĆƤ śĩĝń-ĩńś ƒŕōḿ ōţĥēŕ ĺōćàĺ Ćũŕśōŕ ƥŕōĵēćţś. Àƥƥĺĩēś ōń ţĥē ńēxţ ĺàũńćĥ.", + "cursorMcpAuthDisabledNote": "Ţũŕńĩńĝ ţĥĩś ōƒƒ ŕēḿōvēś ţĥē śĥàŕēď ĺĩńķ ōń ţĥē ńēxţ ĺàũńćĥ. Ŕũńńĩńĝ śēśśĩōńś ķēēƥ ćŕēďēńţĩàĺś àĺŕēàďŷ ĺōàďēď.", + "cursorMcpAuthLabel": "Śĥàŕē ĺōćàĺ Ćũŕśōŕ ḾĆƤ ćŕēďēńţĩàĺś", "commandPreview": "Ćōḿḿàńď Ƥŕēvĩēŵ", "commandPreviewHelp": "Ţĥē ĆĹĨ ćōḿḿàńď ţĥàţ ŵĩĺĺ ƀē ēxēćũţēď ƀàśēď ōń ţĥē ćũŕŕēńţ śēţţĩńĝś.", "commandRequired": "Ćōḿḿàńď ĩś ŕēqũĩŕēď", diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json index bd6b452344..38770f6bfc 100644 --- a/apps/web/src/locales/pt-pt/agents.json +++ b/apps/web/src/locales/pt-pt/agents.json @@ -43,6 +43,9 @@ "command": "Comando", "commandPrefix": "Prefixo do comando", "commandPrefixHelp": "Elementos acrescentados no início do comando de arranque do agente, para que este seja executado sob um lançador de sandbox (ex.: <0>{{example}}). O valor é dividido como na shell. Deixe vazio para executar o agente diretamente. Aplica-se apenas a sessões ACP; não tem efeito quando o perfil usa passagem direta TUI.", + "cursorMcpAuthDescription": "Reutiliza as autenticações MCP de outros projetos locais do Cursor. Aplica-se no próximo arranque.", + "cursorMcpAuthDisabledNote": "Ao desativar esta opção, a ligação partilhada será removida no próximo arranque. As sessões em curso mantêm as credenciais já carregadas.", + "cursorMcpAuthLabel": "Partilhar credenciais MCP locais do Cursor", "commandPreview": "Pré-visualização do comando", "commandPreviewHelp": "O comando de CLI que será executado com base nas definições atuais.", "commandRequired": "O comando é obrigatório", diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json index c834079d7a..9fbab9dc08 100644 --- a/apps/web/src/locales/zh-cn/agents.json +++ b/apps/web/src/locales/zh-cn/agents.json @@ -45,6 +45,9 @@ "command": "命令", "commandPrefix": "命令前缀", "commandPrefixHelp": "附加在智能体启动命令前的令牌,使其在沙箱启动器下运行(例如 <0>{{example}})。该值会按 shell 规则拆分为多个令牌。留空则直接运行智能体。仅适用于 ACP 会话;当配置使用 TUI 透传时无效。", + "cursorMcpAuthDescription": "复用其他本地 Cursor 项目中的 MCP 登录信息。下次启动时生效。", + "cursorMcpAuthDisabledNote": "关闭后,下次启动时会移除共享链接。正在运行的会话会继续使用已加载的凭据。", + "cursorMcpAuthLabel": "共享本地 Cursor MCP 凭据", "commandPreview": "命令预览", "commandPreviewHelp": "根据当前设置将执行的 CLI 命令。", "commandRequired": "命令为必填项", diff --git a/apps/web/src/locales/zh-hk/agents.json b/apps/web/src/locales/zh-hk/agents.json index 40a9aaf417..ddc2bba555 100644 --- a/apps/web/src/locales/zh-hk/agents.json +++ b/apps/web/src/locales/zh-hk/agents.json @@ -45,6 +45,9 @@ "command": "命令", "commandPrefix": "命令前綴", "commandPrefixHelp": "附加在代理程式啓動命令前的權杖,使其在沙箱啓動器下執行(例如 <0>{{example}})。該值會按 shell 規則拆分為多個權杖。留空則直接執行代理程式。僅適用於 ACP 工作階段;當設定使用 TUI 透傳時無效。", + "cursorMcpAuthDescription": "複用其他本地 Cursor 項目中的 MCP 登入資訊。下次啓動時生效。", + "cursorMcpAuthDisabledNote": "關閉後,下次啓動時會移除共享連結。正在執行的工作階段會繼續使用已載入的認證資料。", + "cursorMcpAuthLabel": "共享本地 Cursor MCP 認證資料", "commandPreview": "命令預覽", "commandPreviewHelp": "根據目前設定將執行的 CLI 命令。", "commandRequired": "命令為必填項", diff --git a/apps/web/src/locales/zh-tw/agents.json b/apps/web/src/locales/zh-tw/agents.json index 402625cc56..c9e63a2c8f 100644 --- a/apps/web/src/locales/zh-tw/agents.json +++ b/apps/web/src/locales/zh-tw/agents.json @@ -45,6 +45,9 @@ "command": "命令", "commandPrefix": "命令字首", "commandPrefixHelp": "附加在代理程式啟動命令前的權杖,使其在沙箱啟動器下執行(例如 <0>{{example}})。該值會按 shell 規則拆分為多個權杖。留空則直接執行代理程式。僅適用於 ACP 工作階段;當設定使用 TUI 透傳時無效。", + "cursorMcpAuthDescription": "複用其他本地 Cursor 專案中的 MCP 登入資訊。下次啟動時生效。", + "cursorMcpAuthDisabledNote": "關閉後,下次啟動時會移除共享連結。正在執行的工作階段會繼續使用已載入的認證資訊。", + "cursorMcpAuthLabel": "共享本地 Cursor MCP 認證資訊", "commandPreview": "命令預覽", "commandPreviewHelp": "根據目前設定將執行的 CLI 命令。", "commandRequired": "命令為必填項", diff --git a/docs/plans/cursor-mcp-oauth-bridge/plan.md b/docs/plans/cursor-mcp-oauth-bridge/plan.md new file mode 100644 index 0000000000..0ad850ee3e --- /dev/null +++ b/docs/plans/cursor-mcp-oauth-bridge/plan.md @@ -0,0 +1,124 @@ +--- +created: 2026-09-24 +status: complete +requirements: + - REQ-AGENTS-CURSOR-AUTH-001 + - REQ-AGENTS-CURSOR-AUTH-002 + - REQ-AGENTS-CURSOR-AUTH-003 +system_design: + - ../../specs/agents/system-design/cursor-mcp-oauth-bridge.md +legacy_specs: [] +--- + +# Implementation plan: Cursor MCP OAuth bridge + +## Overview + +Implement the filesystem helper first, then the profile preference and settings control. +Connect both to launch preparation last. +All work orders run sequentially in the primary session. +This package does not authorize implementation or subagents. + +Sources: [requirements](../../specs/agents/requirements/cursor-mcp-oauth-bridge.md) and +[system design](../../specs/agents/system-design/cursor-mcp-oauth-bridge.md). + +## Scope + +In scope: local Cursor ACP and CursorStrategy terminal launches, aggregation, atomic links, profile persistence, localized checkbox, and desktop/phone verification. +Out of scope: OAuth login, token refresh, remote credential transport, per-server account selection, and background synchronization. + +## Technical approach + +- Add `mcpconfig/cursor_auth_bridge.go` with pure slug derivation and composed filesystem operations. +- Add `cursor_mcp_auth_enabled` to the existing profile store and shared API contract. Omission defaults to true on creation. +- Carry the value through `AgentProfileInfo`, frontend normalization, draft state, and settings discovery. +- Add a checkbox in `ProfileFormFields` or a focused child component. Use the current effective strategy to determine visibility. +- Add lifecycle preparation before ACP and terminal process launch. Use resolved execution-profile state, not the stable Office identity. +- Update `docs/public/agents-and-profiles.md` with local eligibility, default, conflicts, disable timing, and preservation of regular auth files. + +## ASCII UI preview + +### UI-01: Cursor profile settings + +Entry: Settings > Agents > Cursor profile. Shared desktop/phone composition, enabled state. + +```text +Profile settings + ...existing fields... + [x] Share local Cursor MCP credentials + Reuse MCP sign-ins from other local + Cursor projects. Applies on the next launch. + +Existing settings save controls: + [Discard] [Save changes] +``` + +Unchecked draft: `[ ]`, with the same save controls. +After save, unchecked persists across reload. +Loading and failed-save behavior use the existing settings surface. +The description of disable timing stays visible without hover. + +Required structure: labeled checkbox, visible helper text, existing save/discard flow. +Phone: wrap text, make the label target at least 44px high, and retain the existing page scroll owner. +Desktop: retain current form density. +Copy and spacing are illustrative. Localization supplies final copy. +Maps to AC-AGENTS-CURSOR-AUTH-002.2, .5, and .6. + +## Tests + +Proposed test names are implementation targets, not existing evidence. + +| Criteria | Evidence | +| --- | --- | +| 001.1, .2, .6; 003.1, .5, .6 | `cursor_auth_bridge_test.go`: `TestAggregateCursorMCPAuth` table cases | +| 001.4; 003.2, .3, .4 | Same file: `TestDeriveCursorProjectSlug`, `TestLinkCursorMCPAuth`, `TestCursorMCPAuthConcurrentPreparation` | +| 002.3, .4 | Same file: `TestDisableCursorMCPAuth`; lifecycle cleanup failure test | +| 002.1, .7 | Store migration/round-trip, controller create/patch/duplicate, DTO and settings catalog tests | +| 002.2, .5, .6 | Frontend form/state tests and both Playwright files below | +| 001.3, .5; 003.7 | `manager_project_mcp_test.go` and `manager_passthrough_mcpfiles_test.go`: eligibility, empty servers, errors, both launch modes | +| 001.1, .4 | `TestCursorMCPAuthWorktree`: temporary git worktree and synthetic home | + +Each work order requires RED-GREEN-REFACTOR for changed logic. +Production helpers use Go only. Test fixture setup can invoke git to create a real temporary worktree. + +## E2E tests + +Add `apps/web/e2e/tests/settings/cursor-mcp-auth.spec.ts` for the `chromium` project. +Add `apps/web/e2e/tests/settings/mobile-cursor-mcp-auth.spec.ts` for `mobile-chrome`. +Use shared test helpers for disposable Cursor profile setup. + +Prove default enabled, disable/save/reload, re-enable, discard, and hiding for non-Cursor profiles. +Also cover the custom terminal profile with strategy `cursor`. +Assert an accessible checkbox name and keyboard Space on desktop. +On phone, tap the label, measure its target, and assert no horizontal document overflow. +These flows cover AC-AGENTS-CURSOR-AUTH-002.1, .2, .5, and .6. +Use causal network/event waits and backend fixtures. Do not authenticate real MCP providers. + +## Work orders + +- [x] [Task 01: Safe Cursor auth helpers](task-01-auth-helpers.md) +- [x] [Task 02: Profile preference and settings checkbox](task-02-profile-preference.md) +- [x] [Task 03: Launch integration and delivery verification](task-03-launch-integration.md) + +## Verification results + +Design validation on 2026-09-24: catalog validation passed, all specification files passed lint, and 36 specification-linter tests passed. +Relative document links and whitespace checks passed for all six new documents. + +Implementation completed on 2026-09-25. `make -C apps/backend build`, focused MCP bridge and lifecycle tests, lifecycle race tests, focused frontend tests (104 tests), TypeScript, i18n, desktop E2E (2 tests), mobile E2E (1 test), documentation validation, specification validation, and `git diff --check` passed. + +The requested `make -C apps/backend test` target ran but did not pass. The process-probe package's two real-tree timing tests also fail in isolation. Common-config and launcher package tests pass in isolation after clearing the host-injected `KANDEV_INTERNAL_CONFIG_FILE` and `KANDEV_INTERNAL_CONFIG_HOME_FILE` values; those values made the broad run load `/root/.kandev/config.yaml` during tests that expect temporary configuration. The broad suite was not rerun during review follow-up. + +Review follow-up passed the full focused MCP bridge and lifecycle suites, their race variants, the backend build, public-doc tests/validation, and whitespace/format checks. Terminal profile resolution now supplies the agent and preference from one successful lookup, and eligible local Cursor launches fail closed on missing profile data. Tests canonicalize workspace paths and cover a workspace under a symlinked parent. Public docs now distinguish symlink replacement while sharing is enabled from preservation of unrelated symlinks during opt-out cleanup. + +## Risks + +- Source modification time is deterministic but does not prove token validity. +- Different profiles can share one canonical workspace and therefore one auth link. +- Cursor can update the master or replace its symlink. Regular files remain protected. +- The bridge cannot coordinate token refresh with external processes. +- A non-default process HOME or remote executor cannot safely use the backend's host credentials. +- Filesystem rename behavior and symlink permission differ across operating systems. +- The supplied Cursor filesystem contract requires real-version compatibility evidence before claiming live OAuth success. +- Credential entries are matched by exact server name without checking project MCP URLs or OAuth issuers. Users must trust project configurations launched with sharing enabled and revoke credentials through the provider if an unintended endpoint may have received them. +- No automatic backend restart or global runtime release flag is part of this request. diff --git a/docs/plans/cursor-mcp-oauth-bridge/task-01-auth-helpers.md b/docs/plans/cursor-mcp-oauth-bridge/task-01-auth-helpers.md new file mode 100644 index 0000000000..1c6d07f76b --- /dev/null +++ b/docs/plans/cursor-mcp-oauth-bridge/task-01-auth-helpers.md @@ -0,0 +1,82 @@ +--- +id: "01-auth-helpers" +title: "Safe Cursor auth helpers" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-AGENTS-CURSOR-AUTH-001 + - REQ-AGENTS-CURSOR-AUTH-002 + - REQ-AGENTS-CURSOR-AUTH-003 +acceptance_criteria: + - AC-AGENTS-CURSOR-AUTH-001.1 + - AC-AGENTS-CURSOR-AUTH-001.2 + - AC-AGENTS-CURSOR-AUTH-001.4 + - AC-AGENTS-CURSOR-AUTH-001.6 + - AC-AGENTS-CURSOR-AUTH-002.3 + - AC-AGENTS-CURSOR-AUTH-003.1 + - AC-AGENTS-CURSOR-AUTH-003.2 + - AC-AGENTS-CURSOR-AUTH-003.3 + - AC-AGENTS-CURSOR-AUTH-003.4 + - AC-AGENTS-CURSOR-AUTH-003.5 + - AC-AGENTS-CURSOR-AUTH-003.6 +system_design: + - ../../specs/agents/system-design/cursor-mcp-oauth-bridge.md +--- + +# Safe Cursor auth helpers + +## Summary + +Create safe, independently tested filesystem helpers with synthetic credentials. + +## Scope and owned files + +- New `apps/backend/internal/agent/mcpconfig/cursor_auth_bridge.go`. +- New `apps/backend/internal/agent/mcpconfig/cursor_auth_bridge_test.go`. +- A focused private helper file if Go size limits require it. +- Requested helper signatures, private aggregation result, and bridge-link cleanup. Aggregation accepts excluded workspace roots so task credentials remain excluded with custom `tasks_base_path` values. +- Serialization, source selection, canonical workspace resolution, atomic writes, and destination preservation. + +Exclude lifecycle hooks, profile persistence, UI, OAuth calls, and real home-directory reads. + +## Implementation acceptance + +1. All filesystem cases in the design pass, including duplicate timestamps, symlinked source directories, and task worktrees beneath a custom configured root. +2. No-source and malformed-source cases cannot create a new link to a stale master. Regular destinations remain unchanged. +3. Parallel bridge calls produce complete snapshots and links. Disabled cleanup removes only a matching bridge link. +4. Slug derivation covers Windows separators and drive prefixes. Tests that create symlinks skip only when symlink creation is unavailable. + +## TDD and verification + +First write table cases for selection, slug derivation, protected files, and removal. +Then implement the smallest helpers that satisfy them. +Add error cases for malformed JSON, unreadable entries, invalid master type, and failed publication. +Use injected failures where root execution makes permission tests unreliable. +Check exact mode bits on supported filesystems. +Check temporary-file cleanup after failures. + +From `apps/backend`: + +```bash +go test -v ./internal/agent/mcpconfig/... +go test -race ./internal/agent/mcpconfig/... +``` + +## Dependencies and risks + +No implementation dependency. Use only standard-library filesystem and JSON operations. +The mutex coordinates Kandev callers, not Cursor or hostile external path replacement. +Do not promise token validity or external-process refresh synchronization. + +## Results + +Implemented the synthetic Cursor auth aggregation, atomic master publication, project symlink, protected destination handling, canonical workspace resolution, and disabled cleanup helpers. + +Verification passed from `apps/backend`: + +- `go test ./internal/agent/mcpconfig/...` +- `go test -race ./internal/agent/mcpconfig/...` + +Review follow-up adds deterministic Windows drive and UNC slug cases, custom task-root exclusion (including stale Cursor entries after the root is removed), same-name credential sharing coverage for the documented cross-origin trust boundary, and a Windows CI slug test. Symlink-dependent tests probe host support and skip when unavailable. diff --git a/docs/plans/cursor-mcp-oauth-bridge/task-02-profile-preference.md b/docs/plans/cursor-mcp-oauth-bridge/task-02-profile-preference.md new file mode 100644 index 0000000000..86b63e414a --- /dev/null +++ b/docs/plans/cursor-mcp-oauth-bridge/task-02-profile-preference.md @@ -0,0 +1,132 @@ +--- +id: "02-profile-preference" +title: "Profile preference and settings checkbox" +status: done +wave: 2 +depends_on: + - "01-auth-helpers" +plan: "plan.md" +requirements: + - REQ-AGENTS-CURSOR-AUTH-002 +acceptance_criteria: + - AC-AGENTS-CURSOR-AUTH-002.1 + - AC-AGENTS-CURSOR-AUTH-002.2 + - AC-AGENTS-CURSOR-AUTH-002.5 + - AC-AGENTS-CURSOR-AUTH-002.6 + - AC-AGENTS-CURSOR-AUTH-002.7 +system_design: + - ../../specs/agents/system-design/cursor-mcp-oauth-bridge.md +--- + +# Profile preference and settings checkbox + +## Summary + +Persist the default-enabled preference and expose one localized checkbox through the existing settings flow. + +## Scope and owned files + +- `apps/backend/internal/agent/settings/models/models.go`. +- `settings/store/sqlite.go` and migration/duplicate/round-trip tests. +- `settings/dto/profile_contract.go`, `dto.go`, controller CRUD and conversion files, and their tests. +- `apps/backend/internal/settingscatalog/defaults.go` and catalog contract tests. +- Generated settings discovery contracts in `apps/web/lib/settings-discovery/`. +- `apps/backend/internal/agent/runtime/lifecycle/types.go` and `profile_resolver.go` for the resolved preference. +- Web `lib/types/agent-profile.ts`, `lib/types/backend.ts`, `lib/api/domains/agent-profile-normalize.ts`, and profile mutation types. +- Web `components/settings/profile-form-fields.tsx`, `agent-profile-page.tsx`, `agent-profile-page-state.ts`, `agent-profile-dirty.ts`, and `agent-profile-reconciliation.ts`. +- Web `app/settings/agents/[agentId]/agent-save-helpers.ts` and creation flow defaults. +- Relevant tests and `src/locales/{en,pt-pt,zh-cn,zh-tw,zh-hk,ja}/agents.json`. +- New desktop/phone E2E files from the plan and their shared setup helper. + +Follow existing field mappings into boot payloads, actions, export/import contracts, and event normalization where they explicitly enumerate profile fields. +Preserve false through every supported profile round trip. +Exclude launch filesystem mutations and changes to generic permission behavior. + +## Implementation acceptance + +1. Migration and all creation paths default to true. Explicit false survives update, duplication, reload, and unrelated edits. +2. Applicable profiles expose UI-01 with normal save/discard and conflict behavior. Other profiles do not expose the checkbox. +3. Desktop and phone tests prove persistence, accessibility, touch size, and localization completeness. + +## ASCII UI preview + +### UI-01: Cursor profile settings + +Entry: Settings > Agents > Cursor profile. Shared desktop/phone composition, enabled state. + +```text +Profile settings + ...existing fields... + [x] Share local Cursor MCP credentials + Reuse MCP sign-ins from other local + Cursor projects. Applies on the next launch. + +Existing settings save controls: + [Discard] [Save changes] +``` + +Unchecked draft: `[ ]`, with the same save controls. +After save, unchecked persists across reload. +Loading and failed-save behavior use the existing settings surface. +The description of disable timing stays visible without hover. + +Required structure: labeled checkbox, visible helper text, existing save/discard flow. +Phone: wrap text, make the label target at least 44px high, and retain the existing page scroll owner. +Desktop: retain current form density. +Copy and spacing are illustrative. Localization supplies final copy. +Maps to AC-AGENTS-CURSOR-AUTH-002.2, .5, and .6. + +Full context: [plan preview](plan.md#ascii-ui-preview). + +## TDD and verification + +Write failing backend tests for omitted create, explicit false, omitted patch, migration, and duplicate. +Write frontend tests for normalization, dirty detection, patch conversion, and external update reconciliation. +Extend existing tests where the behavior already has a suite. + +From `apps/backend`: + +```bash +go test ./internal/agent/settings/... ./internal/settingscatalog/... +go test ./internal/agent/runtime/lifecycle/... -run TestProfileResolver +``` + +From `apps/web`: + +```bash +pnpm exec vitest run components/settings/profile-form-fields.test.tsx components/settings/agent-profile-page-state.test.ts components/settings/agent-profile-reconciliation.test.ts lib/api/domains/agent-profile-normalize.test.ts +pnpm run typecheck +pnpm run i18n:zh-hant +pnpm run i18n:check +pnpm e2e:run --host --project chromium -- tests/settings/cursor-mcp-auth.spec.ts +pnpm e2e:run --host --project mobile-chrome -- tests/settings/mobile-cursor-mcp-auth.spec.ts +``` + +The managed E2E runner rebuilds production assets. +Use the standard fixture mock agents or an existing custom terminal fixture with Cursor strategy. +Keep the fixture independent of a real Cursor install. +Record screenshots or a rendered inspection for the phone checkbox. + +## Dependencies and risks + +Depends on Task 01 for agreed helper policy. +Omitted booleans and explicit false must remain distinct in create and patch requests. +Reconciliation and duplicated profiles can silently lose a field if only the main editor changes. + +## Results + +Implemented the profile preference through SQLite, DTO/controller contracts, runtime profile resolution, frontend drafts, save paths, localization, and the settings checkbox. The checkbox appears for Cursor ACP and custom Cursor-strategy agents, defaults on, and uses the normal reset/save and external-conflict flow. + +Validation passed: + +- `go test ./internal/agent/settings/... ./internal/settingscatalog/...` +- `go test ./internal/agent/runtime/lifecycle/... -run TestProfileResolver` (the pattern matches no tests; full lifecycle tests remain in Task 03) +- Focused Vitest suites for form rendering, reconciliation, normalization, page save, and agent save helpers. +- `pnpm run typecheck` +- `pnpm run i18n:check` +- Chromium settings E2E: 2 passed, including persistence and non-Cursor visibility. +- Mobile Chrome settings E2E: 1 passed, including touch-size and overflow checks. + +`pnpm run i18n:zh-hant` is blocked by the existing simplified-string warning at `workflows:openAgentSettings`. The supported namespace-scoped generator wrote the `agents` Traditional Chinese catalogs from Simplified Chinese without reformatting other translated messages. + +Review follow-up makes both profile editor and agent-level profile saves omit an unchanged Cursor MCP auth preference, so stale drafts do not overwrite a newer value. Regression tests cover both save paths. The default-field test now also fails if the catalog field is absent. Regenerated the settings contract snapshots after CI detected that they did not yet include the new preference; `go run ./cmd/settings-catalog --check` passes. diff --git a/docs/plans/cursor-mcp-oauth-bridge/task-03-launch-integration.md b/docs/plans/cursor-mcp-oauth-bridge/task-03-launch-integration.md new file mode 100644 index 0000000000..b063c7b7ba --- /dev/null +++ b/docs/plans/cursor-mcp-oauth-bridge/task-03-launch-integration.md @@ -0,0 +1,124 @@ +--- +id: "03-launch-integration" +title: "Launch integration and delivery verification" +status: complete +wave: 3 +depends_on: + - "02-profile-preference" +plan: "plan.md" +requirements: + - REQ-AGENTS-CURSOR-AUTH-001 + - REQ-AGENTS-CURSOR-AUTH-002 + - REQ-AGENTS-CURSOR-AUTH-003 +acceptance_criteria: + - AC-AGENTS-CURSOR-AUTH-001.1 + - AC-AGENTS-CURSOR-AUTH-001.3 + - AC-AGENTS-CURSOR-AUTH-001.4 + - AC-AGENTS-CURSOR-AUTH-001.5 + - AC-AGENTS-CURSOR-AUTH-002.3 + - AC-AGENTS-CURSOR-AUTH-002.4 + - AC-AGENTS-CURSOR-AUTH-003.7 +system_design: + - ../../specs/agents/system-design/cursor-mcp-oauth-bridge.md +--- + +# Launch integration and delivery verification + +## Summary + +Apply the preference before eligible local Cursor launches and verify the complete feature. + +## Scope and owned files + +- `apps/backend/internal/agent/runtime/lifecycle/manager_project_mcp.go` and its tests. +- A focused lifecycle bridge helper, plus `manager_passthrough.go` and `manager_passthrough_mcpfiles_test.go`. +- `manager_launch.go` for resolved-profile forwarding at launch and workspace promotion. +- Existing execution-profile, environment, and runtime classification helpers as required for eligibility. +- `apps/backend/internal/agent/mcpconfig/cursor_auth_bridge_test.go` for the temporary-worktree integration case. +- `docs/public/agents-and-profiles.md`, this package's results/statuses, and paired specifications after verified implementation. + +Exclude remote credential transport, background refresh, process termination, and automatic OAuth login. + +## Implementation acceptance + +1. Both Cursor launch paths prepare auth before process start, including empty profile MCP configuration and workspace promotion. +2. Locality and resolved-profile tests prove opt-out, non-Cursor no-op, remote no-op, and failure policy. +3. A temporary git worktree receives the expected link. All required backend and desktop/phone checks pass. + +## TDD and verification + +Write lifecycle RED tests before launch changes. +Inject a temporary home and use synthetic source credentials. +Cover disabled cleanup failure, missing Cursor home, malformed sources, canonical path aliases, and preserved regular destinations. +Use the actual resolved execution profile in an Office/dynamic-profile case. +Cover resumed process launch and prove an existing running process is not reconfigured. +Add `TestCursorMCPAuthWorktree` with a temporary repository and real git worktree. +Assert `Readlink`, master JSON, and absence of writes to the original source files. + +From `apps/backend`: + +```bash +go test -v ./internal/agent/mcpconfig/... ./internal/agent/runtime/lifecycle/... +go test -race ./internal/agent/mcpconfig/... ./internal/agent/runtime/lifecycle/... +go test -v ./internal/agent/mcpconfig/... -run TestCursorMCPAuthWorktree +``` + +From the repository root: + +```bash +make -C apps/backend test +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +From `apps/web`, after backend integration: + +```bash +pnpm e2e:run --host --project chromium -- tests/settings/cursor-mcp-auth.spec.ts +pnpm e2e:run --host --project mobile-chrome -- tests/settings/mobile-cursor-mcp-auth.spec.ts +``` + +Run commands sequentially with the repository worker limits. +Record results in this work order and the plan. +Public docs use a short reference section in the existing agents guide. +Explain local eligibility, newest-file precedence, next-launch opt-out, and token-refresh limitations. + +## Dependencies and risks + +Depends on Task 02 and its profile resolver field. +Promotion updates execution identity after part of launch preparation. Pass the incoming resolved profile explicitly. +Do not append persistent bridge files to session-owned MCP cleanup lists. +The fixture proves filesystem behavior, not successful OAuth against live Atlassian or Figma. + +## Results + +Completed on 2026-09-25. + +- Passed `make -C apps/backend build`. +- Passed `go test -v ./internal/agent/mcpconfig/... ./internal/agent/runtime/lifecycle/...` and `go test -race ./internal/agent/mcpconfig/... ./internal/agent/runtime/lifecycle/...`. +- Passed `pnpm run typecheck`, `pnpm run i18n:check`, and the five focused Vitest files: 104 tests passed. +- Passed desktop Playwright: 2 tests; mobile Chrome Playwright: 1 test. +- Passed public-doc validation (62 validator tests and 47 published pages), specification validation/lint (305 decisions, 1144 specifications), and `git diff --check`. +- `make -C apps/backend test` ran but failed in checks outside this change. `internal/agentctl/server/process/probe` failed `TestProbeRealTree_AllDescendantsPreTurn_Settled` and `TestProbeRealTree_NewDescendantAfterTurnStart_Live` both in the full run and in isolation. `internal/common/config` and `internal/launcher` failed during the broad run because the host environment injected `KANDEV_INTERNAL_CONFIG_FILE=/root/.kandev/config.yaml`; both packages pass when run in isolation with `KANDEV_INTERNAL_CONFIG_FILE` and `KANDEV_INTERNAL_CONFIG_HOME_FILE` unset. +- Review follow-up: `resolvePassthroughAgent` now obtains agent and profile together and propagates resolver failures; eligible local Cursor preparation rejects unresolved profile data. A resume regression verifies opt-out cleanup happens before a later resolver error can prevent process start. Expected test paths resolve the workspace independently, with deterministic symlink-parent coverage. Public documentation now limits unrelated-symlink preservation to disabled cleanup. +- Re-ran `go test -v ./internal/agent/mcpconfig/... ./internal/agent/runtime/lifecycle/...`, the matching `-race` command, `make -C apps/backend build`, public-doc tests/validation, `gofmt`, and `git diff --check`; all passed. The broad backend suite was not rerun during review follow-up. + +PR review follow-up on 2026-09-25 also normalizes Windows Cursor project slugs and tests them in the Windows workflow; excludes the configured task-worktree root; recognizes HOME aliases by filesystem identity; and omits unchanged Cursor auth values from profile-save patches. It regenerates both settings discovery snapshots that the initial PR's frontend CI found stale, strengthens the settings-default assertion, and guards symlink-dependent tests on platforms without symlink support. The public guide and authoritative requirements/design now disclose that credentials are shared by exact server name without URL or issuer comparison. A synthetic test records that accepted trust boundary. Existing links remain when a refresh has no valid source, as required by the approved system-design compatibility limit. + +The initial PR-head frontend check failed because its generated settings snapshot was stale. The generated files are now current. Backend results from the original implementation remain as documented above; the broad backend suite was not rerun for this review follow-up. + + +Local review-fixup verification passed on 2026-09-25: + +- `go test ./internal/agent/mcpconfig/... ./internal/agent/runtime/lifecycle/... ./internal/settingscatalog/...` +- `go test -race ./internal/agent/mcpconfig/... ./internal/agent/runtime/lifecycle/... ./internal/settingscatalog/...` +- `GOOS=windows GOARCH=amd64 go test -c -o /tmp/cursor-mcpconfig-windows.test.exe ./internal/agent/mcpconfig` +- `golangci-lint run ./... --new-from-rev=b88aea31ad49b2cda40e8ad84452356888e36a42 --timeout=5m` +- `make -C apps/backend build` +- Cursor profile and agent-level save Vitest suites (49 tests combined), web typecheck, targeted ESLint, and Prettier check. +- Settings contract CI test (3 tests), public-doc validation (62 tests, 47 pages), specification catalog validation/lint, and `git diff --check`. + +The generated settings snapshots now pass `node --test scripts/settings-contract-ci.test.mjs`. Refreshed PR checks will be recorded after pushing the fixup commit. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index 512ff6702a..b581aed78a 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -225,6 +225,7 @@ Select an agent, create a profile, then open **Settings > Agents > _Agent_ > _Pr | Enabled | Keeps the profile available to existing sessions and settings while hiding it from new task, session, handoff, and Quick Chat selectors. | | Auto-approve all permissions | Answers automatically: the first `allow_once`/`allow_always` option, otherwise the first option supplied by the agent; no options cancels. It is off by default. | | MCP servers | Adds profile-specific external MCP servers when the agent supports MCP. | +| Share local Cursor MCP credentials | Enabled by default for Cursor ACP and Cursor-strategy terminal profiles. It applies only to local executions that share the backend home directory. | Agents can inspect and update declared profile settings through the compact `search_settings_kandev`, `describe_setting_kandev`, `get_settings_kandev`, @@ -463,6 +464,16 @@ Terminal custom profiles use passthrough and preserve the CLI's native PTY inter > **MCP credential exposure:** MCP headers and environment values are stored in profile configuration. Codex may place them in process arguments, and Cursor or Pi may leave them in project files after teardown. Use short-lived, narrowly scoped credentials and review persisted files. +### Share local Cursor MCP credentials + +The **Share local Cursor MCP credentials** profile option is enabled by default for Cursor ACP and custom terminal profiles that use Cursor's MCP strategy. It applies only to local and worktree executions that use the same home directory as the Kandev backend. Remote and container executors, and profiles that set a different `HOME`, do not share the backend user's credentials. + +On launch, Kandev combines valid `mcp-auth.json` files from other local Cursor projects into `~/.cursor/kandev-mcp-auth-unified.json`. For duplicate server names, the newest source file wins. Equal timestamps use project path order. Kandev links the current project's auth path to this private shared file. It preserves an existing regular `mcp-auth.json` file. While sharing is enabled, it replaces an existing symlink at that path when it points elsewhere. Disabling sharing does not restore the replaced target. On a disabled launch, Kandev removes only its own project auth link and leaves unrelated symlinks unchanged. + +The shared auth file is keyed by MCP server name. Kandev does not compare server URLs or OAuth issuers between projects. A project with a matching server name can therefore use a copied credential even when its MCP configuration points to a different endpoint. Enable sharing only for trusted local project configurations. If a credential may have reached an unintended endpoint, revoke it through that provider. + +To stop sharing, clear the option and launch that profile again. That launch removes only the Kandev-created link for its project. Running processes keep credentials they already loaded. Cursor may refresh credentials through the link, but Kandev does not copy refreshed credentials back to source projects. A later launch rebuilds the shared file from those source projects. +
Configure external MCP servers diff --git a/docs/specs/agents/requirements/cursor-mcp-oauth-bridge.md b/docs/specs/agents/requirements/cursor-mcp-oauth-bridge.md new file mode 100644 index 0000000000..8a12a72941 --- /dev/null +++ b/docs/specs/agents/requirements/cursor-mcp-oauth-bridge.md @@ -0,0 +1,81 @@ +--- +status: draft +system: agents +created: 2026-09-24 +owners: + - kandev +--- + +# Cursor MCP OAuth bridge requirements + +## Overview + +Cursor stores MCP OAuth credentials per project. A new task worktree therefore lacks credentials that exist in another local project. +The agent system owns this capability because it owns profile preferences and provider launch behavior. + +## Requirements + +### REQ-AGENTS-CURSOR-AUTH-001: Local credential sharing + +**Intent:** Local Cursor sessions inherit MCP credentials from existing projects. + +#### Acceptance criteria + +- **AC-AGENTS-CURSOR-AUTH-001.1:** With sharing enabled, a new local Cursor execution shall inherit credentials from eligible non-task projects. Different server identities shall coexist. +- **AC-AGENTS-CURSOR-AUTH-001.2:** For duplicate server identities, the most recently modified source auth file shall win. Equal timestamps shall use ascending project-path order, with the first path winning. +- **AC-AGENTS-CURSOR-AUTH-001.3:** Both Cursor ACP and terminal profiles with the Cursor MCP strategy shall support sharing. Sharing shall work without profile-configured MCP servers. +- **AC-AGENTS-CURSOR-AUTH-001.4:** A workspace and its canonical filesystem path shall resolve to the same Cursor project identity. +- **AC-AGENTS-CURSOR-AUTH-001.5:** Absent Cursor data or absent eligible auth files shall produce a silent no-op. Other agents and remote executions shall not receive host credentials. +- **AC-AGENTS-CURSOR-AUTH-001.6:** Each eligible launch shall refresh the shared snapshot from current source files. Kandev shall preserve each selected server object without interpretation. + +### REQ-AGENTS-CURSOR-AUTH-002: Profile control + +**Intent:** Users can choose whether each Cursor profile shares local credentials. + +#### Acceptance criteria + +- **AC-AGENTS-CURSOR-AUTH-002.1:** Existing and new Cursor profiles shall default to enabled. An explicit disabled value shall survive save, reload, restart, duplication, and unrelated profile edits. +- **AC-AGENTS-CURSOR-AUTH-002.2:** Applicable profile settings shall expose a labeled checkbox. The description shall explain local cross-project sharing and next-launch timing. +- **AC-AGENTS-CURSOR-AUTH-002.3:** On the next disabled launch, Kandev shall remove only that workspace's link to its unified auth file. It shall preserve other links and regular files. +- **AC-AGENTS-CURSOR-AUTH-002.4:** Disabling shall not remove the shared file or change running sessions. If link removal fails, the new execution shall fail with a credential-free error. +- **AC-AGENTS-CURSOR-AUTH-002.5:** Desktop and phone users shall save, discard, and reload the same value. Keyboard access, visible focus, and localized labels shall remain available. +- **AC-AGENTS-CURSOR-AUTH-002.6:** The phone checkbox label shall provide a touch target of at least 44px. The settings page shall have no horizontal overflow. +- **AC-AGENTS-CURSOR-AUTH-002.7:** Profile APIs and settings discovery shall expose the saved boolean and its default. They shall preserve omission separately from explicit false. + +### REQ-AGENTS-CURSOR-AUTH-003: Credential file safety + +**Intent:** The bridge preserves user files and keeps credentials out of application data and logs. + +#### Acceptance criteria + +- **AC-AGENTS-CURSOR-AUTH-003.1:** Task-project auth files and symlinked source entries shall never contribute credentials to the shared snapshot. +- **AC-AGENTS-CURSOR-AUTH-003.2:** A regular destination auth file shall remain byte-for-byte unchanged. Directories and special files shall also remain unchanged. +- **AC-AGENTS-CURSOR-AUTH-003.3:** Updates shall expose complete JSON snapshots and complete symlink targets. Concurrent Kandev launches shall not expose partial writes. +- **AC-AGENTS-CURSOR-AUTH-003.4:** Kandev shall create shared credential files with owner-only read/write permissions. Credentials shall never appear in logs, APIs, events, or browser state. +- **AC-AGENTS-CURSOR-AUTH-003.5:** Invalid or unreadable source files shall not prevent valid independent sources from contributing. Diagnostics shall omit credential contents. +- **AC-AGENTS-CURSOR-AUTH-003.6:** If no valid source remains, Kandev shall not create a new link to an old snapshot. Existing user files shall remain unchanged. +- **AC-AGENTS-CURSOR-AUTH-003.7:** Enabled bridge failures shall preserve existing files and allow normal Cursor startup. Cursor can then request authentication through its existing flow. + +## Scope and limits + +The checkbox controls bridge preparation, not OAuth revocation or credential isolation between processes that use the same workspace. +A running process can retain credentials already loaded into memory. +Profile choices share a filesystem destination when they use the same canonical workspace. + +### Credential trust boundary + +The bridge uses the exact MCP server name as the credential identity. It does not compare server URLs or OAuth issuers between projects. A trusted local project configuration with a matching name can therefore use a copied credential at a different endpoint. This is an accepted consequence of the default-enabled cross-project sharing behavior. Users are responsible for trusting project configurations launched with sharing enabled and for revoking provider credentials if an unintended endpoint may have received them. + +The user chose newest-file conflict precedence and removal of the bridge link on the next disabled launch. +File modification time does not prove credential freshness or token validity. + +## Out of scope + +- OAuth login, token validation, token refresh, and server configuration discovery. +- Host credential transfer to containers, SSH, Kubernetes, or other remote runtimes. +- Per-server account selection, background synchronization, and revocation of cached tokens. +- Reading real developer credentials during automated verification. + +## Implementation plans + +- [Cursor auth bridge plan](../../../plans/cursor-mcp-oauth-bridge/plan.md) diff --git a/docs/specs/agents/system-design/cursor-mcp-oauth-bridge.md b/docs/specs/agents/system-design/cursor-mcp-oauth-bridge.md new file mode 100644 index 0000000000..2c51e0f366 --- /dev/null +++ b/docs/specs/agents/system-design/cursor-mcp-oauth-bridge.md @@ -0,0 +1,220 @@ +--- +status: draft +system: agents +requirements: + - REQ-AGENTS-CURSOR-AUTH-001 + - REQ-AGENTS-CURSOR-AUTH-002 + - REQ-AGENTS-CURSOR-AUTH-003 +--- + +# Cursor MCP OAuth bridge system design + +## Ownership and source evidence + +Agents owns the profile value and launch policy. Executors owns the local-versus-remote boundary. +The [settings parity contract](../../platform/requirements/agent-settings-parity.md) supplies discovery and mutation conventions. + +Current `materializeRuntimeProjectMCP` writes project MCP configuration for ACP providers. +It returns early when no servers exist. +Current `applyPassthroughMCP` handles terminal strategies separately. +`CursorACP.Runtime` declares `mcpconfig.CursorStrategy{}`. +Custom terminal agents can select the persisted strategy key `cursor`. + +The user supplied the Cursor auth layout and slug algorithm. +Automated tests will exercise that contract with synthetic credentials. +They cannot prove compatibility with every Cursor release. + +## Requirement mapping + +| Requirement | Design sections | +| --- | --- | +| REQ-AGENTS-CURSOR-AUTH-001 | Eligibility, aggregation, launch integration | +| REQ-AGENTS-CURSOR-AUTH-002 | Profile contract, disable behavior, settings UI | +| REQ-AGENTS-CURSOR-AUTH-003 | File safety, failures, verification | + +## Profile contract + +Add `cursor_mcp_auth_enabled` to the persisted agent profile and shared DTO contracts. +Use `CursorMCPAuthEnabled` in Go and `cursorMcpAuthEnabled` in browser models. +This is a profile preference with a user-requested default of true, not a release toggle. + +Add an integer SQLite column with `NOT NULL DEFAULT 1`. +Cover fresh schema creation, additive migration, and legacy table reconstruction. +Create requests use a boolean pointer so omission selects true and explicit false remains false. +Patch omission preserves the saved value. +All creation paths, including discovery and seeding, must set the default explicitly when SQL inserts include the column. +Duplication copies the value. Reconciliation must not reset a saved false. + +Extend models, DTOs, CRUD conversion, SELECT/scan, INSERT/UPDATE, boot payloads, and profile notifications. +Register the boolean in `settings/dto/profile_contract.go` and `settingscatalog/defaults.go`. +Use the existing compact settings update contract and field validation. +Only the preference crosses APIs. Credential objects never cross those boundaries. + +Extend `AgentProfileInfo` and `profile_resolver.go`. +Launch policy uses the resolved concrete execution profile, including Office and dynamic-profile selection. +Do not resolve an Office identity profile in place of its execution profile. +Missing or failed profile resolution must not silently enable credential sharing. + +## Eligibility and home + +The bridge runs for `cursor-acp` and effective `CursorStrategy` profiles. +Other agents do not read or mutate Cursor auth data. +Resolve eligibility from the actual agent/strategy, not a command-name substring. + +Only native local executions that share the backend filesystem and user home are eligible. +Use existing executor/runtime classification and effective process environment. +Exclude containers and remote runtimes even when a backend-local workspace path exists. +If a profile overrides HOME to another user directory, skip host sharing rather than link unusable or unintended credentials. +Treat unknown execution locality as ineligible. + +Resolve the backend user home through Go, then use its `.cursor` directory. +Do not create a missing Cursor home. +Tests inject a temporary home and never scan the developer's home. + +## Helper boundary and aggregation + +Add `mcpconfig/cursor_auth_bridge.go` with the requested public helpers: + +- `DeriveCursorProjectSlug(workspacePath string) string` +- `AggregateCursorMCPAuth(cursorHome string, excludedWorkspaceRoots ...string) error` +- `LinkCursorMCPAuth(workspacePath, cursorHome string, excludedWorkspaceRoots ...string) error` + +Keep slug derivation pure. The link helper resolves an absolute path and evaluates workspace symlinks before derivation. +Normalize Windows separators. Replace each slash, dot, underscore, and colon, including a drive-volume separator, with a dash, then trim boundary dashes. +Do not collapse internal dash runs. Reject an empty resulting slug. + +Scan direct project directories under `/projects`. +Exclude the current workspace and project slugs under the default task scratch root and configured task-worktree root, names containing `kandev-tasks`, symlinked project entries, and non-directory entries. +Task-root exclusion compares each canonical root's project slug with entry slug prefixes, so custom `tasks_base_path` values do not depend on their name containing `kandev-tasks`. +When a configured root is absent, resolve its existing parent and append the missing path suffix. This still excludes stale Cursor project entries after task worktree directories are removed. A different root-resolution error prevents aggregation rather than risking task credentials in the snapshot. +Use `Lstat` for each auth file and accept only regular, non-symlink files. +Also reject a symlinked projects root to avoid scanning an unexpected tree. + +Decode JSON as `map[string]json.RawMessage`. +Require an object root and object-valued server entries. +Skip invalid files as a whole. Preserve unknown fields inside server objects. +Order sources by descending modification time and ascending path. +Select the first object for each exact server key. +Do not deep-merge tokens or combine accounts. + +Credential identity is the exact server name. The bridge does not read project MCP configuration or compare server URLs or OAuth issuers. A local project that declares an existing server name can therefore use its copied credential at another endpoint. This is an accepted trust boundary of default-enabled sharing: users must trust local project configurations launched with sharing enabled and revoke credentials through the provider if an unintended endpoint may have received them. The synthetic aggregation test covers this name-only sharing behavior. + +Rebuild `/kandev-mcp-auth-unified.json` from sources on each eligible enabled launch. +The prior master is never a source. +A valid empty source object can produce an empty snapshot. +No eligible valid source means no master publication and no new link. +Use an internal result that distinguishes no sources from a successful empty snapshot. +The public error-only helper can wrap this result. +The composed link operation uses the result directly and must not infer success from an old master. + +## File safety and concurrency + +Use a package mutex around the complete bridge operation. +Concurrent Kandev launches serialize discovery, publication, linking, and disabled cleanup. +The lock must also cover public helper calls without recursive locking. +Independent backend processes and Cursor do not participate in this mutex. +Unique temporary names and atomic publication keep their writes complete, but cannot provide token-refresh coordination. + +Write the master to a temporary file in the same directory. +Create it with mode `0600`, write complete JSON, close it, then rename it into place. +Remove temporary files after every failure. +Reject a pre-existing master that is a symlink, directory, or special file. +The master is a Kandev-owned regular file and can be replaced. +The prohibition on replacing regular user auth files applies to the project destination. + +Create the project directory with requested mode `0755`, subject to umask. +Do not change permissions on existing user directories. +Reject a symlinked project destination directory. +Check destination `mcp-auth.json` with `Lstat`. +If it is regular, a directory, or special, preserve it without replacement. +If it already points to the master, leave the link unchanged. +For an absent destination, create the final symlink directly. Creation is atomic and fails if a file appears concurrently. +For an existing symlink, create a uniquely named sibling symlink and rename it over the old link. +Recheck destination type immediately before replacement. +This prevents ordinary launch races under the package lock. +It does not claim protection against an adversarial process that replaces paths between filesystem calls. + +The symlink target is the absolute master path. +Bridge files do not enter generic MCP teardown lists. +Stopping one session must not remove credentials used by another session. + +## Disable behavior + +On the next eligible disabled launch, resolve the canonical project destination without creating directories. +Use `Lstat` and `Readlink`. +Remove only a symlink whose normalized absolute target is the Kandev master. +Accept an equivalent relative target after resolution against the link directory. +Preserve regular files, unrelated links, and the master. +Do not aggregate on this path. + +A missing link is successful cleanup. +If a matching link cannot be removed, return a sanitized launch error. +Otherwise a disabled launch could still inherit shared credentials. +Saving the preference does not touch the filesystem or terminate running sessions. + +## Launch integration + +Call one lifecycle helper from `materializeRuntimeProjectMCP` before the empty-server return. +Call it from `applyPassthroughMCP` for the Cursor strategy before ordinary MCP materialization. +Keep `CursorStrategy.BuildPassthroughMCP` free of home-directory side effects. +Its callers also serve command previews and must not publish credentials. + +Pass the resolved execution profile into bridge preparation. +The two normal launch sites in `manager_launch.go` already hold resolved profile information. +Workspace promotion must use the incoming execution profile even before it updates execution metadata. +Exercise initial launch, workspace promotion, and resumed execution that starts a new process. +Do not rerun preparation for a message sent to an already running process. + +## Failures and diagnostics + +Missing Cursor home, absent sources, ineligible runtime, and protected user destinations produce no warning. +Malformed or unreadable sources can produce a bounded warning with an operation/reason code. +Never log JSON, server objects, tokens, or raw parser errors containing source text. + +Enabled publication or link errors produce a sanitized warning and preserve normal launch behavior. +A failed publication does not create a link to an older master. +Existing links can still reference the previous snapshot after a failed refresh. +This is a compatibility limit, not a claim of fresh authentication. +Disabled cleanup errors fail the new launch as described above. + +## Settings UI and mobile contract + +Show a checkbox in the existing profile settings card for Cursor and custom terminal agents whose MCP strategy is Cursor. +Use `@kandev/ui/checkbox`, a linked label, and visible description. +Do not classify this setting as a permission or send it as a CLI flag. +The value participates in create/edit drafts, dirty detection, save/discard, external-change reconciliation, and normalization. + +Proposed localized copy: + +- Label: "Share local Cursor MCP credentials" +- Description: "Reuse MCP sign-ins from other local Cursor projects. Applies on the next launch." +- Disabled timing note: "Turning this off removes the shared link on the next launch. Running sessions keep credentials already loaded." + +Use the existing Settings save controls and failure messages. +Add English, Portuguese, Simplified Chinese, both Traditional Chinese catalogs, and Japanese. +Generate the Traditional Chinese pair with `pnpm run i18n:zh-hant`. + +Entry point: Settings > Agents > applicable profile. +The nearest shipped exemplar is `agent-profile-page.tsx` and its shared settings card. +`mobile-agent-profile-layout.spec.ts` demonstrates direct phone navigation and overflow checks. +The form stays inline on the full settings route because this is one persistent preference. +The existing settings scroll container remains the sole page scroll owner. +The phone label wraps and supplies a 44px minimum touch target. +Desktop keeps normal form density. Both sizes share draft state and save logic. +No overlay or hover-only help is necessary. + +## Verification and limits + +Use synthetic opaque server objects and a temporary HOME. +The helper suite covers source exclusion, conflict order, canonical slug, permissions, atomic publication, protected destinations, concurrency, and disable cleanup. +Lifecycle tests cover eligibility, both launch modes, promotion, no-server behavior, profile resolution, and failure policy. +A temporary git repository/worktree test proves the expected link target without real OAuth. + +Desktop and phone Playwright tests prove default-on, save/reload false, re-enable, discard, and absence for other agents. +Unit tests cover request omission, migration, duplication, and frontend draft reconciliation. +The [plan](../../../plans/cursor-mcp-oauth-bridge/plan.md) owns exact commands and test names. + +Cursor can replace a symlink during its own auth writes. A later enabled launch repairs it only if it remains a symlink. +If Cursor creates a regular file, Kandev preserves that file. +Cursor token refresh through a link can update the master, but the next aggregation still uses source-project precedence. +This design does not synchronize refresh writes back to source projects.