fix(backend): cover cursor-agent CLI local-mode endpoint surface

Desktop Cursor works through the local proxy because it drives the agent
over BidiAppend/RunSSE plus the already-mocked unary endpoints. The
cursor-agent CLI speaks the same agent protocol but calls additional
unary endpoints that had no local handlers, so every request fell into a
wildcard route and came back as HTTP 404, which the Connect client maps
to '[unimplemented] HTTP 404'.

Three independent breaks, one visible symptom:

1. Startup: ServerConfigService/GetServerConfig (only the AiService
   variant was mocked), DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam
   and DashboardService/ListMarketplaces were missing, so the CLI aborted
   during session init.

2. Git workspaces: the CLI resolves the repo path-encryption key from
   indexingConfig.default{User,Team}PathEncryptionKey in GetServerConfig
   when no IDE-stored repo keys exist. The mock returned no indexingConfig,
   so repository identity init failed with 'No encryption key found'.

3. Tool execution: every fs tool executor (Ls/Grep/Glob/Shell) consults
   the ignore service, which calls getRepoBlockExcludeGlobs() ->
   DashboardService/GetTeamReposOrEmptyIfNotInTeam. The 404 propagated as
   the tool result error, so model answers arrived but every tool call
   returned '[unimplemented] HTTP 404'. Non-git workspaces skip the
   repo-block path, which is why tools only failed inside git repos.

Also close the remaining tolerated 404 noise so a CLI session produces
zero unimplemented responses: model listing (GetUsableModels,
GetDefaultModelForCli, GetDefaultModel), dashboard/plugin housekeeping
(GetGlobalCommands, GetEffectiveUserPlugins, RegisterMarketplaceAndPlugins,
GetCliDownloadUrl) and telemetry (AnalyticsService/SubmitLogs,
AnalyticsService/TrackEvents, OTLP /v1/traces).

