diff --git a/internal/backend/host.go b/internal/backend/host.go
index 945d6f2..9048d59 100644
--- a/internal/backend/host.go
+++ b/internal/backend/host.go
@@ -27,10 +27,11 @@ const healthPath = "/healthz"
const tabServerBaseURL = "https://tab.leokun.cn"
type Host struct {
- store *serverconfig.Store
- listenAddr string
- configs *serverconfig.Manager
- healthHTTP *http.Client
+ store *serverconfig.Store
+ listenAddr string
+ configs *serverconfig.Manager
+ healthHTTP *http.Client
+ controlPlaneAuth upstream.AuthorizationProvider
runMu sync.RWMutex
httpServer *http.Server
@@ -40,7 +41,7 @@ type Host struct {
mux http.Handler
}
-func NewHost(store *serverconfig.Store) (*Host, error) {
+func NewHost(store *serverconfig.Store, controlPlaneAuth upstream.AuthorizationProvider) (*Host, error) {
if store == nil {
return nil, fmt.Errorf("backend config store is required")
}
@@ -50,10 +51,11 @@ func NewHost(store *serverconfig.Store) (*Host, error) {
}
cfg := configs.Current()
host := &Host{
- store: store,
- listenAddr: cfg.BackendListenAddr,
- configs: configs,
- healthHTTP: newLoopbackHTTPClient(),
+ store: store,
+ listenAddr: cfg.BackendListenAddr,
+ configs: configs,
+ healthHTTP: newLoopbackHTTPClient(),
+ controlPlaneAuth: controlPlaneAuth,
}
if err := host.rebuild(cfg); err != nil {
return nil, err
@@ -515,15 +517,25 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
server.POST("/aiserver.v1.DashboardService/GetManagedSkills",
server.Name("dashboard_get_managed_skills"),
server.ConnectUnary(),
- server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
- Name: "dashboard_get_managed_skills",
- StatusCode: http.StatusOK,
- MockProtoType: "aiserver.v1.GetManagedSkillsResponse",
- MockBuilder: upstream.DashboardManagedSkillsMockBuilder,
- })),
- server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
- Name: "dashboard_get_managed_skills",
- })),
+ server.Local(cursorControlPlaneAction(
+ host.controlPlaneAuth,
+ routeDeps,
+ "dashboard_get_managed_skills",
+ upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
+ Name: "dashboard_get_managed_skills",
+ StatusCode: http.StatusOK,
+ MockProtoType: "aiserver.v1.GetManagedSkillsResponse",
+ MockBuilder: upstream.DashboardManagedSkillsMockBuilder,
+ }),
+ )),
+ server.Upstream(cursorControlPlaneAction(
+ host.controlPlaneAuth,
+ routeDeps,
+ "dashboard_get_managed_skills",
+ upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
+ Name: "dashboard_get_managed_skills",
+ }),
+ )),
),
server.POST("/aiserver.v1.DashboardService/GetMe",
server.Name("dashboard_get_me"),
@@ -590,7 +602,24 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
Name: "dashboard_is_on_new_pricing",
})),
),
- // tabServerUpstreamProcedure("/aiserver.v1.DashboardService/GetEffectiveUserPlugins", "dashboard_get_effective_user_plugins", server.ConnectUnary(), routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/AddMarketplace", "dashboard_add_marketplace", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/AddMcpServersFromPlugin", "dashboard_add_mcp_servers_from_plugin", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/BatchGetPluginMcpConfig", "dashboard_batch_get_plugin_mcp_config", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/GetAvailableMcpServers", "dashboard_get_available_mcp_servers", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/GetEffectiveUserPlugins", "dashboard_get_effective_user_plugins", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/GetPlugin", "dashboard_get_plugin", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/GetPluginMcpConfig", "dashboard_get_plugin_mcp_config", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/InstallUserPlugin", "dashboard_install_user_plugin", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/ListMarketplacePlugins", "dashboard_list_marketplace_plugins", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/ListMarketplaces", "dashboard_list_marketplaces", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/ListUserPluginInstalls", "dashboard_list_user_plugin_installs", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/RefreshMarketplace", "dashboard_refresh_marketplace", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/RegisterMarketplaceAndPlugins", "dashboard_register_marketplace_and_plugins", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/RemoveMarketplace", "dashboard_remove_marketplace", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/ResolvePluginsByRef", "dashboard_resolve_plugins_by_ref", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/UninstallUserPlugin", "dashboard_uninstall_user_plugin", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.DashboardService/UpdateUserPluginInstall", "dashboard_update_user_plugin_install", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
+ cursorControlPlaneProcedure("/aiserver.v1.MCPRegistryService/GetKnownServers", "mcp_registry_get_known_servers", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
server.Any("/aiserver.v1.DashboardService/*",
server.Name("dashboard"),
server.HTTP(),
@@ -762,6 +791,52 @@ func tabServerUpstreamProcedure(pattern string, name string, protocol server.Rou
)
}
+func cursorControlPlaneProcedure(
+ pattern string,
+ name string,
+ protocol server.RouteOption,
+ authorizationProvider upstream.AuthorizationProvider,
+ deps upstream.Dependencies,
+) server.Option {
+ notFound := func(ctx *server.Context) error {
+ http.NotFound(ctx.Writer, ctx.Request)
+ return nil
+ }
+ return server.POST(pattern,
+ server.Name(name),
+ protocol,
+ server.Local(cursorControlPlaneAction(authorizationProvider, deps, name, notFound)),
+ server.Upstream(cursorControlPlaneAction(
+ authorizationProvider,
+ deps,
+ name,
+ upstream.DirectAction(deps, upstream.CompatRouteConfig{Name: name}),
+ )),
+ )
+}
+
+func cursorControlPlaneAction(
+ authorizationProvider upstream.AuthorizationProvider,
+ deps upstream.Dependencies,
+ name string,
+ fallback server.HandlerFunc,
+) server.HandlerFunc {
+ direct := upstream.AuthenticatedDirectAction(deps, upstream.CompatRouteConfig{Name: name}, authorizationProvider)
+ return func(ctx *server.Context) error {
+ if authorizationProvider == nil || !authorizationProvider.SignedIn() {
+ return fallback(ctx)
+ }
+ if ctx == nil || ctx.Request == nil || ctx.Request.URL == nil {
+ return fmt.Errorf("Cursor 控制面请求上下文无效")
+ }
+ targetURL := *ctx.Request.URL
+ targetURL.Scheme = "https"
+ targetURL.Host = "api2.cursor.sh:443"
+ ctx.UpstreamURL = &targetURL
+ return direct(ctx)
+ }
+}
+
type serverSystemSettings struct {
configs *serverconfig.Manager
}
diff --git a/internal/backend/server/upstream/action.go b/internal/backend/server/upstream/action.go
index c0ecff3..6b3baf9 100644
--- a/internal/backend/server/upstream/action.go
+++ b/internal/backend/server/upstream/action.go
@@ -2,6 +2,7 @@ package upstream
import (
"bytes"
+ "fmt"
"io"
"net/http"
"strings"
@@ -29,6 +30,34 @@ func DirectAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc {
}
}
+// AuthenticatedDirectAction forwards a Cursor control-plane request with the
+// independent desktop account after the local-mode identity rewrite has run.
+func AuthenticatedDirectAction(deps Dependencies, cfg CompatRouteConfig, authorizationProvider AuthorizationProvider) server.HandlerFunc {
+ return func(ctx *server.Context) error {
+ reqCtx, _, err := newCompatRouteObjects(ctx, deps, cfg)
+ if err != nil {
+ return err
+ }
+ if reqCtx == nil || reqCtx.Request == nil {
+ return fmt.Errorf("Cursor 控制面请求上下文无效")
+ }
+ if authorizationProvider == nil {
+ return fmt.Errorf("Cursor 账号服务未初始化")
+ }
+ authorization, err := authorizationProvider.Authorization(reqCtx.Request.Context())
+ if err != nil {
+ return err
+ }
+ _, err = ForwardToUpstream(reqCtx, ForwardOptions{
+ PatchHeaders: func(headers http.Header) {
+ headers.Set("Authorization", authorization)
+ headers.Set("x-cursor-checksum", BuildCursorChecksum(authorization))
+ },
+ })
+ return err
+ }
+}
+
func FixedStatusAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc {
return func(ctx *server.Context) error {
reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg)
diff --git a/internal/backend/server/upstream/types.go b/internal/backend/server/upstream/types.go
index 09cb85e..9fca82e 100644
--- a/internal/backend/server/upstream/types.go
+++ b/internal/backend/server/upstream/types.go
@@ -20,6 +20,13 @@ type SystemSettingService interface {
ResolveModelAdapters(context.Context) ([]legacyruntime.ModelAdapterConfig, error)
}
+// AuthorizationProvider supplies the independent Cursor account used only by
+// official control-plane requests such as Plugins, Skills, and MCP registry.
+type AuthorizationProvider interface {
+ Authorization(context.Context) (string, error)
+ SignedIn() bool
+}
+
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
diff --git a/internal/bridge/proxy.go b/internal/bridge/proxy.go
index 2b85d82..c4070ec 100644
--- a/internal/bridge/proxy.go
+++ b/internal/bridge/proxy.go
@@ -24,6 +24,9 @@ type ModelAdapterTestResult = client.ModelAdapterTestResult
// ModelAdapterTestResultsPayload 定义测速结果事件载荷。
type ModelAdapterTestResultsPayload = client.ModelAdapterTestResultsPayload
+// CursorAccountStatus 是可安全展示给桌面前端的独立 Cursor 账号状态。
+type CursorAccountStatus = client.CursorAccountStatus
+
// LicenseActionRequest 定义了当前模块中的 LicenseActionRequest 类型。
type LicenseActionRequest = client.LicenseActionRequest
@@ -91,6 +94,21 @@ func (s *ProxyService) SaveUserConfig(cfg UserConfig) error {
return s.core.SaveUserConfig(cfg)
}
+// GetCursorAccountStatus 返回 cursor-byok 独立 Cursor 账号的脱敏状态。
+func (s *ProxyService) GetCursorAccountStatus() CursorAccountStatus {
+ return s.core.GetCursorAccountStatus()
+}
+
+// StartCursorAccountLogin 打开官方浏览器登录并异步等待结果。
+func (s *ProxyService) StartCursorAccountLogin() (CursorAccountStatus, error) {
+ return s.core.StartCursorAccountLogin()
+}
+
+// DisconnectCursorAccount 只断开 cursor-byok 自己的账号。
+func (s *ProxyService) DisconnectCursorAccount() (CursorAccountStatus, error) {
+ return s.core.DisconnectCursorAccount()
+}
+
// TestModelAdapter 用于处理与 TestModelAdapter 相关的逻辑。
func (s *ProxyService) TestModelAdapter(adapter ModelAdapterConfig) (ModelAdapterTestResult, error) {
return s.core.TestModelAdapter(adapter)
diff --git a/internal/client/cursor_account.go b/internal/client/cursor_account.go
new file mode 100644
index 0000000..c384445
--- /dev/null
+++ b/internal/client/cursor_account.go
@@ -0,0 +1,35 @@
+package client
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "cursor/internal/cursoraccount"
+)
+
+type CursorAccountStatus = cursoraccount.Status
+
+func (s *ProxyService) GetCursorAccountStatus() CursorAccountStatus {
+ if s == nil || s.cursorAccount == nil {
+ return CursorAccountStatus{State: cursoraccount.StateSignedOut}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ s.cursorAccount.EnsureEmail(ctx)
+ return s.cursorAccount.Status()
+}
+
+func (s *ProxyService) StartCursorAccountLogin() (CursorAccountStatus, error) {
+ if s == nil || s.cursorAccount == nil {
+ return CursorAccountStatus{State: cursoraccount.StateError}, fmt.Errorf("Cursor 账号服务未初始化")
+ }
+ return s.cursorAccount.StartLogin()
+}
+
+func (s *ProxyService) DisconnectCursorAccount() (CursorAccountStatus, error) {
+ if s == nil || s.cursorAccount == nil {
+ return CursorAccountStatus{State: cursoraccount.StateSignedOut}, nil
+ }
+ return s.cursorAccount.Disconnect()
+}
diff --git a/internal/client/lifecycle.go b/internal/client/lifecycle.go
index 86e42f1..2becde4 100644
--- a/internal/client/lifecycle.go
+++ b/internal/client/lifecycle.go
@@ -265,6 +265,9 @@ func (s *ProxyService) ShutdownForQuit() {
finalErr = errors.Join(finalErr, err)
}
}
+ if s.cursorAccount != nil {
+ s.cursorAccount.Shutdown()
+ }
if finalErr != nil {
s.setLastError(finalErr)
}
diff --git a/internal/client/service.go b/internal/client/service.go
index 9e0fcf6..125bca9 100644
--- a/internal/client/service.go
+++ b/internal/client/service.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
+ "path/filepath"
"sync"
"time"
@@ -11,6 +12,7 @@ import (
backend "cursor/internal/backend"
serverconfig "cursor/internal/backend/server/config"
"cursor/internal/certs"
+ "cursor/internal/cursoraccount"
"cursor/internal/logger"
"cursor/internal/mitm"
"cursor/internal/netproxy"
@@ -35,6 +37,8 @@ type ProxyService struct {
certManager *certs.Manager
// backendHost 表示当前嵌入式 backend 服务。
backendHost *backend.Host
+ // cursorAccount 持有仅供插件、Skills 和 MCP 控制面使用的真实 Cursor 身份。
+ cursorAccount *cursoraccount.Manager
// mu 表示当前声明中的 mu。
mu sync.RWMutex
@@ -84,8 +88,12 @@ func NewProxyService(proxy *mitm.ProxyServer, certManager *certs.Manager, caCert
publicClient: netproxy.NewHTTPClient(publicAPITimeout),
modelTestResults: make(map[string]ModelAdapterTestResult),
}
+ service.cursorAccount = cursoraccount.NewManager(
+ filepath.Join(appdata.DataRootPath(), "cursor-account.json"),
+ netproxy.NewHTTPClient(publicAPITimeout),
+ )
service.store = serverconfig.NewStore(service.configPath, service.logsRoot)
- host, err := backend.NewHost(service.store)
+ host, err := backend.NewHost(service.store, service.cursorAccount)
if err != nil {
logger.Errorf("init backend host failed: %v", err)
} else {
@@ -101,7 +109,7 @@ func (s *ProxyService) ensureBackendHost() error {
if s.backendHost != nil {
return nil
}
- host, err := backend.NewHost(s.store)
+ host, err := backend.NewHost(s.store, s.cursorAccount)
if err != nil {
return err
}
diff --git a/internal/cursoraccount/manager.go b/internal/cursoraccount/manager.go
new file mode 100644
index 0000000..2191ac2
--- /dev/null
+++ b/internal/cursoraccount/manager.go
@@ -0,0 +1,589 @@
+package cursoraccount
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "cursor/gen/aiserverv1"
+ "cursor/internal/backend/server/upstream"
+
+ "github.com/google/uuid"
+ "github.com/pkg/browser"
+ "google.golang.org/protobuf/proto"
+)
+
+const (
+ StateSignedOut = "signed_out"
+ StateWaiting = "waiting"
+ StateSignedIn = "signed_in"
+ StateError = "error"
+
+ websiteURL = "https://cursor.com"
+ backendURL = "https://api2.cursor.sh"
+ authClientID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"
+ loginTimeout = 10 * time.Minute
+ pollInterval = time.Second
+ refreshMargin = 2 * time.Minute
+)
+
+var ErrNotSignedIn = errors.New("尚未在 cursor-byok 中登录 Cursor 账号")
+
+// Status 是可安全返回给前端的脱敏账号状态。
+type Status struct {
+ State string `json:"state"`
+ AuthID string `json:"authId"`
+ Email string `json:"email"`
+ Error string `json:"error"`
+}
+
+type credentials struct {
+ AccessToken string `json:"accessToken"`
+ RefreshToken string `json:"refreshToken"`
+ AuthID string `json:"authId"`
+ Email string `json:"email,omitempty"`
+}
+
+type pollResponse struct {
+ AccessToken string `json:"accessToken"`
+ RefreshToken string `json:"refreshToken"`
+ AuthID string `json:"authId"`
+}
+
+type refreshResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ShouldLogout bool `json:"shouldLogout"`
+}
+
+// Manager 持有 cursor-byok 自己的 Cursor 登录态,不读写 Cursor 客户端状态库。
+type Manager struct {
+ path string
+ client *http.Client
+
+ mu sync.RWMutex
+ credentials credentials
+ state string
+ lastError string
+ loginCancel context.CancelFunc
+ loginGeneration uint64
+
+ refreshMu sync.Mutex
+}
+
+func NewManager(path string, client *http.Client) *Manager {
+ if client == nil {
+ client = &http.Client{Timeout: 15 * time.Second}
+ }
+ manager := &Manager{
+ path: strings.TrimSpace(path),
+ client: client,
+ state: StateSignedOut,
+ }
+ if err := manager.load(); err != nil {
+ manager.state = StateError
+ manager.lastError = fmt.Sprintf("读取 Cursor 账号凭据失败: %v", err)
+ }
+ return manager
+}
+
+func (manager *Manager) Status() Status {
+ if manager == nil {
+ return Status{State: StateSignedOut}
+ }
+ manager.mu.RLock()
+ defer manager.mu.RUnlock()
+ return Status{
+ State: manager.state,
+ AuthID: manager.credentials.AuthID,
+ Email: manager.credentials.Email,
+ Error: manager.lastError,
+ }
+}
+
+// EnsureEmail backfills a human-readable identity for credentials saved by
+// builds that only persisted authId. Profile lookup failure does not invalidate
+// an otherwise usable control-plane login.
+func (manager *Manager) EnsureEmail(ctx context.Context) {
+ if manager == nil || !manager.SignedIn() {
+ return
+ }
+ current, generation := manager.snapshotCredentials()
+ if strings.TrimSpace(current.Email) != "" {
+ return
+ }
+ authorization, err := manager.Authorization(ctx)
+ if err != nil {
+ return
+ }
+ profile, err := manager.fetchProfile(ctx, authorization)
+ if err != nil || strings.TrimSpace(profile.GetEmail()) == "" {
+ return
+ }
+ current, currentGeneration := manager.snapshotCredentials()
+ if currentGeneration != generation {
+ return
+ }
+ current.Email = strings.TrimSpace(profile.GetEmail())
+ _ = manager.commitCredentials(generation, current)
+}
+
+func (manager *Manager) SignedIn() bool {
+ if manager == nil {
+ return false
+ }
+ manager.mu.RLock()
+ defer manager.mu.RUnlock()
+ return manager.state == StateSignedIn && strings.TrimSpace(manager.credentials.AccessToken) != ""
+}
+
+// StartLogin 启动官方浏览器 PKCE 登录,并在后台等待登录结果。
+func (manager *Manager) StartLogin() (Status, error) {
+ if manager == nil {
+ return Status{State: StateError}, fmt.Errorf("Cursor 账号服务未初始化")
+ }
+ verifierBytes := make([]byte, 32)
+ if _, err := rand.Read(verifierBytes); err != nil {
+ return manager.Status(), fmt.Errorf("生成 Cursor 登录校验码失败: %w", err)
+ }
+ verifier := base64.RawURLEncoding.EncodeToString(verifierBytes)
+ challengeBytes := sha256.Sum256([]byte(verifier))
+ challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:])
+ loginID := uuid.NewString()
+
+ loginURL, err := buildLoginURL(loginID, challenge)
+ if err != nil {
+ return manager.Status(), err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), loginTimeout)
+
+ manager.mu.Lock()
+ if manager.loginCancel != nil {
+ manager.loginCancel()
+ }
+ manager.loginGeneration++
+ generation := manager.loginGeneration
+ manager.loginCancel = cancel
+ manager.state = StateWaiting
+ manager.lastError = ""
+ manager.mu.Unlock()
+
+ if err := browser.OpenURL(loginURL); err != nil {
+ cancel()
+ manager.finishWithError(generation, fmt.Sprintf("打开 Cursor 登录页面失败: %v", err))
+ return manager.Status(), err
+ }
+
+ go manager.pollLogin(ctx, generation, loginID, verifier)
+ return manager.Status(), nil
+}
+
+// Disconnect 只清除 cursor-byok 自己保存的账号,不调用 Cursor 客户端 logout。
+func (manager *Manager) Disconnect() (Status, error) {
+ if manager == nil {
+ return Status{State: StateSignedOut}, nil
+ }
+ manager.mu.Lock()
+ manager.loginGeneration++
+ if manager.loginCancel != nil {
+ manager.loginCancel()
+ manager.loginCancel = nil
+ }
+ manager.credentials = credentials{}
+ manager.state = StateSignedOut
+ manager.lastError = ""
+ manager.mu.Unlock()
+
+ err := os.Remove(manager.path)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ manager.mu.Lock()
+ manager.state = StateError
+ manager.lastError = fmt.Sprintf("清除 Cursor 账号凭据失败: %v", err)
+ manager.mu.Unlock()
+ return manager.Status(), err
+ }
+ return manager.Status(), nil
+}
+
+func (manager *Manager) Shutdown() {
+ if manager == nil {
+ return
+ }
+ manager.mu.Lock()
+ manager.loginGeneration++
+ if manager.loginCancel != nil {
+ manager.loginCancel()
+ manager.loginCancel = nil
+ }
+ manager.mu.Unlock()
+}
+
+// Authorization 返回官方控制面请求使用的真实 Cursor Bearer 身份。
+func (manager *Manager) Authorization(ctx context.Context) (string, error) {
+ if manager == nil {
+ return "", ErrNotSignedIn
+ }
+ manager.refreshMu.Lock()
+ defer manager.refreshMu.Unlock()
+
+ creds, generation := manager.snapshotCredentials()
+ if strings.TrimSpace(creds.AccessToken) == "" {
+ return "", ErrNotSignedIn
+ }
+ if !tokenNeedsRefresh(creds.AccessToken, time.Now()) {
+ return bearer(creds.AccessToken), nil
+ }
+ if strings.TrimSpace(creds.RefreshToken) == "" {
+ manager.setAuthorizationError(generation, "Cursor 登录已过期,请重新登录")
+ return "", fmt.Errorf("Cursor 登录已过期且没有刷新令牌")
+ }
+
+ updated, shouldLogout, err := manager.refresh(ctx, creds)
+ if err != nil {
+ manager.setAuthorizationError(generation, fmt.Sprintf("刷新 Cursor 登录失败: %v", err))
+ return "", err
+ }
+ if shouldLogout {
+ manager.invalidateAuthorization(generation, "Cursor 登录已失效,请重新登录")
+ return "", ErrNotSignedIn
+ }
+ if err := manager.commitCredentials(generation, updated); err != nil {
+ return "", err
+ }
+ return bearer(updated.AccessToken), nil
+}
+
+func (manager *Manager) pollLogin(ctx context.Context, generation uint64, loginID string, verifier string) {
+ defer func() {
+ manager.mu.Lock()
+ if manager.loginGeneration == generation {
+ manager.loginCancel = nil
+ }
+ manager.mu.Unlock()
+ }()
+
+ for {
+ result, pending, err := manager.pollOnce(ctx, loginID, verifier)
+ if err == nil && !pending {
+ creds := credentials{
+ AccessToken: strings.TrimSpace(result.AccessToken),
+ RefreshToken: strings.TrimSpace(result.RefreshToken),
+ AuthID: strings.TrimSpace(result.AuthID),
+ }
+ if creds.AccessToken == "" {
+ manager.finishWithError(generation, "Cursor 登录响应缺少 access token")
+ return
+ }
+ if profile, profileErr := manager.fetchProfile(ctx, bearer(creds.AccessToken)); profileErr == nil {
+ creds.Email = strings.TrimSpace(profile.GetEmail())
+ }
+ _ = manager.commitCredentials(generation, creds)
+ return
+ }
+ if err != nil && !isRetryablePollError(err) {
+ manager.finishWithError(generation, fmt.Sprintf("Cursor 登录失败: %v", err))
+ return
+ }
+
+ select {
+ case <-ctx.Done():
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ manager.finishWithError(generation, "Cursor 登录等待超时,请重试")
+ }
+ return
+ case <-time.After(pollInterval):
+ }
+ }
+}
+
+func (manager *Manager) fetchProfile(ctx context.Context, authorization string) (*aiserverv1.GetMeResponse, error) {
+ body, err := proto.Marshal(&aiserverv1.GetMeRequest{})
+ if err != nil {
+ return nil, err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, backendURL+"/aiserver.v1.DashboardService/GetMe", bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("authorization", authorization)
+ req.Header.Set("x-cursor-checksum", upstream.BuildCursorChecksum(authorization))
+ req.Header.Set("content-type", "application/proto")
+ req.Header.Set("accept", "application/proto")
+ req.Header.Set("connect-protocol-version", "1")
+ resp, err := manager.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("GetMe 返回 HTTP %d", resp.StatusCode)
+ }
+ profile := &aiserverv1.GetMeResponse{}
+ if err := proto.Unmarshal(responseBody, profile); err != nil {
+ return nil, err
+ }
+ return profile, nil
+}
+
+func (manager *Manager) pollOnce(ctx context.Context, loginID string, verifier string) (pollResponse, bool, error) {
+ endpoint, err := url.Parse(backendURL + "/auth/poll")
+ if err != nil {
+ return pollResponse{}, false, err
+ }
+ query := endpoint.Query()
+ query.Set("uuid", loginID)
+ query.Set("verifier", verifier)
+ endpoint.RawQuery = query.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+ if err != nil {
+ return pollResponse{}, false, err
+ }
+ resp, err := manager.client.Do(req)
+ if err != nil {
+ return pollResponse{}, false, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusNotFound {
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64*1024))
+ return pollResponse{}, true, nil
+ }
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
+ if err != nil {
+ return pollResponse{}, false, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return pollResponse{}, false, fmt.Errorf("登录服务返回 HTTP %d", resp.StatusCode)
+ }
+ result := pollResponse{}
+ if err := json.Unmarshal(body, &result); err != nil {
+ return pollResponse{}, false, fmt.Errorf("解析登录响应失败: %w", err)
+ }
+ return result, false, nil
+}
+
+func (manager *Manager) refresh(ctx context.Context, current credentials) (credentials, bool, error) {
+ payload, err := json.Marshal(map[string]string{
+ "grant_type": "refresh_token",
+ "client_id": authClientID,
+ "refresh_token": current.RefreshToken,
+ })
+ if err != nil {
+ return credentials{}, false, err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, backendURL+"/oauth/token", bytes.NewReader(payload))
+ if err != nil {
+ return credentials{}, false, err
+ }
+ req.Header.Set("content-type", "application/json")
+ resp, err := manager.client.Do(req)
+ if err != nil {
+ return credentials{}, false, err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
+ if err != nil {
+ return credentials{}, false, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return credentials{}, false, fmt.Errorf("刷新服务返回 HTTP %d", resp.StatusCode)
+ }
+ result := refreshResponse{}
+ if err := json.Unmarshal(body, &result); err != nil {
+ return credentials{}, false, fmt.Errorf("解析刷新响应失败: %w", err)
+ }
+ if result.ShouldLogout {
+ return credentials{}, true, nil
+ }
+ if strings.TrimSpace(result.AccessToken) == "" {
+ return credentials{}, false, fmt.Errorf("刷新响应缺少 access token")
+ }
+ current.AccessToken = strings.TrimSpace(result.AccessToken)
+ if strings.TrimSpace(result.RefreshToken) != "" {
+ current.RefreshToken = strings.TrimSpace(result.RefreshToken)
+ }
+ return current, false, nil
+}
+
+func (manager *Manager) load() error {
+ if manager.path == "" {
+ return fmt.Errorf("Cursor 账号凭据路径为空")
+ }
+ data, err := os.ReadFile(manager.path)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ loaded := credentials{}
+ if err := json.Unmarshal(data, &loaded); err != nil {
+ return err
+ }
+ loaded.AccessToken = strings.TrimSpace(loaded.AccessToken)
+ loaded.RefreshToken = strings.TrimSpace(loaded.RefreshToken)
+ loaded.AuthID = strings.TrimSpace(loaded.AuthID)
+ loaded.Email = strings.TrimSpace(loaded.Email)
+ if loaded.AccessToken == "" {
+ return nil
+ }
+ manager.credentials = loaded
+ manager.state = StateSignedIn
+ return nil
+}
+
+func (manager *Manager) save(value credentials) error {
+ if manager.path == "" {
+ return fmt.Errorf("Cursor 账号凭据路径为空")
+ }
+ if err := os.MkdirAll(filepath.Dir(manager.path), 0o700); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ return err
+ }
+ tempPath := manager.path + ".tmp"
+ if err := os.WriteFile(tempPath, append(data, '\n'), 0o600); err != nil {
+ return err
+ }
+ if err := os.Chmod(tempPath, 0o600); err != nil {
+ _ = os.Remove(tempPath)
+ return err
+ }
+ if err := os.Rename(tempPath, manager.path); err != nil {
+ _ = os.Remove(tempPath)
+ return err
+ }
+ return os.Chmod(manager.path, 0o600)
+}
+
+func (manager *Manager) snapshotCredentials() (credentials, uint64) {
+ manager.mu.RLock()
+ defer manager.mu.RUnlock()
+ return manager.credentials, manager.loginGeneration
+}
+
+func (manager *Manager) finishWithError(generation uint64, message string) {
+ manager.mu.Lock()
+ defer manager.mu.Unlock()
+ if manager.loginGeneration != generation {
+ return
+ }
+ manager.state = StateError
+ manager.lastError = strings.TrimSpace(message)
+}
+
+func (manager *Manager) commitCredentials(generation uint64, value credentials) error {
+ manager.mu.Lock()
+ defer manager.mu.Unlock()
+ if manager.loginGeneration != generation {
+ return ErrNotSignedIn
+ }
+ if err := manager.save(value); err != nil {
+ manager.state = StateError
+ manager.lastError = fmt.Sprintf("保存 Cursor 登录凭据失败: %v", err)
+ return err
+ }
+ manager.credentials = value
+ manager.state = StateSignedIn
+ manager.lastError = ""
+ return nil
+}
+
+func (manager *Manager) setAuthorizationError(generation uint64, message string) {
+ manager.mu.Lock()
+ defer manager.mu.Unlock()
+ if manager.loginGeneration != generation {
+ return
+ }
+ manager.state = StateError
+ manager.lastError = strings.TrimSpace(message)
+}
+
+func (manager *Manager) invalidateAuthorization(generation uint64, message string) {
+ manager.mu.Lock()
+ defer manager.mu.Unlock()
+ if manager.loginGeneration != generation {
+ return
+ }
+ manager.loginGeneration++
+ manager.credentials = credentials{}
+ manager.state = StateError
+ manager.lastError = strings.TrimSpace(message)
+ _ = os.Remove(manager.path)
+}
+
+func buildLoginURL(loginID string, challenge string) (string, error) {
+ parsed, err := url.Parse(websiteURL + "/loginDeepControl")
+ if err != nil {
+ return "", err
+ }
+ query := parsed.Query()
+ query.Set("challenge", challenge)
+ query.Set("uuid", loginID)
+ query.Set("mode", "login")
+ query.Set("supportsSelectedTeamLogin", "true")
+ parsed.RawQuery = query.Encode()
+ return parsed.String(), nil
+}
+
+func bearer(token string) string {
+ value := strings.TrimSpace(token)
+ if strings.HasPrefix(strings.ToLower(value), "bearer ") {
+ return value
+ }
+ return "Bearer " + value
+}
+
+func tokenNeedsRefresh(token string, now time.Time) bool {
+ parts := strings.Split(strings.TrimSpace(token), ".")
+ if len(parts) < 2 {
+ return false
+ }
+ payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ return false
+ }
+ claims := struct {
+ ExpiresAt json.Number `json:"exp"`
+ }{}
+ decoder := json.NewDecoder(bytes.NewReader(payload))
+ decoder.UseNumber()
+ if err := decoder.Decode(&claims); err != nil || claims.ExpiresAt == "" {
+ return false
+ }
+ expiresAt, err := claims.ExpiresAt.Int64()
+ if err != nil {
+ return false
+ }
+ return !now.Add(refreshMargin).Before(time.Unix(expiresAt, 0))
+}
+
+func isRetryablePollError(err error) bool {
+ if err == nil {
+ return false
+ }
+ var urlErr *url.Error
+ if errors.As(err, &urlErr) {
+ return true
+ }
+ message := strings.ToLower(err.Error())
+ return strings.Contains(message, "http 429") || strings.Contains(message, "http 5")
+}
From d1ffd1a95c4ca942bd799bdde15aa8d17a5d5173 Mon Sep 17 00:00:00 2001
From: leookun
Date: Sun, 2 Aug 2026 21:39:47 +0800
Subject: [PATCH 2/3] feat: delete test requirements
---
.agents/skills/test-requirements/SKILL.md | 4 ----
.agents/skills/test-requirements/agents/openai.yaml | 4 ----
2 files changed, 8 deletions(-)
delete mode 100644 .agents/skills/test-requirements/SKILL.md
delete mode 100644 .agents/skills/test-requirements/agents/openai.yaml
diff --git a/.agents/skills/test-requirements/SKILL.md b/.agents/skills/test-requirements/SKILL.md
deleted file mode 100644
index 2baea9a..0000000
--- a/.agents/skills/test-requirements/SKILL.md
+++ /dev/null
@@ -1,4 +0,0 @@
----
-name: test-requirements
-description: 本仓库代码禁止写任何测试
----
diff --git a/.agents/skills/test-requirements/agents/openai.yaml b/.agents/skills/test-requirements/agents/openai.yaml
deleted file mode 100644
index 3ca2219..0000000
--- a/.agents/skills/test-requirements/agents/openai.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-interface:
- display_name: "测试要求"
- short_description: "根据测试要求,生成测试用例"
- default_prompt: "使用 $test-requirements 来根据测试要求,生成测试用例。"
From a5437e60fe3bc83d1d3a267631e9a7f9fe98abe1 Mon Sep 17 00:00:00 2001
From: leookun
Date: Sun, 2 Aug 2026 22:53:57 +0800
Subject: [PATCH 3/3] feat(cursor): add contributor profile link and improve UI
for Cursor account card
- Integrated a tooltip with a button to open the contributor's GitHub profile in the CursorAccountCard component.
- Updated the layout of the account card for better visual organization.
- Removed routing mode options from the configuration view and adjusted related translations in multiple languages.
- Cleaned up unused routing mode logic in the app state management.
---
frontend/src/components/CursorAccountCard.vue | 106 +++--
frontend/src/i18n/generated/catalog.json | 444 ++++++------------
frontend/src/i18n/locales/en-US.json | 17 +-
frontend/src/i18n/locales/ja-JP.json | 17 +-
frontend/src/i18n/locales/ru-RU.json | 17 +-
frontend/src/i18n/locales/zh-CN.json | 17 +-
frontend/src/state/appState.js | 48 --
frontend/src/views/Config.vue | 24 +-
frontend/src/views/Home.vue | 25 -
internal/backend/README.md | 5 +-
internal/backend/forwarder/actor.go | 2 +-
internal/backend/host.go | 218 ++-------
internal/backend/server/config/manager.go | 14 -
internal/backend/server/config/store.go | 3 +
internal/backend/server/config/types.go | 24 -
internal/backend/server/context.go | 2 -
internal/backend/server/middleware.go | 12 -
internal/backend/server/policy.go | 19 -
internal/backend/server/route.go | 21 -
internal/backend/server/upstream/action.go | 7 +-
internal/backend/server/upstream/client.go | 3 +-
internal/backend/server/upstream/types.go | 1 -
22 files changed, 293 insertions(+), 753 deletions(-)
delete mode 100644 internal/backend/server/policy.go
diff --git a/frontend/src/components/CursorAccountCard.vue b/frontend/src/components/CursorAccountCard.vue
index 71a172c..c9ab9e0 100644
--- a/frontend/src/components/CursorAccountCard.vue
+++ b/frontend/src/components/CursorAccountCard.vue
@@ -1,6 +1,7 @@