diff --git a/internal/backend/host.go b/internal/backend/host.go index 974153f..3c84d53 100644 --- a/internal/backend/host.go +++ b/internal/backend/host.go @@ -319,6 +319,16 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { MockBuilder: upstream.ServerConfigMockBuilder, })), ), + server.POST("/aiserver.v1.ServerConfigService/GetServerConfig", + server.Name("server_config_service_get_server_config"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "server_config_service_get_server_config", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetServerConfigResponse", + MockBuilder: upstream.ServerConfigMockBuilder, + })), + ), server.POST("/aiserver.v1.AiService/AvailableModels", server.Name("available_models"), server.ConnectUnary(), @@ -329,6 +339,36 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { MockBuilder: upstream.AvailableModelsMockBuilder, })), ), + server.POST("/aiserver.v1.AiService/GetUsableModels", + server.Name("usable_models"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "usable_models", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetUsableModelsResponse", + MockBuilder: upstream.UsableModelsMockBuilder, + })), + ), + server.POST("/aiserver.v1.AiService/GetDefaultModelForCli", + server.Name("default_model_for_cli"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "default_model_for_cli", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetDefaultModelForCliResponse", + MockBuilder: upstream.DefaultModelForCliMockBuilder, + })), + ), + server.POST("/aiserver.v1.AiService/GetDefaultModel", + server.Name("default_model"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "default_model", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetDefaultModelResponse", + MockBuilder: upstream.DefaultModelMockBuilder, + })), + ), server.POST("/aiserver.v1.AiService/GetDefaultModelNudgeData", server.Name("default_model_nudge"), server.ConnectUnary(), @@ -359,6 +399,34 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { MockBuilder: upstream.FirstWindowStatsigDecisionMockBuilder, })), ), + server.POST("/aiserver.v1.AnalyticsService/SubmitLogs", + server.Name("analytics_submit_logs"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "analytics_submit_logs", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.SubmitLogsResponse", + MockBuilder: upstream.SubmitLogsMockBuilder, + })), + ), + server.POST("/aiserver.v1.AnalyticsService/TrackEvents", + server.Name("analytics_track_events"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "analytics_track_events", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.TrackEventsResponse", + MockBuilder: upstream.EmptyMockBuilder, + })), + ), + server.POST("/v1/traces", + server.Name("otlp_traces"), + server.HTTP(), + server.Local(upstream.FixedStatusAction(routeDeps, upstream.CompatRouteConfig{ + Name: "otlp_traces", + StatusCode: http.StatusOK, + })), + ), server.POST("/oauth/token", server.Name("oauth_token"), server.HTTP(), @@ -477,6 +545,76 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { }), )), ), + server.POST("/aiserver.v1.DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam", + server.Name("dashboard_get_team_admin_settings_or_empty"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_get_team_admin_settings_or_empty", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetTeamAdminSettingsResponse", + MockBuilder: upstream.EmptyMockBuilder, + })), + ), + server.POST("/aiserver.v1.DashboardService/GetTeamReposOrEmptyIfNotInTeam", + server.Name("dashboard_get_team_repos_or_empty"), + server.ConnectUnary(), + server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ + Name: "dashboard_get_team_repos_or_empty", + StatusCode: http.StatusOK, + MockProtoType: "aiserver.v1.GetTeamReposResponse", + 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(), diff --git a/internal/backend/server/upstream/action.go b/internal/backend/server/upstream/action.go index 2f090d3..70614cf 100644 --- a/internal/backend/server/upstream/action.go +++ b/internal/backend/server/upstream/action.go @@ -193,6 +193,18 @@ func DefaultModelNudgeMockBuilder(reqCtx *RequestContext) (map[string]any, error return buildDefaultModelNudgeDataPayload(reqCtx) } +func UsableModelsMockBuilder(reqCtx *RequestContext) (map[string]any, error) { + return buildUsableModelsPayload(reqCtx) +} + +func DefaultModelForCliMockBuilder(reqCtx *RequestContext) (map[string]any, error) { + return buildDefaultModelForCliPayload(reqCtx) +} + +func DefaultModelMockBuilder(reqCtx *RequestContext) (map[string]any, error) { + return buildDefaultModelPayload(reqCtx) +} + func BootstrapStatsigMockBuilder(reqCtx *RequestContext) (map[string]any, error) { return buildBootstrapStatsigPayload(reqCtx) } @@ -213,6 +225,18 @@ func DashboardManagedSkillsMockBuilder(reqCtx *RequestContext) (map[string]any, return buildDashboardManagedSkillsPayload(reqCtx) } +// EmptyMockBuilder возвращает пустой proto-ответ для ручек, где клиенту +// достаточно успешного "пусто": нет team-настроек, нет репозиториев, +// нет маркетплейсов/плагинов/команд, телеметрия принята без обработки. +func EmptyMockBuilder(reqCtx *RequestContext) (map[string]any, error) { + return map[string]any{}, nil +} + +// SubmitLogsMockBuilder подтверждает приём логов телеметрии без обработки. +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) } diff --git a/internal/backend/server/upstream/client.go b/internal/backend/server/upstream/client.go index 552dd76..dfedcd8 100644 --- a/internal/backend/server/upstream/client.go +++ b/internal/backend/server/upstream/client.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "cursor/gen/agentv1" "cursor/gen/aiserverv1" "cursor/internal/logger" "cursor/internal/netproxy" @@ -426,6 +427,30 @@ func newProtoMessage(typeName string) (proto.Message, error) { return &aiserverv1.GetUsageLimitStatusAndActiveGrantsResponse{}, nil case "aiserver.v1.IsOnNewPricingResponse": return &aiserverv1.IsOnNewPricingResponse{}, nil + case "aiserver.v1.GetTeamAdminSettingsResponse": + 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": + return &agentv1.GetDefaultModelForCliResponse{}, nil + case "aiserver.v1.GetDefaultModelResponse": + 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": + return &aiserverv1.SubmitLogsResponse{}, nil + case "aiserver.v1.TrackEventsResponse": + return &aiserverv1.TrackEventsResponse{}, nil default: return nil, fmt.Errorf("unsupported proto message type %q", typeName) } diff --git a/internal/backend/server/upstream/mocks.go b/internal/backend/server/upstream/mocks.go index c33dcfc..cc61898 100644 --- a/internal/backend/server/upstream/mocks.go +++ b/internal/backend/server/upstream/mocks.go @@ -17,6 +17,13 @@ const ( modelRuntimeThinkingEffortParameterID = "thinking_effort" + // localPathEncryptionKey — стабильный ключ шифрования путей для индексации + // репозитория, который cursor-agent CLI запрашивает через GetServerConfig + // (indexingConfig.default{User,Team}PathEncryptionKey). Без него CLI + // не может инициализировать repo identity в git-воркспейсе и вызовы + // файловых инструментов падают с "[unimplemented] HTTP 404". + localPathEncryptionKey = "6f6e63652d6c6f63616c2d706174682d656e6372797074696f6e2d6b6579" + localUltraMembershipType = "ultra" localUltraPaymentID = "local_ultra" localUltraSubscriptionStatus = "active" @@ -424,9 +431,13 @@ 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", - // "http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED", + "configVersion": "local_cli_sandbox_defaults_disabled_v2", + "http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED", "cliSandboxDefaultEnabled": true, + "indexingConfig": map[string]any{ + "defaultUserPathEncryptionKey": localPathEncryptionKey, + "defaultTeamPathEncryptionKey": localPathEncryptionKey, + }, }, nil } @@ -440,6 +451,7 @@ func buildAvailableModelsPayload(reqCtx *RequestContext) (map[string]any, error) if len(modelRefs) > 0 { defaultModel = modelRefs[0] } + modelEntries := buildAvailableModelEntries(adapters) return map[string]any{ "backgroundComposerModelConfig": map[string]any{ "bestOfNDefaultModels": append([]string(nil), modelRefs...), @@ -459,7 +471,7 @@ func buildAvailableModelsPayload(reqCtx *RequestContext) (map[string]any, error) "defaultModel": defaultModel, }, "disableUnusedModelsAfterNHours": availableModelsDisableUnusedHours, - "models": buildAvailableModelEntries(adapters), + "models": modelEntries, "planExecutionModelConfig": map[string]any{ "defaultModel": defaultModel, "fallbackModels": append([]string(nil), modelRefs...), @@ -486,6 +498,35 @@ func buildDefaultModelNudgeDataPayload(reqCtx *RequestContext) (map[string]any, }, nil } +func buildUsableModelsPayload(reqCtx *RequestContext) (map[string]any, error) { + adapters, err := loadConfiguredModelAdapters(reqCtx) + if err != nil { + return nil, err + } + return map[string]any{"models": buildCLIModelDetails(adapters)}, nil +} + +func buildDefaultModelForCliPayload(reqCtx *RequestContext) (map[string]any, error) { + adapters, err := loadConfiguredModelAdapters(reqCtx) + if err != nil { + return nil, err + } + models := buildCLIModelDetails(adapters) + if len(models) == 0 { + return map[string]any{"model": map[string]any{}}, nil + } + return map[string]any{"model": models[0]}, nil +} + +func buildDefaultModelPayload(reqCtx *RequestContext) (map[string]any, error) { + adapters, err := loadConfiguredModelAdapters(reqCtx) + if err != nil { + return nil, err + } + defaultModel := firstModelAdapterRef(adapters) + return map[string]any{"model": defaultModel, "thinkingModel": defaultModel}, nil +} + func buildBootstrapStatsigPayload(reqCtx *RequestContext) (map[string]any, error) { generatedAtMs := uint64(time.Now().UnixMilli()) authID := resolveBootstrapStatsigAuthID(reqCtx) @@ -679,6 +720,25 @@ func buildAvailableModelEntries(adapters []legacyruntime.ModelAdapterConfig) []m return output } +func buildCLIModelDetails(adapters []legacyruntime.ModelAdapterConfig) []map[string]any { + models := make([]map[string]any, 0, len(adapters)) + for _, adapter := range adapters { + channelID := strings.TrimSpace(adapter.ID) + if channelID == "" { + continue + } + models = append(models, map[string]any{ + "modelId": channelID, + "displayModelId": channelID, + "apiKeyCredentials": map[string]any{ + "apiKey": strings.TrimSpace(adapter.APIKey), + "baseUrl": strings.TrimSpace(adapter.BaseURL), + }, + }) + } + return models +} + func buildThinkingEffortParameterDefinitions(adapterType string) []map[string]any { values := thinkingEffortValuesForAdapter(adapterType) options := make([]map[string]any, 0, len(values)) @@ -823,6 +883,16 @@ func collectModelAdapterRefs(adapters []legacyruntime.ModelAdapterConfig) []stri return output } +// firstModelAdapterRef возвращает канал первого адаптера или пустую строку, +// если ни один адаптер не сконфигурирован. +func firstModelAdapterRef(adapters []legacyruntime.ModelAdapterConfig) string { + refs := collectModelAdapterRefs(adapters) + if len(refs) == 0 { + return "" + } + return refs[0] +} + func resolveBootstrapStatsigAuthID(reqCtx *RequestContext) string { if reqCtx != nil { if authID := authIDFromBearer(reqCtx.Headers.Get("authorization")); authID != "" { diff --git a/internal/backend/server/upstream/mocks_test.go b/internal/backend/server/upstream/mocks_test.go index 5c878b9..48c4fa6 100644 --- a/internal/backend/server/upstream/mocks_test.go +++ b/internal/backend/server/upstream/mocks_test.go @@ -2,9 +2,55 @@ package upstream import ( "encoding/json" + "reflect" "testing" + + "cursor/gen/agentv1" + legacyruntime "cursor/internal/runtime" + + "google.golang.org/protobuf/proto" ) +func TestBuildCLIModelDetailsPreservesChannelCredentials(t *testing.T) { + adapters := []legacyruntime.ModelAdapterConfig{ + {ID: " channel-a ", ModelID: "model-a", APIKey: "provider-secret-a", BaseURL: "https://provider-a.example/v1"}, + {ID: "channel-b", ModelID: "model-a"}, + {ID: "", ModelID: "model-c"}, + } + + got := buildCLIModelDetails(adapters) + want := []map[string]any{ + {"modelId": "channel-a", "displayModelId": "channel-a", "apiKeyCredentials": map[string]any{"apiKey": "provider-secret-a", "baseUrl": "https://provider-a.example/v1"}}, + {"modelId": "channel-b", "displayModelId": "channel-b", "apiKeyCredentials": map[string]any{"apiKey": "", "baseUrl": ""}}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("build CLI model details: got %v, want %v", got, want) + } +} + +func TestEncodeCLIModelsUsesAgentModelDetailsWireFormat(t *testing.T) { + payload := map[string]any{"models": buildCLIModelDetails([]legacyruntime.ModelAdapterConfig{{ID: "channel-a", APIKey: "provider-secret", BaseURL: "https://provider.example/v1"}})} + encoded, err := encodeMockProto("aiserver.v1.GetUsableModelsResponse", payload) + if err != nil { + t.Fatalf("encode CLI models: %v", err) + } + + response := &agentv1.GetUsableModelsResponse{} + if err := proto.Unmarshal(encoded, response); err != nil { + t.Fatalf("decode CLI models with agent proto: %v", err) + } + if len(response.Models) != 1 { + t.Fatalf("decoded model count: got %d, want 1", len(response.Models)) + } + model := response.Models[0] + if model.GetModelId() != "channel-a" || model.GetDisplayModelId() != "channel-a" { + t.Fatalf("decoded channel IDs: model=%q display=%q", model.GetModelId(), model.GetDisplayModelId()) + } + if credentials := model.GetApiKeyCredentials(); credentials == nil || credentials.GetApiKey() != "provider-secret" || credentials.GetBaseUrl() != "https://provider.example/v1" { + t.Fatalf("decoded relay credentials: %#v", credentials) + } +} + func TestBuildBootstrapStatsigConfigJSONDisablesAlwaysLocalDecompositionGate(t *testing.T) { payload, err := buildBootstrapStatsigConfigJSON(12345, "test-auth-id") if err != nil {