mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
refactor: remove CursorAccountCard component and related localization entries
- Deleted the CursorAccountCard.vue component, which handled user account login and status. - Removed associated localization entries from catalog.json and various language files. - Updated Home.vue to eliminate references to the removed component. - Refactored clientApi.js to remove unused account-related API functions.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+119
-264
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user