diff --git a/internal/app/runner.go b/internal/app/runner.go
index 159790f..97718de 100644
--- a/internal/app/runner.go
+++ b/internal/app/runner.go
@@ -78,7 +78,7 @@ func Run(resources EmbeddedResources) error {
return err
}
- defaultBackendBaseURL := "http://" + serverconfig.DefaultBackendListenAddr
+ defaultBackendBaseURL := browserReachableLoopbackBaseURL(serverconfig.DefaultBackendListenAddr)
proxyServer, err := mitm.NewProxyServer(serverconfig.DefaultProxyListenAddr, defaultBackendBaseURL, "", "", certManager)
if err != nil {
return err
@@ -433,13 +433,16 @@ func windowsAdditionalBrowserArgs() []string {
func browserReachableLoopbackBaseURL(listenAddr string) string {
host, port, err := net.SplitHostPort(strings.TrimSpace(listenAddr))
if err != nil || strings.TrimSpace(port) == "" {
- return "http://" + serverconfig.DefaultBackendListenAddr
+ return "https://localhost:8000"
}
host = strings.TrimSpace(host)
if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" {
host = "127.0.0.1"
}
- return "http://" + net.JoinHostPort(host, port)
+ if host == "127.0.0.1" || host == "::1" || host == "localhost" {
+ host = "localhost"
+ }
+ return "https://" + net.JoinHostPort(host, port)
}
// logEmbeddedCAInfo 用于处理与 logEmbeddedCAInfo 相关的逻辑。
diff --git a/internal/backend/forwarder/ai_handler.go b/internal/backend/forwarder/ai_handler.go
index b7ba836..817dfa6 100644
--- a/internal/backend/forwarder/ai_handler.go
+++ b/internal/backend/forwarder/ai_handler.go
@@ -19,87 +19,119 @@ type usageLookupRecord struct {
CreatedAt time.Time
}
+type aiHandler struct {
+ mux *http.ServeMux
+ paths map[string]struct{}
+}
+
+func newAIHandlerMux() *aiHandler {
+ return &aiHandler{
+ mux: http.NewServeMux(),
+ paths: make(map[string]struct{}),
+ }
+}
+
+func (handler *aiHandler) Handle(pattern string, target http.Handler) {
+ handler.paths[pattern] = struct{}{}
+ handler.mux.Handle(pattern, target)
+}
+
+func (handler *aiHandler) HandlesPath(path string) bool {
+ if handler == nil {
+ return false
+ }
+ _, ok := handler.paths[path]
+ return ok
+}
+
+func (handler *aiHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
+ if handler == nil || handler.mux == nil {
+ http.NotFound(writer, request)
+ return
+ }
+ handler.mux.ServeHTTP(writer, request)
+}
+
const (
dashboardServiceGetTokenUsageProcedure = "/aiserver.v1.DashboardService/GetTokenUsage"
dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure = "/aiserver.v1.DashboardService/GetGlassEarlyPreviewEnrollment"
)
-func newAIHandler(service *Service) http.Handler {
- mux := http.NewServeMux()
- mux.Handle(
+func newAIHandler(service *Service) *aiHandler {
+ handler := newAIHandlerMux()
+ handler.Handle(
dashboardServiceGetTokenUsageProcedure,
connect.NewUnaryHandler(dashboardServiceGetTokenUsageProcedure, service.GetTokenUsage),
)
- mux.Handle(
+ handler.Handle(
dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure,
connect.NewUnaryHandler(dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure, service.GetGlassEarlyPreviewEnrollment),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceCountTokensProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceCountTokensProcedure, service.CountTokens),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceGetThoughtAnnotationProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceGetThoughtAnnotationProcedure, service.GetThoughtAnnotation),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceWriteGitCommitMessageProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceWriteGitCommitMessageProcedure, service.WriteGitCommitMessage),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceCreateExperimentalIndexProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceCreateExperimentalIndexProcedure, service.CreateExperimentalIndex),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceListExperimentalIndexFilesProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceListExperimentalIndexFilesProcedure, service.ListExperimentalIndexFiles),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceListenExperimentalIndexProcedure,
connect.NewServerStreamHandler(aiserverv1connect.AiServiceListenExperimentalIndexProcedure, service.ListenExperimentalIndex),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceRegisterFileToIndexProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceRegisterFileToIndexProcedure, service.RegisterFileToIndex),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceSetupIndexDependenciesProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceSetupIndexDependenciesProcedure, service.SetupIndexDependencies),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceComputeIndexTopoSortProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceComputeIndexTopoSortProcedure, service.ComputeIndexTopoSort),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceDocumentationQueryProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceDocumentationQueryProcedure, service.DocumentationQuery),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceAvailableDocsProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceAvailableDocsProcedure, service.AvailableDocs),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceKnowledgeBaseAddProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseAddProcedure, service.KnowledgeBaseAdd),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceKnowledgeBaseListProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseListProcedure, service.KnowledgeBaseList),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceKnowledgeBaseRemoveProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseRemoveProcedure, service.KnowledgeBaseRemove),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceKnowledgeBaseUpdateProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseUpdateProcedure, service.KnowledgeBaseUpdate),
)
- mux.Handle(
+ handler.Handle(
aiserverv1connect.AiServiceFetchRelevantKnowledgeForConversationProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceFetchRelevantKnowledgeForConversationProcedure, service.FetchRelevantKnowledgeForConversation),
)
- mux.Handle("/", http.NotFoundHandler())
- return mux
+ return handler
}
func (service *Service) GetThoughtAnnotation(_ context.Context, req *connect.Request[aiserverv1.GetThoughtAnnotationRequest]) (*connect.Response[aiserverv1.GetThoughtAnnotationResponse], error) {
diff --git a/internal/backend/forwarder/ai_handler_routes_test.go b/internal/backend/forwarder/ai_handler_routes_test.go
new file mode 100644
index 0000000..5059675
--- /dev/null
+++ b/internal/backend/forwarder/ai_handler_routes_test.go
@@ -0,0 +1,20 @@
+package forwarder
+
+import (
+ "testing"
+
+ "cursor/gen/aiserverv1/aiserverv1connect"
+)
+
+func TestAIHandlerTracksLocallyImplementedPaths(t *testing.T) {
+ handler := newAIHandler(&Service{})
+ if !handler.HandlesPath(aiserverv1connect.AiServiceCountTokensProcedure) {
+ t.Fatalf("expected %q to be handled locally", aiserverv1connect.AiServiceCountTokensProcedure)
+ }
+ if !handler.HandlesPath(dashboardServiceGetTokenUsageProcedure) {
+ t.Fatalf("expected %q to be handled locally", dashboardServiceGetTokenUsageProcedure)
+ }
+ if handler.HandlesPath("/aiserver.v1.AiService/UnknownProcedure") {
+ t.Fatal("unknown AI procedure must fall through to upstream")
+ }
+}
diff --git a/internal/backend/forwarder/module.go b/internal/backend/forwarder/module.go
index edc6078..d30ef13 100644
--- a/internal/backend/forwarder/module.go
+++ b/internal/backend/forwarder/module.go
@@ -32,3 +32,11 @@ func NewModule(historyRoot string, channelService modeladapter.ChannelResolver)
UploadServiceHandler: newUploadServiceHandler(service),
}
}
+
+func (module *Module) HandlesAIPath(path string) bool {
+ if module == nil || module.AiHandler == nil {
+ return false
+ }
+ handler, ok := module.AiHandler.(interface{ HandlesPath(string) bool })
+ return ok && handler.HandlesPath(path)
+}
diff --git a/internal/backend/host.go b/internal/backend/host.go
index 3c84d53..aaf901a 100644
--- a/internal/backend/host.go
+++ b/internal/backend/host.go
@@ -2,6 +2,8 @@ package backend
import (
"context"
+ "crypto/tls"
+ "crypto/x509"
"fmt"
"net"
"net/http"
@@ -27,11 +29,11 @@ const healthPath = "/healthz"
const tabServerBaseURL = "https://tab.leokun.cn"
type Host struct {
- store *serverconfig.Store
- listenAddr string
- configs *serverconfig.Manager
- healthHTTP *http.Client
- controlPlaneAuth upstream.AuthorizationProvider
+ store *serverconfig.Store
+ listenAddr string
+ configs *serverconfig.Manager
+ healthHTTP *http.Client
+ tlsCertificate *tls.Certificate
runMu sync.RWMutex
httpServer *http.Server
@@ -41,7 +43,20 @@ type Host struct {
mux http.Handler
}
-func NewHost(store *serverconfig.Store, controlPlaneAuth upstream.AuthorizationProvider) (*Host, error) {
+type HostOption func(*Host) error
+
+func WithTLSCertificate(certificate *tls.Certificate) HostOption {
+ return func(host *Host) error {
+ if certificate == nil || len(certificate.Certificate) == 0 || certificate.PrivateKey == nil {
+ return fmt.Errorf("backend TLS certificate is invalid")
+ }
+ copied := *certificate
+ host.tlsCertificate = &copied
+ return nil
+ }
+}
+
+func NewHost(store *serverconfig.Store, options ...HostOption) (*Host, error) {
if store == nil {
return nil, fmt.Errorf("backend config store is required")
}
@@ -51,12 +66,19 @@ func NewHost(store *serverconfig.Store, controlPlaneAuth upstream.AuthorizationP
}
cfg := configs.Current()
host := &Host{
- store: store,
- listenAddr: cfg.BackendListenAddr,
- configs: configs,
- healthHTTP: newLoopbackHTTPClient(),
- controlPlaneAuth: controlPlaneAuth,
+ store: store,
+ listenAddr: cfg.BackendListenAddr,
+ configs: configs,
}
+ for _, option := range options {
+ if option == nil {
+ continue
+ }
+ if err := option(host); err != nil {
+ return nil, err
+ }
+ }
+ host.healthHTTP = newLoopbackHTTPClient(host.tlsCertificate)
if err := host.rebuild(cfg); err != nil {
return nil, err
}
@@ -107,7 +129,14 @@ func (host *Host) BaseURL() string {
if listenAddr == "" {
return ""
}
- return "http://" + listenAddr
+ if host.tlsCertificate == nil {
+ return "http://" + listenAddr
+ }
+ serverName := "localhost"
+ if _, port, err := net.SplitHostPort(listenAddr); err == nil {
+ return "https://" + net.JoinHostPort(serverName, port)
+ }
+ return "https://" + listenAddr
}
func (host *Host) IsRunning() bool {
@@ -153,6 +182,12 @@ func (host *Host) Start() error {
host.lastRunErr = fmt.Errorf("监听内置后端 %s 失败: %w", host.listenAddr, err)
return host.lastRunErr
}
+ if host.tlsCertificate != nil {
+ listener = tls.NewListener(listener, &tls.Config{
+ Certificates: []tls.Certificate{*host.tlsCertificate},
+ MinVersion: tls.VersionTLS12,
+ })
+ }
host.listenAddr = listener.Addr().String()
host.httpServer = httpServer
host.lastRunErr = nil
@@ -202,7 +237,7 @@ func (host *Host) HealthCheck(ctx context.Context) error {
}
client := host.healthHTTP
if client == nil {
- client = newLoopbackHTTPClient()
+ client = newLoopbackHTTPClient(host.tlsCertificate)
}
response, err := client.Do(request)
if err != nil {
@@ -245,19 +280,34 @@ func (host *Host) InProcessHealthCheck() error {
return nil
}
-func newLoopbackHTTPClient() *http.Client {
+func newLoopbackHTTPClient(certificate *tls.Certificate) *http.Client {
+ transport := &http.Transport{
+ Proxy: nil,
+ DialContext: (&net.Dialer{
+ Timeout: 1 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }).DialContext,
+ ForceAttemptHTTP2: false,
+ MaxIdleConns: 1,
+ MaxIdleConnsPerHost: 1,
+ IdleConnTimeout: 30 * time.Second,
+ }
+ if certificate != nil {
+ roots := x509.NewCertPool()
+ for _, rawCertificate := range certificate.Certificate[1:] {
+ parsed, err := x509.ParseCertificate(rawCertificate)
+ if err == nil {
+ roots.AddCert(parsed)
+ }
+ }
+ transport.TLSClientConfig = &tls.Config{
+ MinVersion: tls.VersionTLS12,
+ RootCAs: roots,
+ ServerName: "localhost",
+ }
+ }
return &http.Client{
- Transport: &http.Transport{
- Proxy: nil,
- DialContext: (&net.Dialer{
- Timeout: 1 * time.Second,
- KeepAlive: 30 * time.Second,
- }).DialContext,
- ForceAttemptHTTP2: false,
- MaxIdleConns: 1,
- MaxIdleConnsPerHost: 1,
- IdleConnTimeout: 30 * time.Second,
- },
+ Transport: transport,
}
}
@@ -276,8 +326,20 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
SystemSettingService: &serverSystemSettings{configs: host.configs},
HTTPClient: netproxy.NewHTTPClient(30000 * time.Second),
}
+ fallbackForward := upstream.FallbackForwardAction(
+ routeDeps,
+ upstream.CompatRouteConfig{Name: "upstream_fallback"},
+ upstream.DefaultCursorUpstreamBaseURL,
+ )
+ localAIAction := server.HTTPHandlerAction(agentModule.AiHandler)
+ aiServiceAction := func(ctx *server.Context) error {
+ if ctx != nil && ctx.Request != nil && ctx.Request.URL != nil && agentModule.HandlesAIPath(ctx.Request.URL.Path) {
+ return localAIAction(ctx)
+ }
+ return fallbackForward(ctx)
+ }
- host.mux = server.New(
+ host.mux = withLocalBackendCORS(server.New(
server.Use(
server.Recover(),
server.ServerContext(),
@@ -427,19 +489,11 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
StatusCode: http.StatusOK,
})),
),
- server.POST("/oauth/token",
- server.Name("oauth_token"),
+ server.GET("/auth/cursor_dev_session_token",
+ server.Name("auth_cursor_dev_session_token"),
server.HTTP(),
- server.Local(upstream.MockOAuthAction(routeDeps, upstream.CompatRouteConfig{
- Name: "oauth_token",
- StatusCode: http.StatusOK,
- })),
- ),
- server.POST("/aiserver.v1.AuthService/GetEmail",
- server.Name("auth_service_get_email"),
- server.ConnectUnary(),
- server.Local(upstream.MockAuthEmailAction(routeDeps, upstream.CompatRouteConfig{
- Name: "auth_service_get_email",
+ server.Local(upstream.MockDevSessionTokenAction(routeDeps, upstream.CompatRouteConfig{
+ Name: "auth_cursor_dev_session_token",
StatusCode: http.StatusOK,
})),
),
@@ -476,17 +530,14 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
server.Any("/aiserver.v1.AiService/*",
server.Name("ai_service"),
server.HTTP(),
- server.Local(server.HTTPHandlerAction(agentModule.AiHandler)),
+ server.Local(aiServiceAction),
),
tabServerProcedure("/aiserver.v1.CppService/AvailableModels", "cpp_available_models", server.ConnectUnary(), routeDeps),
tabServerProcedure("/aiserver.v1.CppService/RecordCppFate", "cpp_record_cpp_fate", server.ConnectUnary(), routeDeps),
server.Any("/aiserver.v1.CppService/*",
server.Name("cpp_service"),
server.HTTP(),
- server.Local(func(ctx *server.Context) error {
- http.NotFound(ctx.Writer, ctx.Request)
- return nil
- }),
+ server.Local(fallbackForward),
),
tabServerProcedure("/aiserver.v1.FileSyncService/FSSyncFile", "file_sync_sync_file", server.ConnectUnary(), routeDeps),
tabServerProcedure("/aiserver.v1.FileSyncService/FSIsEnabledForUser", "file_sync_is_enabled_for_user", server.ConnectUnary(), routeDeps),
@@ -495,10 +546,7 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
server.Any("/aiserver.v1.FileSyncService/*",
server.Name("file_sync"),
server.HTTP(),
- server.Local(func(ctx *server.Context) error {
- http.NotFound(ctx.Writer, ctx.Request)
- return nil
- }),
+ server.Local(fallbackForward),
),
server.POST("/aiserver.v1.DashboardService/GetTokenUsage",
server.Name("dashboard_token_usage"),
@@ -530,21 +578,6 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
MockBuilder: upstream.DashboardTeamsMockBuilder,
})),
),
- server.POST("/aiserver.v1.DashboardService/GetManagedSkills",
- server.Name("dashboard_get_managed_skills"),
- server.ConnectUnary(),
- 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.POST("/aiserver.v1.DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam",
server.Name("dashboard_get_team_admin_settings_or_empty"),
server.ConnectUnary(),
@@ -565,76 +598,6 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
MockBuilder: upstream.EmptyMockBuilder,
})),
),
- server.POST("/aiserver.v1.DashboardService/ListMarketplaces",
- server.Name("dashboard_list_marketplaces"),
- server.ConnectUnary(),
- server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
- Name: "dashboard_list_marketplaces",
- StatusCode: http.StatusOK,
- MockProtoType: "aiserver.v1.ListMarketplacesResponse",
- MockBuilder: upstream.EmptyMockBuilder,
- })),
- ),
- server.POST("/aiserver.v1.DashboardService/GetGlobalCommands",
- server.Name("dashboard_get_global_commands"),
- server.ConnectUnary(),
- server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
- Name: "dashboard_get_global_commands",
- StatusCode: http.StatusOK,
- MockProtoType: "aiserver.v1.GetGlobalCommandsResponse",
- MockBuilder: upstream.EmptyMockBuilder,
- })),
- ),
- server.POST("/aiserver.v1.DashboardService/GetEffectiveUserPlugins",
- server.Name("dashboard_get_effective_user_plugins"),
- server.ConnectUnary(),
- server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
- Name: "dashboard_get_effective_user_plugins",
- StatusCode: http.StatusOK,
- MockProtoType: "aiserver.v1.GetEffectiveUserPluginsResponse",
- MockBuilder: upstream.EmptyMockBuilder,
- })),
- ),
- server.POST("/aiserver.v1.DashboardService/RegisterMarketplaceAndPlugins",
- server.Name("dashboard_register_marketplace_and_plugins"),
- server.ConnectUnary(),
- server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
- Name: "dashboard_register_marketplace_and_plugins",
- StatusCode: http.StatusOK,
- MockProtoType: "aiserver.v1.RegisterMarketplaceAndPluginsResponse",
- MockBuilder: upstream.EmptyMockBuilder,
- })),
- ),
- server.POST("/aiserver.v1.DashboardService/GetCliDownloadUrl",
- server.Name("dashboard_get_cli_download_url"),
- server.ConnectUnary(),
- server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
- Name: "dashboard_get_cli_download_url",
- StatusCode: http.StatusOK,
- MockProtoType: "aiserver.v1.GetCliDownloadUrlResponse",
- MockBuilder: upstream.EmptyMockBuilder,
- })),
- ),
- server.POST("/aiserver.v1.DashboardService/GetMe",
- server.Name("dashboard_get_me"),
- server.ConnectUnary(),
- server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
- Name: "dashboard_get_me",
- StatusCode: http.StatusOK,
- MockProtoType: "aiserver.v1.GetMeResponse",
- MockBuilder: upstream.DashboardGetMeMockBuilder,
- })),
- ),
- server.POST("/aiserver.v1.DashboardService/GetUserPrivacyMode",
- server.Name("dashboard_user_privacy_mode"),
- server.ConnectUnary(),
- server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
- Name: "dashboard_user_privacy_mode",
- StatusCode: http.StatusOK,
- MockProtoType: "aiserver.v1.GetUserPrivacyModeResponse",
- MockBuilder: upstream.DashboardUserPrivacyModeMockBuilder,
- })),
- ),
server.POST("/aiserver.v1.DashboardService/GetPlanInfo",
server.Name("dashboard_plan_info"),
server.ConnectUnary(),
@@ -665,104 +628,36 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
MockBuilder: upstream.DashboardIsOnNewPricingMockBuilder,
})),
),
- 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.Any("/*",
+ server.Name("upstream_fallback"),
server.HTTP(),
- server.Local(func(ctx *server.Context) error {
- http.NotFound(ctx.Writer, ctx.Request)
- return nil
- }),
+ server.Local(fallbackForward),
),
- server.Any("/aiserver.v1.NetworkService/*",
- server.Name("network_service"),
- server.HTTP(),
- server.Local(func(ctx *server.Context) error {
- http.NotFound(ctx.Writer, ctx.Request)
- return nil
- }),
- ),
- server.Any("/aiserver.v1.InAppAdService/*",
- server.Name("in_app_ad"),
- server.HTTP(),
- server.Local(func(ctx *server.Context) error {
- http.NotFound(ctx.Writer, ctx.Request)
- return nil
- }),
- ),
- server.GET("/auth/full_stripe_profile",
- server.Name("auth_full_stripe_profile"),
- server.HTTP(),
- server.Local(upstream.MockAuthFullStripeProfileAction(routeDeps, upstream.CompatRouteConfig{
- Name: "auth_full_stripe_profile",
- StatusCode: http.StatusOK,
- })),
- ),
- server.GET("/auth/stripe_profile",
- server.Name("auth_stripe_profile"),
- server.HTTP(),
- server.Local(upstream.MockAuthStripeProfileAction(routeDeps, upstream.CompatRouteConfig{
- Name: "auth_stripe_profile",
- StatusCode: http.StatusOK,
- })),
- ),
- server.GET("/auth/has_valid_payment_method",
- server.Name("auth_has_valid_payment_method"),
- server.HTTP(),
- server.Local(upstream.MockJSONAction(routeDeps, upstream.CompatRouteConfig{
- Name: "auth_has_valid_payment_method",
- StatusCode: http.StatusOK,
- JSONBody: map[string]any{
- "hasValidPaymentMethod": true,
- },
- })),
- ),
- server.Any("/auth/poll",
- server.Name("auth_poll"),
- server.HTTP(),
- server.Local(upstream.MockAuthPollAction(routeDeps, upstream.CompatRouteConfig{
- Name: "auth_poll",
- StatusCode: http.StatusOK,
- })),
- ),
- server.POST("/auth/logout",
- server.Name("auth_logout"),
- server.HTTP(),
- server.Local(upstream.FixedStatusAction(routeDeps, upstream.CompatRouteConfig{
- Name: "auth_logout",
- StatusCode: http.StatusNoContent,
- })),
- ),
- server.Any("/auth/*",
- server.Name("auth_proxy"),
- server.HTTP(),
- server.Local(func(ctx *server.Context) error {
- http.NotFound(ctx.Writer, ctx.Request)
- return nil
- }),
- ),
- )
+ ))
return nil
}
+func withLocalBackendCORS(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ writer.Header().Set("Access-Control-Allow-Origin", "*")
+ writer.Header().Del("Access-Control-Allow-Credentials")
+ if strings.EqualFold(request.Method, http.MethodOptions) && strings.TrimSpace(request.Header.Get("Access-Control-Request-Method")) != "" {
+ writer.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
+ requestedHeaders := strings.TrimSpace(request.Header.Get("Access-Control-Request-Headers"))
+ if requestedHeaders == "" {
+ requestedHeaders = "authorization,content-type,x-cursor-client-type"
+ }
+ writer.Header().Set("Access-Control-Allow-Headers", requestedHeaders)
+ writer.Header().Set("Access-Control-Max-Age", "86400")
+ writer.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ next.ServeHTTP(writer, request)
+ })
+}
+
func repositoryServiceProcedure(pattern string, name string, protocol server.RouteOption, module *forwarder.Module) server.Option {
localAction := server.HTTPHandlerAction(module.RepositoryServiceHandler)
return server.POST(pattern,
@@ -803,46 +698,6 @@ func tabServerProcedure(pattern string, name string, protocol server.RouteOption
)
}
-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)),
- )
-}
-
-func cursorControlPlaneAction(
- authorizationProvider upstream.AuthorizationProvider,
- deps upstream.Dependencies,
- name string,
- fallback server.HandlerFunc,
-) server.HandlerFunc {
- forward := upstream.AuthenticatedForwardAction(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 forward(ctx)
- }
-}
-
type serverSystemSettings struct {
configs *serverconfig.Manager
}
diff --git a/internal/backend/host_auth_test.go b/internal/backend/host_auth_test.go
new file mode 100644
index 0000000..2ba9acb
--- /dev/null
+++ b/internal/backend/host_auth_test.go
@@ -0,0 +1,175 @@
+package backend
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/json"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "cursor/gen/aiserverv1"
+ serverconfig "cursor/internal/backend/server/config"
+ "cursor/internal/certs"
+
+ "google.golang.org/protobuf/proto"
+)
+
+func TestHostServesDevLoginAndLocalTeamsRoute(t *testing.T) {
+ store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir())
+ host, err := NewHost(store)
+ if err != nil {
+ t.Fatalf("new host: %v", err)
+ }
+
+ loginRequest := httptest.NewRequest(http.MethodGet, "http://local/auth/cursor_dev_session_token?plan=enterprise&email=enterprise%40example.com", nil)
+ loginRecorder := httptest.NewRecorder()
+ host.mux.ServeHTTP(loginRecorder, loginRequest)
+ if loginRecorder.Code != http.StatusOK {
+ t.Fatalf("dev login status: got %d, want %d; body=%s", loginRecorder.Code, http.StatusOK, loginRecorder.Body.String())
+ }
+ var loginResponse struct {
+ AccessToken string `json:"accessToken"`
+ }
+ if err := json.Unmarshal(loginRecorder.Body.Bytes(), &loginResponse); err != nil {
+ t.Fatalf("decode dev login: %v", err)
+ }
+ if loginResponse.AccessToken == "" {
+ t.Fatal("dev login returned an empty access token")
+ }
+
+ teamsRequest := httptest.NewRequest(http.MethodPost, "http://local/aiserver.v1.DashboardService/GetTeams", nil)
+ teamsRequest.Header.Set("Authorization", "Bearer "+loginResponse.AccessToken)
+ teamsRecorder := httptest.NewRecorder()
+ host.mux.ServeHTTP(teamsRecorder, teamsRequest)
+ if teamsRecorder.Code != http.StatusOK {
+ t.Fatalf("teams status: got %d, want %d", teamsRecorder.Code, http.StatusOK)
+ }
+ teams := &aiserverv1.GetTeamsResponse{}
+ if err := proto.Unmarshal(teamsRecorder.Body.Bytes(), teams); err != nil {
+ t.Fatalf("decode teams response: %v", err)
+ }
+ if len(teams.GetTeams()) != 1 || !teams.GetTeams()[0].GetIsEnterprise() {
+ t.Fatalf("unexpected teams response: %v", teams.GetTeams())
+ }
+}
+
+func TestHostAllowsWildcardCORS(t *testing.T) {
+ store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir())
+ host, err := NewHost(store)
+ if err != nil {
+ t.Fatalf("new host: %v", err)
+ }
+
+ preflightRequest := httptest.NewRequest(http.MethodOptions, "http://local/auth/cursor_dev_session_token?plan=free", nil)
+ preflightRequest.Header.Set("Origin", "vscode-file://vscode-app")
+ preflightRequest.Header.Set("Access-Control-Request-Method", http.MethodGet)
+ preflightRequest.Header.Set("Access-Control-Request-Headers", "x-cursor-client-type")
+ preflightRecorder := httptest.NewRecorder()
+ host.mux.ServeHTTP(preflightRecorder, preflightRequest)
+ if preflightRecorder.Code != http.StatusNoContent {
+ t.Fatalf("preflight status: got %d, want %d", preflightRecorder.Code, http.StatusNoContent)
+ }
+ if got := preflightRecorder.Header().Get("Access-Control-Allow-Origin"); got != "*" {
+ t.Fatalf("preflight allow origin: got %q", got)
+ }
+ if got := preflightRecorder.Header().Get("Access-Control-Allow-Credentials"); got != "" {
+ t.Fatalf("preflight allow credentials: got %q, want empty", got)
+ }
+ if got := preflightRecorder.Header().Get("Access-Control-Allow-Headers"); got != "x-cursor-client-type" {
+ t.Fatalf("preflight allow headers: got %q", got)
+ }
+
+ loginRequest := httptest.NewRequest(http.MethodGet, "http://local/auth/cursor_dev_session_token?plan=free", nil)
+ loginRequest.Header.Set("Origin", "vscode-file://vscode-app")
+ loginRequest.Header.Set("x-cursor-client-type", "ide")
+ loginRecorder := httptest.NewRecorder()
+ host.mux.ServeHTTP(loginRecorder, loginRequest)
+ if loginRecorder.Code != http.StatusOK {
+ t.Fatalf("dev login status: got %d, want %d", loginRecorder.Code, http.StatusOK)
+ }
+ if got := loginRecorder.Header().Get("Access-Control-Allow-Origin"); got != "*" {
+ t.Fatalf("dev login allow origin: got %q", got)
+ }
+}
+
+func TestHostAllowsRemoteWebOriginWithWildcard(t *testing.T) {
+ store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir())
+ host, err := NewHost(store)
+ if err != nil {
+ t.Fatalf("new host: %v", err)
+ }
+
+ request := httptest.NewRequest(http.MethodOptions, "http://local/auth/cursor_dev_session_token", nil)
+ request.Header.Set("Origin", "https://example.com")
+ request.Header.Set("Access-Control-Request-Method", http.MethodGet)
+ recorder := httptest.NewRecorder()
+ host.mux.ServeHTTP(recorder, request)
+ if got := recorder.Header().Get("Access-Control-Allow-Origin"); got != "*" {
+ t.Fatalf("remote origin allow origin: got %q, want wildcard", got)
+ }
+}
+
+func TestHostServesDevLoginOverTrustedLocalhostTLS(t *testing.T) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("reserve backend port: %v", err)
+ }
+ listenAddr := listener.Addr().String()
+ if err := listener.Close(); err != nil {
+ t.Fatalf("release backend port: %v", err)
+ }
+
+ store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir())
+ config := serverconfig.DefaultConfig()
+ config.BackendListenAddr = listenAddr
+ if _, err := store.Save(context.Background(), config); err != nil {
+ t.Fatalf("save backend config: %v", err)
+ }
+ certificateManager, err := certs.NewEmbeddedManager()
+ if err != nil {
+ t.Fatalf("new certificate manager: %v", err)
+ }
+ serverCertificate, err := certificateManager.CertificateForServerName("localhost")
+ if err != nil {
+ t.Fatalf("create localhost certificate: %v", err)
+ }
+ host, err := NewHost(store, WithTLSCertificate(serverCertificate))
+ if err != nil {
+ t.Fatalf("new TLS host: %v", err)
+ }
+ if err := host.Start(); err != nil {
+ t.Fatalf("start TLS host: %v", err)
+ }
+ defer func() {
+ stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := host.Stop(stopContext); err != nil {
+ t.Errorf("stop TLS host: %v", err)
+ }
+ }()
+
+ caCertificate, err := certificateManager.CATLSCertificate()
+ if err != nil {
+ t.Fatalf("load CA certificate: %v", err)
+ }
+ roots := x509.NewCertPool()
+ roots.AddCert(caCertificate.Leaf)
+ client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{
+ MinVersion: tls.VersionTLS12,
+ RootCAs: roots,
+ ServerName: "localhost",
+ }}}
+ response, err := client.Get(host.BaseURL() + "/auth/cursor_dev_session_token?plan=pro&trial=true")
+ if err != nil {
+ t.Fatalf("request dev login over TLS: %v", err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ t.Fatalf("dev login TLS status: got %d, want %d", response.StatusCode, http.StatusOK)
+ }
+}
diff --git a/internal/backend/host_fallback_test.go b/internal/backend/host_fallback_test.go
new file mode 100644
index 0000000..4dabae4
--- /dev/null
+++ b/internal/backend/host_fallback_test.go
@@ -0,0 +1,127 @@
+package backend
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "cursor/internal/backend/server"
+ serverconfig "cursor/internal/backend/server/config"
+)
+
+func TestHostForwardsUnhandledRoutesToOriginalUpstream(t *testing.T) {
+ var requestCount atomic.Int32
+ upstreamServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ requestCount.Add(1)
+ body, err := io.ReadAll(request.Body)
+ if err != nil {
+ t.Errorf("read upstream request body: %v", err)
+ }
+ writer.Header().Set("X-Upstream-Path", request.URL.RequestURI())
+ writer.WriteHeader(http.StatusMultiStatus)
+ _, _ = writer.Write(body)
+ }))
+ defer upstreamServer.Close()
+
+ store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir())
+ host, err := NewHost(store)
+ if err != nil {
+ t.Fatalf("new host: %v", err)
+ }
+
+ testCases := []struct {
+ name string
+ method string
+ path string
+ }{
+ {name: "managed skills", path: "/aiserver.v1.DashboardService/GetManagedSkills?source=skills"},
+ {name: "effective plugins", path: "/aiserver.v1.DashboardService/GetEffectiveUserPlugins?source=plugins"},
+ {name: "MCP registry", path: "/aiserver.v1.MCPRegistryService/GetKnownServers?source=mcp"},
+ {name: "auth poll", path: "/auth/poll?uuid=local-login&verifier=test"},
+ {name: "OAuth token", path: "/oauth/token"},
+ {name: "auth email", path: "/aiserver.v1.AuthService/GetEmail"},
+ {name: "dashboard me", path: "/aiserver.v1.DashboardService/GetMe"},
+ {name: "full stripe profile", method: http.MethodGet, path: "/auth/full_stripe_profile"},
+ {name: "stripe profile", method: http.MethodGet, path: "/auth/stripe_profile"},
+ {name: "valid payment method", method: http.MethodGet, path: "/auth/has_valid_payment_method"},
+ {name: "auth logout", path: "/auth/logout"},
+ {name: "dashboard global commands", path: "/aiserver.v1.DashboardService/GetGlobalCommands"},
+ {name: "dashboard CLI download", path: "/aiserver.v1.DashboardService/GetCliDownloadUrl"},
+ {name: "dashboard privacy mode", path: "/aiserver.v1.DashboardService/GetUserPrivacyMode"},
+ {name: "service catch-all", path: "/aiserver.v1.NetworkService/UnknownProcedure?source=network"},
+ {name: "AI handler miss", path: "/aiserver.v1.AiService/UnknownProcedure?source=ai"},
+ {name: "global miss", path: "/unknown/service/path?source=global"},
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ method := testCase.method
+ if method == "" {
+ method = http.MethodPost
+ }
+ body := "payload-" + testCase.name
+ request := httptest.NewRequest(method, "http://localhost:8000"+testCase.path, strings.NewReader(body))
+ request.Header.Set(server.HeaderServerUpstreamURL, upstreamServer.URL+testCase.path)
+ recorder := httptest.NewRecorder()
+
+ host.mux.ServeHTTP(recorder, request)
+
+ if got := recorder.Code; got != http.StatusMultiStatus {
+ t.Fatalf("status: got %d, want %d; body=%s", got, http.StatusMultiStatus, recorder.Body.String())
+ }
+ if got := recorder.Header().Get("X-Upstream-Path"); got != testCase.path {
+ t.Fatalf("upstream path: got %q, want %q", got, testCase.path)
+ }
+ wantBody := body
+ if method == http.MethodGet {
+ wantBody = ""
+ }
+ if got := recorder.Body.String(); got != wantBody {
+ t.Fatalf("response body: got %q, want %q", got, wantBody)
+ }
+ })
+ }
+
+ requestsBeforeHealthCheck := requestCount.Load()
+ healthRequest := httptest.NewRequest(http.MethodGet, "http://localhost:8000"+healthPath, nil)
+ healthRecorder := httptest.NewRecorder()
+ host.mux.ServeHTTP(healthRecorder, healthRequest)
+ if got := healthRecorder.Code; got != http.StatusOK {
+ t.Fatalf("health status: got %d, want %d", got, http.StatusOK)
+ }
+ if got := requestCount.Load(); got != requestsBeforeHealthCheck {
+ t.Fatalf("local health route unexpectedly reached upstream: requests before=%d after=%d", requestsBeforeHealthCheck, got)
+ }
+}
+
+func TestHostFallbackKeepsWildcardCORSWhenUpstreamReturnsCORSHeaders(t *testing.T) {
+ upstreamServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ writer.Header().Set("Access-Control-Allow-Origin", "vscode-file://vscode-app")
+ writer.Header().Set("Access-Control-Allow-Credentials", "true")
+ writer.WriteHeader(http.StatusOK)
+ }))
+ defer upstreamServer.Close()
+
+ store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir())
+ host, err := NewHost(store)
+ if err != nil {
+ t.Fatalf("new host: %v", err)
+ }
+
+ request := httptest.NewRequest(http.MethodGet, "http://localhost:8000/auth/poll?uuid=test", nil)
+ request.Header.Set("Origin", "vscode-file://vscode-app")
+ request.Header.Set(server.HeaderServerUpstreamURL, upstreamServer.URL+request.URL.RequestURI())
+ recorder := httptest.NewRecorder()
+
+ host.mux.ServeHTTP(recorder, request)
+
+ if got := recorder.Header().Values("Access-Control-Allow-Origin"); len(got) != 1 || got[0] != "*" {
+ t.Fatalf("allow origin values: got %q, want [*]", got)
+ }
+ if got := recorder.Header().Get("Access-Control-Allow-Credentials"); got != "" {
+ t.Fatalf("allow credentials: got %q, want empty", got)
+ }
+}
diff --git a/internal/backend/server/config/types.go b/internal/backend/server/config/types.go
index c0a1999..321f036 100644
--- a/internal/backend/server/config/types.go
+++ b/internal/backend/server/config/types.go
@@ -13,7 +13,7 @@ import (
)
const (
- DefaultBackendListenAddr = "127.0.0.1:18090"
+ DefaultBackendListenAddr = "127.0.0.1:8000"
DefaultProxyListenAddr = "127.0.0.1:18080"
DefaultFrontendBaseURL = "http://127.0.0.1"
DefaultProviderStreamIdleTimeoutSeconds = 240
diff --git a/internal/backend/server/upstream/action.go b/internal/backend/server/upstream/action.go
index 70614cf..e6f635a 100644
--- a/internal/backend/server/upstream/action.go
+++ b/internal/backend/server/upstream/action.go
@@ -14,12 +14,13 @@ import (
type CompatRouteConfig struct {
Name string
StatusCode int
- JSONBody map[string]any
MockProtoType string
MockBuilder func(*RequestContext) (map[string]any, error)
ConsoleLog bool
}
+const DefaultCursorUpstreamBaseURL = "https://api2.cursor.sh:443"
+
func ForwardAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc {
return func(ctx *server.Context) error {
reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg)
@@ -30,31 +31,27 @@ func ForwardAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc
}
}
-// AuthenticatedForwardAction forwards a Cursor control-plane request with the
-// independent desktop account after the local-mode identity rewrite has run.
-func AuthenticatedForwardAction(deps Dependencies, cfg CompatRouteConfig, authorizationProvider AuthorizationProvider) server.HandlerFunc {
+// FallbackForwardAction preserves an MITM request's original upstream URL. A
+// native request has no original host metadata, so it is resolved against the
+// configured default upstream while retaining its path and query string.
+func FallbackForwardAction(deps Dependencies, cfg CompatRouteConfig, defaultBaseURL string) server.HandlerFunc {
+ forward := ForwardAction(deps, cfg)
return func(ctx *server.Context) error {
- reqCtx, _, err := newCompatRouteObjects(ctx, deps, cfg)
- if err != nil {
- return err
+ if ctx == nil || ctx.Request == nil || ctx.Request.URL == nil {
+ return fmt.Errorf("fallback upstream request context is invalid")
}
- if reqCtx == nil || reqCtx.Request == nil {
- return fmt.Errorf("Cursor 控制面请求上下文无效")
+ if ctx.UpstreamURL == nil {
+ baseURL, err := ParseAndValidateRawURL(defaultBaseURL)
+ if err != nil {
+ return fmt.Errorf("parse fallback upstream URL: %w", err)
+ }
+ targetURL := *ctx.Request.URL
+ targetURL.Scheme = baseURL.Scheme
+ targetURL.Host = baseURL.Host
+ targetURL.User = baseURL.User
+ ctx.UpstreamURL = &targetURL
}
- 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
+ return forward(ctx)
}
}
@@ -68,63 +65,13 @@ func FixedStatusAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerF
}
}
-func MockJSONAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc {
+func MockDevSessionTokenAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc {
return func(ctx *server.Context) error {
reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg)
if err != nil {
return err
}
- return handleMockJSON(reqCtx, route)
- }
-}
-
-func MockOAuthAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc {
- return func(ctx *server.Context) error {
- reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg)
- if err != nil {
- return err
- }
- return handleMockOAuth(reqCtx, route)
- }
-}
-
-func MockAuthFullStripeProfileAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc {
- return func(ctx *server.Context) error {
- reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg)
- if err != nil {
- return err
- }
- return handleMockAuthFullStripeProfile(reqCtx, route)
- }
-}
-
-func MockAuthStripeProfileAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc {
- return func(ctx *server.Context) error {
- reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg)
- if err != nil {
- return err
- }
- return handleMockAuthStripeProfile(reqCtx, route)
- }
-}
-
-func MockAuthPollAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc {
- return func(ctx *server.Context) error {
- reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg)
- if err != nil {
- return err
- }
- return handleMockAuthPoll(reqCtx, route)
- }
-}
-
-func MockAuthEmailAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc {
- return func(ctx *server.Context) error {
- reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg)
- if err != nil {
- return err
- }
- return handleMockAuthEmail(reqCtx, route)
+ return handleMockDevSessionToken(reqCtx, route)
}
}
@@ -169,7 +116,6 @@ func newCompatRouteObjects(ctx *server.Context, deps Dependencies, cfg CompatRou
Name: cfg.Name,
Pattern: ctx.Request.URL.Path,
StatusCode: cfg.StatusCode,
- JSONBody: cfg.JSONBody,
MockProtoType: cfg.MockProtoType,
MockPayloadBuilder: cfg.MockBuilder,
ConsoleLog: cfg.ConsoleLog,
@@ -221,10 +167,6 @@ func DashboardTeamsMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return buildDashboardTeamsPayload(reqCtx)
}
-func DashboardManagedSkillsMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
- return buildDashboardManagedSkillsPayload(reqCtx)
-}
-
// EmptyMockBuilder возвращает пустой proto-ответ для ручек, где клиенту
// достаточно успешного "пусто": нет team-настроек, нет репозиториев,
// нет маркетплейсов/плагинов/команд, телеметрия принята без обработки.
@@ -237,14 +179,6 @@ func SubmitLogsMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return map[string]any{"success": true}, nil
}
-func DashboardGetMeMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
- return buildDashboardGetMePayload(reqCtx)
-}
-
-func DashboardUserPrivacyModeMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
- return buildDashboardUserPrivacyModePayload(reqCtx)
-}
-
func DashboardPlanInfoMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return buildDashboardPlanInfoPayload(reqCtx)
}
diff --git a/internal/backend/server/upstream/auth.go b/internal/backend/server/upstream/auth.go
new file mode 100644
index 0000000..4bb65e4
--- /dev/null
+++ b/internal/backend/server/upstream/auth.go
@@ -0,0 +1,193 @@
+package upstream
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ legacyruntime "cursor/internal/runtime"
+)
+
+const (
+ localDevDefaultPlan = "ultra"
+ localDevTokenLifetime = 10 * 365 * 24 * time.Hour
+ localDevSubscriptionActive = "active"
+)
+
+var localDevPlans = map[string]struct{}{
+ "free": {},
+ "pro": {},
+ "pro_plus": {},
+ "ultra": {},
+ "enterprise": {},
+}
+
+type localDevSessionClaims struct {
+ Subject string `json:"sub"`
+ Email string `json:"email"`
+ Plan string `json:"cursor_local_plan"`
+ Trial bool `json:"cursor_local_trial"`
+ TokenType string `json:"type"`
+ Issuer string `json:"iss"`
+ Scope string `json:"scope"`
+ IssuedAt int64 `json:"iat"`
+ ExpiresAt int64 `json:"exp"`
+}
+
+func handleMockDevSessionToken(reqCtx *RequestContext, route *Route) error {
+ _ = route
+ if reqCtx == nil || reqCtx.Request == nil || reqCtx.ResponseWriter == nil {
+ return fmt.Errorf("dev session request context is invalid")
+ }
+
+ plan, trial, email, err := parseLocalDevSessionQuery(reqCtx.Request)
+ if err != nil {
+ writeJSONError(reqCtx.ResponseWriter, http.StatusBadRequest, err.Error())
+ return nil
+ }
+
+ token, claims, err := buildLocalDevSessionToken(plan, trial, email, time.Now())
+ if err != nil {
+ return err
+ }
+ responseBody, err := marshalJSONBody(map[string]any{
+ "accessToken": token,
+ "refreshToken": token,
+ "authId": claims.Subject,
+ })
+ if err != nil {
+ return err
+ }
+ reqCtx.ResponseWriter.Header().Set("content-type", "application/json")
+ reqCtx.ResponseWriter.WriteHeader(http.StatusOK)
+ _, _ = reqCtx.ResponseWriter.Write(responseBody)
+ return nil
+}
+
+func parseLocalDevSessionQuery(request *http.Request) (string, bool, string, error) {
+ plan := localDevDefaultPlan
+ email := legacyruntime.InjectAccountEmail
+ if request == nil || request.URL == nil {
+ return plan, false, email, nil
+ }
+
+ query := request.URL.Query()
+ if requestedPlan := strings.TrimSpace(query.Get("plan")); requestedPlan != "" {
+ plan = requestedPlan
+ }
+ if _, ok := localDevPlans[plan]; !ok {
+ return "", false, "", fmt.Errorf("unsupported dev plan %q", plan)
+ }
+
+ trial := false
+ if rawTrial := strings.TrimSpace(query.Get("trial")); rawTrial != "" {
+ parsed, err := strconv.ParseBool(rawTrial)
+ if err != nil {
+ return "", false, "", fmt.Errorf("invalid trial value %q", rawTrial)
+ }
+ trial = parsed
+ }
+ if trial && plan != "pro" && plan != "pro_plus" {
+ return "", false, "", fmt.Errorf("trial is only supported for pro and pro_plus")
+ }
+
+ if requestedEmail := strings.TrimSpace(query.Get("email")); requestedEmail != "" {
+ email = requestedEmail
+ }
+ return plan, trial, email, nil
+}
+
+func buildLocalDevSessionToken(plan string, trial bool, email string, now time.Time) (string, localDevSessionClaims, error) {
+ authID := "local-dev-" + strings.ReplaceAll(plan, "_", "-")
+ if trial {
+ authID += "-trial"
+ }
+ claims := localDevSessionClaims{
+ Subject: authID,
+ Email: strings.TrimSpace(email),
+ Plan: plan,
+ Trial: trial,
+ TokenType: "session",
+ Issuer: "cursor-local-backend",
+ Scope: "openid profile email",
+ IssuedAt: now.Unix(),
+ ExpiresAt: now.Add(localDevTokenLifetime).Unix(),
+ }
+ headerJSON, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
+ if err != nil {
+ return "", localDevSessionClaims{}, err
+ }
+ claimsJSON, err := json.Marshal(claims)
+ if err != nil {
+ return "", localDevSessionClaims{}, err
+ }
+ encode := base64.RawURLEncoding.EncodeToString
+ token := encode(headerJSON) + "." + encode(claimsJSON) + ".local-dev"
+ return token, claims, nil
+}
+
+func localDevClaimsFromRequest(reqCtx *RequestContext) (localDevSessionClaims, bool) {
+ if reqCtx == nil {
+ return localDevSessionClaims{}, false
+ }
+ return localDevClaimsFromAuthorization(reqCtx.Headers.Get("authorization"))
+}
+
+func localDevClaimsFromAuthorization(authorization string) (localDevSessionClaims, bool) {
+ authorization = strings.TrimSpace(authorization)
+ if len(authorization) >= len("Bearer ") && strings.EqualFold(authorization[:len("Bearer ")], "Bearer ") {
+ authorization = strings.TrimSpace(authorization[len("Bearer "):])
+ }
+ parts := strings.Split(authorization, ".")
+ if len(parts) != 3 {
+ return localDevSessionClaims{}, false
+ }
+ payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ return localDevSessionClaims{}, false
+ }
+ claims := localDevSessionClaims{}
+ if err := json.Unmarshal(payload, &claims); err != nil {
+ return localDevSessionClaims{}, false
+ }
+ if claims.Issuer != "cursor-local-backend" {
+ return localDevSessionClaims{}, false
+ }
+ if _, ok := localDevPlans[claims.Plan]; !ok || strings.TrimSpace(claims.Subject) == "" {
+ return localDevSessionClaims{}, false
+ }
+ return claims, true
+}
+
+func localDevPlanFromRequest(reqCtx *RequestContext) string {
+ if claims, ok := localDevClaimsFromRequest(reqCtx); ok {
+ return claims.Plan
+ }
+ return localDevDefaultPlan
+}
+
+func localDevPlanDetails(plan string) (string, int) {
+ switch plan {
+ case "free":
+ return "Free Plan", 0
+ case "pro":
+ return "Pro Plan", 2000
+ case "pro_plus":
+ return "Pro+ Plan", 6000
+ case "enterprise":
+ return "Enterprise Plan", 0
+ default:
+ return "Ultra Plan", localUltraPlanIncludedCents
+ }
+}
+
+func writeJSONError(writer http.ResponseWriter, statusCode int, message string) {
+ writer.Header().Set("content-type", "application/json")
+ writer.WriteHeader(statusCode)
+ payload, _ := json.Marshal(map[string]string{"error": message})
+ _, _ = writer.Write(payload)
+}
diff --git a/internal/backend/server/upstream/auth_test.go b/internal/backend/server/upstream/auth_test.go
new file mode 100644
index 0000000..42a14c8
--- /dev/null
+++ b/internal/backend/server/upstream/auth_test.go
@@ -0,0 +1,138 @@
+package upstream
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "cursor/gen/aiserverv1"
+ "cursor/internal/backend/server"
+
+ "google.golang.org/protobuf/proto"
+)
+
+func TestMockDevSessionTokenActionSupportsCursorDevLoginModes(t *testing.T) {
+ testCases := []struct {
+ name string
+ query string
+ plan string
+ trial bool
+ }{
+ {name: "default", query: "", plan: "ultra"},
+ {name: "free", query: "?plan=free", plan: "free"},
+ {name: "pro trial", query: "?plan=pro&trial=true", plan: "pro", trial: true},
+ {name: "pro", query: "?plan=pro", plan: "pro"},
+ {name: "pro plus trial", query: "?plan=pro_plus&trial=true", plan: "pro_plus", trial: true},
+ {name: "pro plus", query: "?plan=pro_plus", plan: "pro_plus"},
+ {name: "ultra", query: "?plan=ultra", plan: "ultra"},
+ {name: "enterprise", query: "?plan=enterprise", plan: "enterprise"},
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "http://local/auth/cursor_dev_session_token"+testCase.query, nil)
+ recorder := httptest.NewRecorder()
+ handler := MockDevSessionTokenAction(Dependencies{}, CompatRouteConfig{Name: "dev_login", StatusCode: http.StatusOK})
+ if err := handler(&server.Context{Writer: recorder, Request: request}); err != nil {
+ t.Fatalf("dev login handler: %v", err)
+ }
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
+ }
+
+ var response struct {
+ AccessToken string `json:"accessToken"`
+ RefreshToken string `json:"refreshToken"`
+ AuthID string `json:"authId"`
+ }
+ if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if response.AccessToken == "" || response.RefreshToken != response.AccessToken {
+ t.Fatalf("unexpected tokens: access=%q refresh=%q", response.AccessToken, response.RefreshToken)
+ }
+ claims, ok := localDevClaimsFromAuthorization("Bearer " + response.AccessToken)
+ if !ok {
+ t.Fatal("response access token is not a local dev JWT")
+ }
+ if claims.Plan != testCase.plan || claims.Trial != testCase.trial {
+ t.Fatalf("claims: got plan=%q trial=%v, want plan=%q trial=%v", claims.Plan, claims.Trial, testCase.plan, testCase.trial)
+ }
+ if response.AuthID != claims.Subject || claims.ExpiresAt <= time.Now().Unix() {
+ t.Fatalf("unexpected identity claims: response=%+v claims=%+v", response, claims)
+ }
+ })
+ }
+}
+
+func TestMockDevSessionTokenActionUsesRequestedEmail(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "http://local/auth/cursor_dev_session_token?plan=pro&email=dev%2Bcursor%40example.com", nil)
+ recorder := httptest.NewRecorder()
+ handler := MockDevSessionTokenAction(Dependencies{}, CompatRouteConfig{Name: "dev_login", StatusCode: http.StatusOK})
+ if err := handler(&server.Context{Writer: recorder, Request: request}); err != nil {
+ t.Fatalf("dev login handler: %v", err)
+ }
+
+ var response map[string]string
+ if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ claims, ok := localDevClaimsFromAuthorization(response["accessToken"])
+ if !ok || claims.Email != "dev+cursor@example.com" {
+ t.Fatalf("unexpected email claims: %+v", claims)
+ }
+}
+
+func TestMockDevSessionTokenActionRejectsUnsupportedOptions(t *testing.T) {
+ for _, query := range []string{"?plan=business", "?plan=ultra&trial=true", "?plan=pro&trial=maybe"} {
+ request := httptest.NewRequest(http.MethodGet, "http://local/auth/cursor_dev_session_token"+query, nil)
+ recorder := httptest.NewRecorder()
+ handler := MockDevSessionTokenAction(Dependencies{}, CompatRouteConfig{Name: "dev_login", StatusCode: http.StatusOK})
+ if err := handler(&server.Context{Writer: recorder, Request: request}); err != nil {
+ t.Fatalf("dev login handler for %q: %v", query, err)
+ }
+ if recorder.Code != http.StatusBadRequest {
+ t.Fatalf("status for %q: got %d, want %d", query, recorder.Code, http.StatusBadRequest)
+ }
+ }
+}
+
+func TestEnterpriseDevSessionProvidesBillableTeam(t *testing.T) {
+ token, _, err := buildLocalDevSessionToken("enterprise", false, "enterprise@example.com", time.Now())
+ if err != nil {
+ t.Fatalf("build token: %v", err)
+ }
+ reqCtx := authRequestContext(http.MethodPost, "/aiserver.v1.DashboardService/GetTeams", "", token)
+ payload, err := buildDashboardTeamsPayload(reqCtx)
+ if err != nil {
+ t.Fatalf("build teams: %v", err)
+ }
+ encoded, err := encodeMockProto("aiserver.v1.GetTeamsResponse", payload)
+ if err != nil {
+ t.Fatalf("encode teams: %v", err)
+ }
+ response := &aiserverv1.GetTeamsResponse{}
+ if err := proto.Unmarshal(encoded, response); err != nil {
+ t.Fatalf("decode teams: %v", err)
+ }
+ if len(response.Teams) != 1 || !response.Teams[0].GetHasBilling() || response.Teams[0].GetSeats() == 0 || !response.Teams[0].GetIsEnterprise() {
+ t.Fatalf("unexpected enterprise teams: %+v", response.Teams)
+ }
+}
+
+func authRequestContext(method string, path string, body string, token string) *RequestContext {
+ request := httptest.NewRequest(method, "http://local"+path, strings.NewReader(body))
+ if token != "" {
+ request.Header.Set("Authorization", "Bearer "+token)
+ }
+ return &RequestContext{
+ ResponseWriter: httptest.NewRecorder(),
+ Request: request,
+ Method: method,
+ Headers: request.Header.Clone(),
+ RequestBody: []byte(body),
+ }
+}
diff --git a/internal/backend/server/upstream/client.go b/internal/backend/server/upstream/client.go
index dfedcd8..bd36743 100644
--- a/internal/backend/server/upstream/client.go
+++ b/internal/backend/server/upstream/client.go
@@ -2,23 +2,18 @@ package upstream
import (
"bytes"
- "crypto/sha256"
- "encoding/base64"
"encoding/json"
"fmt"
"io"
- "math/big"
"net/http"
"net/url"
"strconv"
"strings"
- "time"
"cursor/gen/agentv1"
"cursor/gen/aiserverv1"
"cursor/internal/logger"
"cursor/internal/netproxy"
- legacyruntime "cursor/internal/runtime"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
@@ -87,14 +82,6 @@ func buildUpstreamRequest(reqCtx *RequestContext, body []byte, options ForwardOp
}
upstreamRequest.Host = reqCtx.TargetURL.Host
- if shouldRewriteHost(reqCtx.TargetURL.Hostname()) {
- auth := formatBearerAuthorization(legacyruntime.LocalRelayToken)
- if auth == "" {
- return nil, nil, legacyruntime.ErrInvalidSystemSetting
- }
- upstreamRequest.Header.Set("Authorization", auth)
- upstreamRequest.Header.Set("x-cursor-checksum", BuildCursorChecksum(auth))
- }
if options.PatchHeaders != nil {
options.PatchHeaders(upstreamRequest.Header)
}
@@ -167,61 +154,21 @@ func copyRequestHeadersForUpstream(target http.Header, source http.Header) {
}
func copyResponseHeadersToClient(target http.Header, source http.Header) {
+ localWildcardCORS := target.Get("Access-Control-Allow-Origin") == "*"
for key, values := range source {
lowerKey := strings.ToLower(key)
if _, exists := hopByHopHeaders[lowerKey]; exists {
continue
}
+ if localWildcardCORS && (lowerKey == "access-control-allow-origin" || lowerKey == "access-control-allow-credentials") {
+ continue
+ }
for _, value := range values {
target.Add(key, value)
}
}
}
-func shouldRewriteHost(host string) bool {
- normalized := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
- if normalized == "" {
- return false
- }
- return normalized == "cursor.sh" || strings.HasSuffix(normalized, ".cursor.sh")
-}
-
-func BuildCursorChecksum(authorization string) string {
- const (
- checksumTimestampDivisor = 1_000_000
- checksumInitialSeed = 165
- )
- timestamp := time.Now().UnixMilli() / checksumTimestampDivisor
- timestampBytes := make([]byte, 6)
- timestampBigInt := big.NewInt(timestamp)
- for index := 0; index < len(timestampBytes); index++ {
- shift := uint((len(timestampBytes) - 1 - index) * 8)
- timestampBytes[index] = byte(new(big.Int).Rsh(timestampBigInt, shift).Uint64() & 0xff)
- }
- seed := checksumInitialSeed
- for index := 0; index < len(timestampBytes); index++ {
- current := int(timestampBytes[index]^byte(seed)) + (index % 256)
- current &= 0xff
- timestampBytes[index] = byte(current)
- seed = current
- }
- prefix := strings.TrimRight(base64.StdEncoding.EncodeToString(timestampBytes), "=")
- hashBytes := sha256.Sum256([]byte(strings.TrimSpace(authorization)))
- hash := fmt.Sprintf("%x", hashBytes)
- return prefix + hash[:32]
-}
-
-func formatBearerAuthorization(raw string) string {
- value := strings.TrimSpace(raw)
- if value == "" {
- return ""
- }
- if strings.HasPrefix(strings.ToLower(value), "bearer ") {
- return value
- }
- return "Bearer " + value
-}
-
func shouldRequestCarryBody(method string) bool {
switch strings.ToUpper(strings.TrimSpace(method)) {
case http.MethodGet, http.MethodHead, http.MethodDelete:
@@ -238,17 +185,6 @@ func marshalJSONBody(payload map[string]any) ([]byte, error) {
return json.Marshal(payload)
}
-func handleMockJSON(reqCtx *RequestContext, route *Route) error {
- responseBody, err := marshalJSONBody(route.JSONBody)
- if err != nil {
- return err
- }
- reqCtx.ResponseWriter.Header().Set("content-type", "application/json")
- reqCtx.ResponseWriter.WriteHeader(route.StatusCode)
- _, _ = reqCtx.ResponseWriter.Write(responseBody)
- return nil
-}
-
func handleMockProto(reqCtx *RequestContext, route *Route) error {
payload := map[string]any{}
if route.MockPayloadBuilder != nil {
@@ -270,91 +206,6 @@ func handleMockProto(reqCtx *RequestContext, route *Route) error {
return nil
}
-func handleMockOAuth(reqCtx *RequestContext, route *Route) error {
- payload := struct {
- RefreshToken string `json:"refresh_token"`
- }{}
- _ = json.Unmarshal(reqCtx.RequestBody, &payload)
- responseBody, err := marshalJSONBody(map[string]any{
- "access_token": payload.RefreshToken,
- "id_token": payload.RefreshToken,
- "shouldLogout": false,
- })
- if err != nil {
- return err
- }
- reqCtx.ResponseWriter.Header().Set("content-type", "application/json")
- reqCtx.ResponseWriter.WriteHeader(http.StatusOK)
- _, _ = reqCtx.ResponseWriter.Write(responseBody)
- return nil
-}
-
-func handleMockAuthFullStripeProfile(reqCtx *RequestContext, route *Route) error {
- _ = route
- responseBody, err := marshalJSONBody(map[string]any{
- "membershipType": localUltraMembershipType,
- "subscriptionStatus": localUltraSubscriptionStatus,
- "lastPaymentFailed": false,
- "pendingCancellationDate": "",
- "daysRemainingOnTrial": 0,
- "paymentId": localUltraPaymentID,
- })
- if err != nil {
- return err
- }
- reqCtx.ResponseWriter.Header().Set("content-type", "application/json")
- reqCtx.ResponseWriter.WriteHeader(http.StatusOK)
- _, _ = reqCtx.ResponseWriter.Write(responseBody)
- return nil
-}
-
-func handleMockAuthStripeProfile(reqCtx *RequestContext, route *Route) error {
- _ = route
- responseBody, err := json.Marshal(localUltraPaymentID)
- if err != nil {
- return err
- }
- reqCtx.ResponseWriter.Header().Set("content-type", "application/json")
- reqCtx.ResponseWriter.WriteHeader(http.StatusOK)
- _, _ = reqCtx.ResponseWriter.Write(responseBody)
- return nil
-}
-
-func handleMockAuthPoll(reqCtx *RequestContext, route *Route) error {
- _ = route
- responseBody, err := marshalJSONBody(map[string]any{
- "accessToken": legacyruntime.InjectAuthToken,
- "refreshToken": legacyruntime.InjectAuthToken,
- "authId": "local_auth",
- })
- if err != nil {
- return err
- }
- reqCtx.ResponseWriter.Header().Set("content-type", "application/json")
- reqCtx.ResponseWriter.WriteHeader(http.StatusOK)
- _, _ = reqCtx.ResponseWriter.Write(responseBody)
- return nil
-}
-
-func handleMockAuthEmail(reqCtx *RequestContext, route *Route) error {
- _ = route
- responseBody := encodeAuthGetEmailResponse(legacyruntime.InjectAccountEmail)
- reqCtx.ResponseWriter.Header().Set("content-type", "application/proto")
- reqCtx.ResponseWriter.Header().Set("content-length", strconv.Itoa(len(responseBody)))
- reqCtx.ResponseWriter.WriteHeader(http.StatusOK)
- _, _ = reqCtx.ResponseWriter.Write(responseBody)
- return nil
-}
-
-func encodeAuthGetEmailResponse(email string) []byte {
- output := make([]byte, 0, len(email)+8)
- output = append(output, 0x0a)
- output = appendProtoVarint(output, uint64(len(email)))
- output = append(output, []byte(email)...)
- output = append(output, 0x10, 0x03) // GetEmailResponse.SignUpType.SIGN_UP_TYPE_GOOGLE
- return output
-}
-
func appendProtoVarint(output []byte, value uint64) []byte {
for value >= 0x80 {
output = append(output, byte(value)|0x80)
@@ -431,8 +282,6 @@ func newProtoMessage(typeName string) (proto.Message, error) {
return &aiserverv1.GetTeamAdminSettingsResponse{}, nil
case "aiserver.v1.GetTeamReposResponse":
return &aiserverv1.GetTeamReposResponse{}, nil
- case "aiserver.v1.ListMarketplacesResponse":
- return &aiserverv1.ListMarketplacesResponse{}, nil
case "aiserver.v1.GetUsableModelsResponse":
return &agentv1.GetUsableModelsResponse{}, nil
case "aiserver.v1.GetDefaultModelForCliResponse":
@@ -441,10 +290,6 @@ func newProtoMessage(typeName string) (proto.Message, error) {
return &aiserverv1.GetDefaultModelResponse{}, nil
case "aiserver.v1.GetGlobalCommandsResponse":
return &aiserverv1.GetGlobalCommandsResponse{}, nil
- case "aiserver.v1.GetEffectiveUserPluginsResponse":
- return &aiserverv1.GetEffectiveUserPluginsResponse{}, nil
- case "aiserver.v1.RegisterMarketplaceAndPluginsResponse":
- return &aiserverv1.RegisterMarketplaceAndPluginsResponse{}, nil
case "aiserver.v1.GetCliDownloadUrlResponse":
return &aiserverv1.GetCliDownloadUrlResponse{}, nil
case "aiserver.v1.SubmitLogsResponse":
diff --git a/internal/backend/server/upstream/fallback_action_test.go b/internal/backend/server/upstream/fallback_action_test.go
new file mode 100644
index 0000000..9695b66
--- /dev/null
+++ b/internal/backend/server/upstream/fallback_action_test.go
@@ -0,0 +1,147 @@
+package upstream
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "cursor/internal/backend/server"
+)
+
+type fallbackHTTPClientFunc func(*http.Request) (*http.Response, error)
+
+func (fn fallbackHTTPClientFunc) Do(request *http.Request) (*http.Response, error) {
+ return fn(request)
+}
+
+func TestFallbackForwardActionUsesOriginalMITMUpstreamURL(t *testing.T) {
+ originalURL := "https://api3.cursor.sh/aiserver.v1.UnknownService/Call?mode=exact"
+ parsedURL, err := url.Parse(originalURL)
+ if err != nil {
+ t.Fatalf("parse original URL: %v", err)
+ }
+
+ client := fallbackHTTPClientFunc(func(request *http.Request) (*http.Response, error) {
+ if got := request.URL.String(); got != originalURL {
+ t.Fatalf("upstream URL: got %q, want %q", got, originalURL)
+ }
+ if got := request.Method; got != http.MethodPost {
+ t.Fatalf("method: got %q, want POST", got)
+ }
+ body, readErr := io.ReadAll(request.Body)
+ if readErr != nil {
+ t.Fatalf("read request body: %v", readErr)
+ }
+ if got := string(body); got != "request-body" {
+ t.Fatalf("body: got %q", got)
+ }
+ if got := request.Header.Get("X-Test-Header"); got != "preserved" {
+ t.Fatalf("custom header: got %q", got)
+ }
+ if got := request.Header.Get(server.HeaderServerUpstreamURL); got != "" {
+ t.Fatalf("internal upstream header leaked: %q", got)
+ }
+ return &http.Response{
+ StatusCode: http.StatusAccepted,
+ Status: "202 Accepted",
+ Header: http.Header{"X-Upstream-Response": []string{"preserved"}},
+ Body: io.NopCloser(strings.NewReader("upstream-body")),
+ }, nil
+ })
+
+ request := httptest.NewRequest(http.MethodPost, "http://localhost:8000/ignored", strings.NewReader("request-body"))
+ request.Header.Set("X-Test-Header", "preserved")
+ request.Header.Set(server.HeaderServerUpstreamURL, originalURL)
+ recorder := httptest.NewRecorder()
+ ctx := &server.Context{Writer: recorder, Request: request, UpstreamURL: parsedURL}
+ action := FallbackForwardAction(Dependencies{HTTPClient: client}, CompatRouteConfig{Name: "fallback"}, DefaultCursorUpstreamBaseURL)
+
+ if err := action(ctx); err != nil {
+ t.Fatalf("forward fallback request: %v", err)
+ }
+ if got := recorder.Code; got != http.StatusAccepted {
+ t.Fatalf("response status: got %d, want %d", got, http.StatusAccepted)
+ }
+ if got := recorder.Header().Get("X-Upstream-Response"); got != "preserved" {
+ t.Fatalf("response header: got %q", got)
+ }
+ if got := recorder.Body.String(); got != "upstream-body" {
+ t.Fatalf("response body: got %q", got)
+ }
+}
+
+func TestFallbackForwardActionUsesDefaultUpstreamForNativeRequest(t *testing.T) {
+ const defaultBaseURL = "https://fallback.example:8443"
+ wantURL := defaultBaseURL + "/aiserver.v1.UnknownService/Call?mode=native"
+ client := fallbackHTTPClientFunc(func(request *http.Request) (*http.Response, error) {
+ if got := request.URL.String(); got != wantURL {
+ t.Fatalf("upstream URL: got %q, want %q", got, wantURL)
+ }
+ return &http.Response{
+ StatusCode: http.StatusNoContent,
+ Status: "204 No Content",
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("")),
+ }, nil
+ })
+
+ request := httptest.NewRequest(http.MethodGet, "http://localhost:8000/aiserver.v1.UnknownService/Call?mode=native", nil)
+ recorder := httptest.NewRecorder()
+ ctx := &server.Context{Writer: recorder, Request: request}
+ action := FallbackForwardAction(Dependencies{HTTPClient: client}, CompatRouteConfig{Name: "fallback"}, defaultBaseURL)
+
+ if err := action(ctx); err != nil {
+ t.Fatalf("forward fallback request: %v", err)
+ }
+ if got := recorder.Code; got != http.StatusNoContent {
+ t.Fatalf("response status: got %d, want %d", got, http.StatusNoContent)
+ }
+}
+
+func TestFallbackForwardActionPreservesAuthorization(t *testing.T) {
+ const (
+ originalURL = "https://api2.cursor.sh/aiserver.v1.AuthService/GetEmail"
+ officialAuthorization = "Bearer official-access-token"
+ officialChecksum = "official-checksum"
+ )
+ parsedURL, err := url.Parse(originalURL)
+ if err != nil {
+ t.Fatalf("parse original URL: %v", err)
+ }
+
+ client := fallbackHTTPClientFunc(func(request *http.Request) (*http.Response, error) {
+ if got := request.Header.Get("Authorization"); got != officialAuthorization {
+ t.Fatalf("authorization: got %q, want %q", got, officialAuthorization)
+ }
+ if got := request.Header.Get("x-cursor-checksum"); got != officialChecksum {
+ t.Fatalf("checksum: got %q, want %q", got, officialChecksum)
+ }
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Status: "200 OK",
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("upstream-account")),
+ }, nil
+ })
+
+ request := httptest.NewRequest(http.MethodPost, "http://localhost:8000/aiserver.v1.AuthService/GetEmail", nil)
+ request.Header.Set("Authorization", officialAuthorization)
+ request.Header.Set("x-cursor-checksum", officialChecksum)
+ recorder := httptest.NewRecorder()
+ ctx := &server.Context{Writer: recorder, Request: request, UpstreamURL: parsedURL}
+ action := FallbackForwardAction(
+ Dependencies{HTTPClient: client},
+ CompatRouteConfig{Name: "fallback"},
+ DefaultCursorUpstreamBaseURL,
+ )
+
+ if err := action(ctx); err != nil {
+ t.Fatalf("forward authenticated fallback request: %v", err)
+ }
+ if got := recorder.Body.String(); got != "upstream-account" {
+ t.Fatalf("response body: got %q, want upstream-account", got)
+ }
+}
diff --git a/internal/backend/server/upstream/mocks.go b/internal/backend/server/upstream/mocks.go
index f20b14b..d138834 100644
--- a/internal/backend/server/upstream/mocks.go
+++ b/internal/backend/server/upstream/mocks.go
@@ -24,9 +24,7 @@ const (
// файловых инструментов падают с "[unimplemented] HTTP 404".
localPathEncryptionKey = "6f6e63652d6c6f63616c2d706174682d656e6372797074696f6e2d6b6579"
- localUltraMembershipType = "ultra"
localUltraPaymentID = "local_ultra"
- localUltraSubscriptionStatus = "active"
localUltraPlanIncludedCents = 20000
localUltraDashboardUserID = 1
localUltraBillingCycleDuration = 30 * 24 * time.Hour
@@ -431,7 +429,8 @@ func buildServerTimePayload(*RequestContext) (map[string]any, error) {
func buildServerConfigPayload(*RequestContext) (map[string]any, error) {
return map[string]any{
- "configVersion": "local_cli_sandbox_defaults_disabled_v2",
+ "configVersion": "local_cli_sandbox_defaults_disabled_v2",
+ "isDevDoNotUseForSecretThingsBecauseCanBeSpoofedByUsers": true,
"http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED",
"cliSandboxDefaultEnabled": true,
"indexingConfig": map[string]any{
@@ -547,26 +546,29 @@ func buildFirstWindowStatsigDecisionPayload(*RequestContext) (map[string]any, er
}, nil
}
-func buildDashboardCurrentPeriodUsagePayload(*RequestContext) (map[string]any, error) {
+func buildDashboardCurrentPeriodUsagePayload(reqCtx *RequestContext) (map[string]any, error) {
+ plan := localDevPlanFromRequest(reqCtx)
+ planName, includedSpend := localDevPlanDetails(plan)
billingCycleStart := time.Now().Add(-localUltraBillingCycleDuration).UnixMilli()
billingCycleEnd := time.Now().Add(10 * 365 * 24 * time.Hour).UnixMilli()
+ displayMessage := planName + " active"
return map[string]any{
- "autoModelSelectedDisplayMessage": "Ultra plan active",
+ "autoModelSelectedDisplayMessage": displayMessage,
"billingCycleEnd": billingCycleEnd,
"billingCycleStart": billingCycleStart,
- "displayMessage": "Ultra plan active",
+ "displayMessage": displayMessage,
"displayThreshold": 99999999,
"enabled": true,
- "namedModelSelectedDisplayMessage": "Ultra plan active",
+ "namedModelSelectedDisplayMessage": displayMessage,
"planUsage": map[string]any{
"apiPercentUsed": 0,
"apiSpend": 0,
"autoPercentUsed": 0,
"autoSpend": 0,
- "bonusTooltip": "Ultra local account mock is active.",
- "includedSpend": localUltraPlanIncludedCents,
- "limit": localUltraPlanIncludedCents,
- "remaining": localUltraPlanIncludedCents,
+ "bonusTooltip": "Local account mock is active.",
+ "includedSpend": includedSpend,
+ "limit": includedSpend,
+ "remaining": includedSpend,
"remainingBonus": false,
"totalPercentUsed": 0,
"totalSpend": 0,
@@ -577,62 +579,45 @@ func buildDashboardCurrentPeriodUsagePayload(*RequestContext) (map[string]any, e
}, nil
}
-func buildDashboardTeamsPayload(*RequestContext) (map[string]any, error) {
+func buildDashboardTeamsPayload(reqCtx *RequestContext) (map[string]any, error) {
+ if claims, ok := localDevClaimsFromRequest(reqCtx); ok && claims.Plan == "enterprise" {
+ return map[string]any{
+ "teams": []map[string]any{{
+ "name": "Local Enterprise",
+ "id": 1,
+ "seats": 1,
+ "hasBilling": true,
+ "subscriptionStatus": localDevSubscriptionActive,
+ "verified": true,
+ "isEnterprise": true,
+ "membershipType": "enterprise",
+ }},
+ }, nil
+ }
return map[string]any{
"teams": []map[string]any{},
}, nil
}
-func buildDashboardManagedSkillsPayload(*RequestContext) (map[string]any, error) {
- return map[string]any{
- "skills": []map[string]any{},
- }, nil
-}
-
-func buildDashboardGetMePayload(reqCtx *RequestContext) (map[string]any, error) {
- authID := ""
- if reqCtx != nil {
- authID = authIDFromBearer(reqCtx.Headers.Get("authorization"))
+func buildDashboardPlanInfoPayload(reqCtx *RequestContext) (map[string]any, error) {
+ plan := localDevPlanFromRequest(reqCtx)
+ planName, includedAmountCents := localDevPlanDetails(plan)
+ price := "$200/mo"
+ switch plan {
+ case "free":
+ price = "$0/mo"
+ case "pro":
+ price = "$20/mo"
+ case "pro_plus":
+ price = "$60/mo"
+ case "enterprise":
+ price = "Custom"
}
- if authID == "" {
- authID = authIDFromJWT(legacyruntime.InjectAuthToken)
- }
- if authID == "" {
- authID = localUltraPaymentID
- }
-
- return map[string]any{
- "authId": authID,
- "userId": localUltraDashboardUserID,
- "email": legacyruntime.InjectAccountEmail,
- "firstName": "Cursor",
- "lastName": "Local",
- "createdAt": time.Now().UTC().Format(time.RFC3339),
- "isEnterpriseUser": false,
- "teamName": "",
- "emailDomainType": "personal",
- "country": "US",
- "profilePictureUrl": "",
- }, nil
-}
-
-func buildDashboardUserPrivacyModePayload(*RequestContext) (map[string]any, error) {
- return map[string]any{
- "privacyMode": "PRIVACY_MODE_NO_STORAGE",
- "hoursRemainingInGracePeriod": 0,
- "isEnforcedByTeam": false,
- "isNotMigratedToServerSourceOfTruth": false,
- "partnerDataShare": false,
- "hasAcknowledgedGracePeriodDisclaimer": true,
- }, nil
-}
-
-func buildDashboardPlanInfoPayload(*RequestContext) (map[string]any, error) {
return map[string]any{
"planInfo": map[string]any{
- "planName": "Ultra Plan",
- "includedAmountCents": localUltraPlanIncludedCents,
- "price": "$200/mo",
+ "planName": planName,
+ "includedAmountCents": includedAmountCents,
+ "price": price,
"billingCycleEnd": time.Now().Add(10 * 365 * 24 * time.Hour).UnixMilli(),
},
}, nil
diff --git a/internal/backend/server/upstream/mocks_test.go b/internal/backend/server/upstream/mocks_test.go
index 921e09b..21bf000 100644
--- a/internal/backend/server/upstream/mocks_test.go
+++ b/internal/backend/server/upstream/mocks_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"cursor/gen/agentv1"
+ "cursor/gen/aiserverv1"
legacyruntime "cursor/internal/runtime"
"google.golang.org/protobuf/proto"
@@ -54,6 +55,26 @@ func TestEncodeCLIModelsUsesAgentModelDetailsWireFormat(t *testing.T) {
}
}
+func TestBuildServerConfigEnablesDevUserBackendCommands(t *testing.T) {
+ payload, err := buildServerConfigPayload(nil)
+ if err != nil {
+ t.Fatalf("build server config: %v", err)
+ }
+
+ encoded, err := encodeMockProto("aiserver.v1.GetServerConfigResponse", payload)
+ if err != nil {
+ t.Fatalf("encode server config: %v", err)
+ }
+
+ response := &aiserverv1.GetServerConfigResponse{}
+ if err := proto.Unmarshal(encoded, response); err != nil {
+ t.Fatalf("decode server config: %v", err)
+ }
+ if !response.GetIsDevDoNotUseForSecretThingsBecauseCanBeSpoofedByUsers() {
+ t.Fatal("expected server config to enable dev-user backend commands")
+ }
+}
+
func TestBuildBootstrapStatsigConfigJSONDisablesAlwaysLocalDecompositionGate(t *testing.T) {
payload, err := buildBootstrapStatsigConfigJSON(12345, "test-auth-id")
if err != nil {
diff --git a/internal/backend/server/upstream/types.go b/internal/backend/server/upstream/types.go
index 458a9f1..e40706d 100644
--- a/internal/backend/server/upstream/types.go
+++ b/internal/backend/server/upstream/types.go
@@ -20,13 +20,6 @@ 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)
}
@@ -91,7 +84,6 @@ type Route struct {
Matcher Matcher
ConsoleLog bool
StatusCode int
- JSONBody map[string]any
MockProtoType string
MockPayloadBuilder func(*RequestContext) (map[string]any, error)
Handler RouteHandler
diff --git a/internal/bridge/proxy.go b/internal/bridge/proxy.go
index 75414f8..e5fed2c 100644
--- a/internal/bridge/proxy.go
+++ b/internal/bridge/proxy.go
@@ -30,9 +30,6 @@ type ModelAdapterModelsRequest = client.ModelAdapterModelsRequest
// ModelAdapterModelsResult 定义模型列表查询结果。
type ModelAdapterModelsResult = client.ModelAdapterModelsResult
-// CursorAccountStatus 是可安全展示给桌面前端的独立 Cursor 账号状态。
-type CursorAccountStatus = client.CursorAccountStatus
-
// LicenseActionRequest 定义了当前模块中的 LicenseActionRequest 类型。
type LicenseActionRequest = client.LicenseActionRequest
@@ -100,21 +97,6 @@ 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
deleted file mode 100644
index c384445..0000000
--- a/internal/client/cursor_account.go
+++ /dev/null
@@ -1,35 +0,0 @@
-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 2becde4..91c090c 100644
--- a/internal/client/lifecycle.go
+++ b/internal/client/lifecycle.go
@@ -11,7 +11,6 @@ import (
"cursor/internal/logger"
"cursor/internal/mitm"
"cursor/internal/netproxy"
- localruntime "cursor/internal/runtime"
"github.com/wailsapp/wails/v3/pkg/application"
)
@@ -85,11 +84,8 @@ func (s *ProxyService) StartProxy() (ProxyState, error) {
if err := s.ensureProxy(cfg); err != nil {
return fail("ensure_proxy", err)
}
-
- // 启动时注入账号信息
- if err := cursor.InjectCursorUserInfo(localruntime.InjectAccountEmail, localruntime.InjectAuthToken); err != nil {
- logger.Errorf("injectCursorUserInfo failed: %v", err)
- // 不阻断启动,仅记录日志
+ if err := cursor.DisableCursorStatsigGates(); err != nil {
+ logger.Errorf("disableCursorStatsigGates failed: %v", err)
}
if s.proxy != nil && !s.proxy.IsRunning() {
@@ -265,9 +261,6 @@ 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 125bca9..225d46d 100644
--- a/internal/client/service.go
+++ b/internal/client/service.go
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net/http"
- "path/filepath"
"sync"
"time"
@@ -12,7 +11,6 @@ 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"
@@ -37,8 +35,6 @@ type ProxyService struct {
certManager *certs.Manager
// backendHost 表示当前嵌入式 backend 服务。
backendHost *backend.Host
- // cursorAccount 持有仅供插件、Skills 和 MCP 控制面使用的真实 Cursor 身份。
- cursorAccount *cursoraccount.Manager
// mu 表示当前声明中的 mu。
mu sync.RWMutex
@@ -88,12 +84,8 @@ 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, service.cursorAccount)
+ host, err := service.newBackendHost()
if err != nil {
logger.Errorf("init backend host failed: %v", err)
} else {
@@ -109,7 +101,7 @@ func (s *ProxyService) ensureBackendHost() error {
if s.backendHost != nil {
return nil
}
- host, err := backend.NewHost(s.store, s.cursorAccount)
+ host, err := s.newBackendHost()
if err != nil {
return err
}
@@ -117,6 +109,18 @@ func (s *ProxyService) ensureBackendHost() error {
return nil
}
+func (s *ProxyService) newBackendHost() (*backend.Host, error) {
+ options := []backend.HostOption{}
+ if s != nil && s.certManager != nil {
+ certificate, err := s.certManager.CertificateForServerName("localhost")
+ if err != nil {
+ return nil, fmt.Errorf("create localhost backend certificate: %w", err)
+ }
+ options = append(options, backend.WithTLSCertificate(certificate))
+ }
+ return backend.NewHost(s.store, options...)
+}
+
func (s *ProxyService) ensureProxy(cfg serverconfig.Config) error {
if s == nil {
return nil
diff --git a/internal/cursor/state_db.go b/internal/cursor/state_db.go
index 940c0f1..5de9697 100644
--- a/internal/cursor/state_db.go
+++ b/internal/cursor/state_db.go
@@ -60,6 +60,23 @@ func InjectCursorUserInfo(email, token string) error {
return nil
}
+// DisableCursorStatsigGates preserves the local-mode feature gates without
+// injecting or replacing Cursor account state.
+func DisableCursorStatsigGates() error {
+ stateDBPath, err := resolveCursorStateDBPath()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(stateDBPath), 0o755); err != nil {
+ return fmt.Errorf("创建 Cursor 状态目录失败: %w", err)
+ }
+ if err := disableCursorStatsigGatesInDB(stateDBPath); err != nil {
+ return fmt.Errorf("同步 Cursor Statsig gates 失败 path=%s: %w", stateDBPath, err)
+ }
+ logger.Infof("disableCursorStatsigGates synced path=%s gates=%s", stateDBPath, strings.Join(cursorStateDisabledStatsigGates, ","))
+ return nil
+}
+
func buildCursorAuthStateValues(email, token string) map[string]string {
email = strings.TrimSpace(email)
token = strings.TrimSpace(token)
@@ -131,6 +148,44 @@ func syncCursorAuthStateDB(path string, values map[string]string) error {
return nil
}
+func disableCursorStatsigGatesInDB(path string) error {
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ return err
+ }
+ defer db.Close()
+ db.SetMaxOpenConns(1)
+ db.SetMaxIdleConns(1)
+
+ ctx := context.Background()
+ if _, err := db.ExecContext(ctx, fmt.Sprintf("PRAGMA busy_timeout = %d", cursorStateSQLiteBusyTimeoutMS)); err != nil {
+ return err
+ }
+ if _, err := db.ExecContext(ctx, "CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil {
+ return err
+ }
+
+ tx, err := db.BeginTx(ctx, &sql.TxOptions{})
+ if err != nil {
+ return err
+ }
+ committed := false
+ defer func() {
+ if !committed {
+ _ = tx.Rollback()
+ }
+ }()
+
+ if err := disableCursorStatsigGates(ctx, tx); err != nil {
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+ committed = true
+ return nil
+}
+
func disableCursorStatsigGates(ctx context.Context, tx *sql.Tx) error {
var raw []byte
err := tx.QueryRowContext(ctx, "SELECT value FROM ItemTable WHERE key = ?", cursorStateStatsigBootstrapKey).Scan(&raw)
diff --git a/internal/cursor/state_db_test.go b/internal/cursor/state_db_test.go
index 040b917..8de6ffa 100644
--- a/internal/cursor/state_db_test.go
+++ b/internal/cursor/state_db_test.go
@@ -59,6 +59,55 @@ func TestSyncCursorAuthStateDBDisablesCachedTerminalOutputUIStreamingIdempotentl
}
}
+func TestDisableCursorStatsigGatesInDBDoesNotInjectAuthState(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.vscdb")
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatalf("open temporary state db: %v", err)
+ }
+ if _, err := db.Exec("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil {
+ db.Close()
+ t.Fatalf("create ItemTable: %v", err)
+ }
+ bootstrap := map[string]any{
+ "feature_gates": map[string]any{},
+ "hash_used": "none",
+ }
+ raw, err := json.Marshal(bootstrap)
+ if err != nil {
+ db.Close()
+ t.Fatalf("encode bootstrap: %v", err)
+ }
+ if _, err := db.Exec("INSERT INTO ItemTable(key, value) VALUES(?, ?)", cursorStateStatsigBootstrapKey, raw); err != nil {
+ db.Close()
+ t.Fatalf("insert bootstrap: %v", err)
+ }
+ if err := db.Close(); err != nil {
+ t.Fatalf("close setup db: %v", err)
+ }
+
+ if err := disableCursorStatsigGatesInDB(path); err != nil {
+ t.Fatalf("disable statsig gates: %v", err)
+ }
+ updated := readCursorStatsigBootstrapForTest(t, path)
+ for _, gate := range cursorStateDisabledStatsigGates {
+ assertCursorStatsigGateValueForTest(t, updated, gate, false)
+ }
+
+ db, err = sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatalf("reopen state db: %v", err)
+ }
+ defer db.Close()
+ var authKeyCount int
+ if err := db.QueryRow("SELECT COUNT(*) FROM ItemTable WHERE key LIKE 'cursorAuth/%'").Scan(&authKeyCount); err != nil {
+ t.Fatalf("count auth keys: %v", err)
+ }
+ if authKeyCount != 0 {
+ t.Fatalf("statsig sync injected %d auth keys", authKeyCount)
+ }
+}
+
func readCursorStatsigBootstrapForTest(t *testing.T, path string) []byte {
t.Helper()
db, err := sql.Open("sqlite", path)
diff --git a/internal/cursoraccount/manager.go b/internal/cursoraccount/manager.go
deleted file mode 100644
index 2191ac2..0000000
--- a/internal/cursoraccount/manager.go
+++ /dev/null
@@ -1,589 +0,0 @@
-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")
-}
diff --git a/internal/mitm/service.go b/internal/mitm/service.go
index b7fefda..901fc4e 100644
--- a/internal/mitm/service.go
+++ b/internal/mitm/service.go
@@ -184,6 +184,19 @@ func NewProxyServer(addr, baseURL, _ string, _ string, certManager *certs.Manage
return nil, err
}
+ tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
+ if certManager != nil {
+ caCertificate, err := certManager.CATLSCertificate()
+ if err != nil {
+ return nil, fmt.Errorf("load proxy backend CA: %w", err)
+ }
+ roots := x509.NewCertPool()
+ if caCertificate.Leaf != nil {
+ roots.AddCert(caCertificate.Leaf)
+ }
+ tlsConfig.RootCAs = roots
+ }
+
s := &ProxyServer{
addr: addr,
baseURL: normalizedBaseURL,
@@ -198,6 +211,7 @@ func NewProxyServer(addr, baseURL, _ string, _ string, certManager *certs.Manage
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 60 * time.Second,
+ TLSClientConfig: tlsConfig,
},
},
}