Additional logging: PolicyMiddleware now includes the request path in the
per-request log line, which is what made this diagnosable from app.log.
This commit is contained in:
warelik
2026-08-01 10:58:19 +03:00
parent 1f976b2a5e
commit 2c650b3765
5 changed files with 284 additions and 1 deletions
+180
View File
@@ -330,6 +330,19 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
Name: "server_config", Name: "server_config",
})), })),
), ),
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.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "server_config_service_get_server_config",
})),
),
server.POST("/aiserver.v1.AiService/AvailableModels", server.POST("/aiserver.v1.AiService/AvailableModels",
server.Name("available_models"), server.Name("available_models"),
server.ConnectUnary(), server.ConnectUnary(),
@@ -343,6 +356,45 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
Name: "available_models", Name: "available_models",
})), })),
), ),
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.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "usable_models",
})),
),
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.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "default_model_for_cli",
})),
),
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.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "default_model",
})),
),
server.POST("/aiserver.v1.AiService/GetDefaultModelNudgeData", server.POST("/aiserver.v1.AiService/GetDefaultModelNudgeData",
server.Name("default_model_nudge"), server.Name("default_model_nudge"),
server.ConnectUnary(), server.ConnectUnary(),
@@ -382,6 +434,43 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
Name: "first_window_statsig_decision", Name: "first_window_statsig_decision",
})), })),
), ),
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.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "analytics_submit_logs",
})),
),
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.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "analytics_track_events",
})),
),
server.POST("/v1/traces",
server.Name("otlp_traces"),
server.HTTP(),
server.Local(upstream.FixedStatusAction(routeDeps, upstream.CompatRouteConfig{
Name: "otlp_traces",
StatusCode: http.StatusOK,
})),
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "otlp_traces",
})),
),
server.POST("/oauth/token", server.POST("/oauth/token",
server.Name("oauth_token"), server.Name("oauth_token"),
server.HTTP(), server.HTTP(),
@@ -525,6 +614,97 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
Name: "dashboard_get_managed_skills", Name: "dashboard_get_managed_skills",
})), })),
), ),
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.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_team_admin_settings_or_empty",
})),
),
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.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_team_repos_or_empty",
})),
),
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.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_list_marketplaces",
})),
),
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.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_global_commands",
})),
),
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.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_effective_user_plugins",
})),
),
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.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_register_marketplace_and_plugins",
})),
),
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.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_cli_download_url",
})),
),
server.POST("/aiserver.v1.DashboardService/GetMe", server.POST("/aiserver.v1.DashboardService/GetMe",
server.Name("dashboard_get_me"), server.Name("dashboard_get_me"),
server.ConnectUnary(), server.ConnectUnary(),
+5 -1
View File
@@ -43,7 +43,11 @@ func PolicyMiddleware(configs *serverconfig.Manager) Middleware {
return func(next HandlerFunc) HandlerFunc { return func(next HandlerFunc) HandlerFunc {
return func(ctx *Context) error { return func(ctx *Context) error {
ctx.Mode = parseExecutionMode(configs.RouteMode(ctx.UpstreamURL != nil)) ctx.Mode = parseExecutionMode(configs.RouteMode(ctx.UpstreamURL != nil))
logger.Infof("ctx.Mode=%s upstream=%t", ctx.Mode, ctx.UpstreamURL != nil) path := ""
if ctx.Request != nil && ctx.Request.URL != nil {
path = ctx.Request.URL.Path
}
logger.Infof("ctx.Mode=%s upstream=%t path=%s", ctx.Mode, ctx.UpstreamURL != nil, path)
return next(ctx) return next(ctx)
} }
} }
@@ -165,6 +165,18 @@ func DefaultModelNudgeMockBuilder(reqCtx *RequestContext) (map[string]any, error
return buildDefaultModelNudgeDataPayload(reqCtx) 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) { func BootstrapStatsigMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return buildBootstrapStatsigPayload(reqCtx) return buildBootstrapStatsigPayload(reqCtx)
} }
@@ -185,6 +197,18 @@ func DashboardManagedSkillsMockBuilder(reqCtx *RequestContext) (map[string]any,
return buildDashboardManagedSkillsPayload(reqCtx) 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) { func DashboardGetMeMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return buildDashboardGetMePayload(reqCtx) return buildDashboardGetMePayload(reqCtx)
} }
@@ -427,6 +427,30 @@ func newProtoMessage(typeName string) (proto.Message, error) {
return &aiserverv1.GetUsageLimitStatusAndActiveGrantsResponse{}, nil return &aiserverv1.GetUsageLimitStatusAndActiveGrantsResponse{}, nil
case "aiserver.v1.IsOnNewPricingResponse": case "aiserver.v1.IsOnNewPricingResponse":
return &aiserverv1.IsOnNewPricingResponse{}, nil 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 &aiserverv1.GetUsableModelsResponse{}, nil
case "aiserver.v1.GetDefaultModelForCliResponse":
return &aiserverv1.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: default:
return nil, fmt.Errorf("unsupported proto message type %q", typeName) return nil, fmt.Errorf("unsupported proto message type %q", typeName)
} }
+51
View File
@@ -17,6 +17,13 @@ const (
modelRuntimeThinkingEffortParameterID = "thinking_effort" modelRuntimeThinkingEffortParameterID = "thinking_effort"
// localPathEncryptionKey — стабильный ключ шифрования путей для индексации
// репозитория, который cursor-agent CLI запрашивает через GetServerConfig
// (indexingConfig.default{User,Team}PathEncryptionKey). Без него CLI
// не может инициализировать repo identity в git-воркспейсе и вызовы
// файловых инструментов падают с "[unimplemented] HTTP 404".
localPathEncryptionKey = "6f6e63652d6c6f63616c2d706174682d656e6372797074696f6e2d6b6579"
localUltraMembershipType = "ultra" localUltraMembershipType = "ultra"
localUltraPaymentID = "local_ultra" localUltraPaymentID = "local_ultra"
localUltraSubscriptionStatus = "active" localUltraSubscriptionStatus = "active"
@@ -427,6 +434,10 @@ func buildServerConfigPayload(*RequestContext) (map[string]any, error) {
"configVersion": "local_cli_sandbox_defaults_disabled_v2", "configVersion": "local_cli_sandbox_defaults_disabled_v2",
// "http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED", // "http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED",
"cliSandboxDefaultEnabled": true, "cliSandboxDefaultEnabled": true,
"indexingConfig": map[string]any{
"defaultUserPathEncryptionKey": localPathEncryptionKey,
"defaultTeamPathEncryptionKey": localPathEncryptionKey,
},
}, nil }, nil
} }
@@ -486,6 +497,36 @@ func buildDefaultModelNudgeDataPayload(reqCtx *RequestContext) (map[string]any,
}, nil }, nil
} }
func buildUsableModelsPayload(reqCtx *RequestContext) (map[string]any, error) {
adapters, err := loadConfiguredModelAdapters(reqCtx)
if err != nil {
return nil, err
}
refs := collectModelAdapterRefs(adapters)
models := make([]map[string]any, 0, len(refs))
for _, ref := range refs {
models = append(models, map[string]any{"modelName": ref})
}
return map[string]any{"models": models}, nil
}
func buildDefaultModelForCliPayload(reqCtx *RequestContext) (map[string]any, error) {
adapters, err := loadConfiguredModelAdapters(reqCtx)
if err != nil {
return nil, err
}
return map[string]any{"model": map[string]any{"modelName": firstModelAdapterRef(adapters)}}, 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) { func buildBootstrapStatsigPayload(reqCtx *RequestContext) (map[string]any, error) {
generatedAtMs := uint64(time.Now().UnixMilli()) generatedAtMs := uint64(time.Now().UnixMilli())
authID := resolveBootstrapStatsigAuthID(reqCtx) authID := resolveBootstrapStatsigAuthID(reqCtx)
@@ -823,6 +864,16 @@ func collectModelAdapterRefs(adapters []legacyruntime.ModelAdapterConfig) []stri
return output 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 { func resolveBootstrapStatsigAuthID(reqCtx *RequestContext) string {
if reqCtx != nil { if reqCtx != nil {
if authID := authIDFromBearer(reqCtx.Headers.Get("authorization")); authID != "" { if authID := authIDFromBearer(reqCtx.Headers.Get("authorization")); authID != "" {