From dd9da5ec6df3c7f44d88c017b3b8869c3e96462b Mon Sep 17 00:00:00 2001 From: Henrique Caiano <5713877+hcaiano@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:30:12 +0100 Subject: [PATCH] feat(auth-cursor): add included quota display reader --- auth-cursor/README.md | 8 + auth-cursor/go/main.go | 11 + auth-cursor/go/quota.go | 315 +++++++++++++++++ auth-cursor/go/quota_test.go | 651 +++++++++++++++++++++++++++++++++++ auth-cursor/go/register.go | 2 + 5 files changed, 987 insertions(+) create mode 100644 auth-cursor/go/quota.go create mode 100644 auth-cursor/go/quota_test.go diff --git a/auth-cursor/README.md b/auth-cursor/README.md index 3b8d823..430262e 100644 --- a/auth-cursor/README.md +++ b/auth-cursor/README.md @@ -21,6 +21,14 @@ client (OpenAI / Claude / Gemini) A full host config is in [config.example.yaml](config.example.yaml). +## Quota display + +The quota provider returns separate Auto and API included-usage groups for an indexed Cursor credential. The host must supply the selected credential's current `storage_json`. The reader exchanges that API key once, then reads identity, current-period usage, and hard limits with one token and the default team selection. It uses Connect unary JSON directly, without starting the SDK bridge. Reset is unsupported. + +Both percentages and the current billing period must validate, and every required read must succeed. Any failure returns a sanitized error without quota numbers. The reader uses the existing credential, plugin, and environment proxy order. In direct mode it refuses backend overrides other than `https://api2.cursor.sh`. + +These groups are display values. They do not establish Composer model membership, billing scope, no-spend controls, or permission to send inference. The reader does not publish spend policy, currency, model lists, or capacity. Upstream JSON support and selected-key permissions require separate live verification. + ## Use Store source and `plugins.dir` are in the [repository README](../README.md). This section is what this plugin adds on top. diff --git a/auth-cursor/go/main.go b/auth-cursor/go/main.go index db3b633..835011a 100644 --- a/auth-cursor/go/main.go +++ b/auth-cursor/go/main.go @@ -178,6 +178,17 @@ func handleMethod(method string, request []byte) ([]byte, error) { return okEnvelope(struct{}{}) case pluginabi.MethodAuthIdentifier, pluginabi.MethodExecutorIdentifier: return okEnvelope(map[string]string{"identifier": providerIdentifier}) + case "quota.identifier": + return okEnvelope(map[string]string{"identifier": providerIdentifier}) + case "quota.describe": + return okEnvelope(struct { + SupportedProviders []string `json:"supported_providers"` + SupportsReset bool `json:"supports_reset"` + }{SupportedProviders: []string{providerIdentifier}}) + case "quota.fetch": + return fetchCursorQuota(request) + case "quota.reset": + return errorEnvelope("unsupported", "cursor quota reset is not supported"), nil case pluginabi.MethodAuthParse: return parseAuth(request) case pluginabi.MethodAuthRefresh: diff --git a/auth-cursor/go/quota.go b/auth-cursor/go/quota.go new file mode 100644 index 0000000..4f49fdb --- /dev/null +++ b/auth-cursor/go/quota.go @@ -0,0 +1,315 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "math" + "mime" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +const ( + quotaObservationTimeout = 30 * time.Second + quotaResponseMaxBytes = 1 << 20 + quotaDashboardPath = "/aiserver.v1.DashboardService/" +) + +var quotaTransportFactory = func(proxy *url.URL) http.RoundTripper { return egressTransport(proxy) } + +type quotaFetchRequest struct { + AuthIndex string `json:"auth_index"` + AuthID string `json:"auth_id"` + Provider string `json:"provider"` + StorageJSON []byte `json:"storage_json,omitempty"` +} + +type includedUsage struct { + autoUsed float64 + apiUsed float64 + periodStart int64 + periodEnd int64 +} + +type quotaResponse struct { + Groups []quotaGroup `json:"groups"` +} + +type quotaGroup struct { + DisplayName string `json:"displayName"` + Buckets []quotaBucket `json:"buckets"` +} + +type quotaBucket struct { + RemainingFraction float64 `json:"remainingFraction"` + ResetTime string `json:"resetTime"` +} + +func fetchCursorQuota(raw []byte) ([]byte, error) { + var req quotaFetchRequest + if err := json.Unmarshal(raw, &req); err != nil || strings.TrimSpace(req.AuthIndex) == "" || + strings.TrimSpace(req.AuthID) == "" || req.Provider != providerIdentifier { + return errorEnvelope("invalid_quota_request", "cursor quota requires indexed cursor credential context"), nil + } + key, err := requireAPIKey(req.StorageJSON) + if err != nil { + return errorEnvelope("invalid_auth", "cursor quota requires a selected API key"), nil + } + target := cursorBackendTarget + proxy := resolveEgressProxy(resolveProxyURL(req.StorageJSON)) + var parsedProxy *url.URL + if proxy != "" { + parsedProxy, err = parseEgressProxy(proxy) + if err != nil { + return errorEnvelope("quota_transport", "cursor quota proxy configuration is invalid"), nil + } + } else if backend := os.Getenv("CURSOR_BACKEND_URL"); backend != "" && backend != target.String() && backend != target.String()+"/" { + return errorEnvelope("quota_transport", "cursor quota backend override is not supported"), nil + } + transport := quotaTransportFactory(parsedProxy) + if closer, ok := transport.(interface{ CloseIdleConnections() }); ok { + defer closer.CloseIdleConnections() + } + client := &http.Client{ + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + ctx, cancel := context.WithTimeout(context.Background(), quotaObservationTimeout) + defer cancel() + id := registerInFlight(cancel) + defer unregisterInFlight(id) + usage, err := observeCursorQuota(ctx, client, target, key) + if err != nil { + return errorEnvelope("quota_unavailable", err.Error()), nil + } + reset := time.UnixMilli(usage.periodEnd).UTC().Format(time.RFC3339Nano) + return okEnvelope(quotaResponse{Groups: []quotaGroup{ + {DisplayName: "Auto", Buckets: []quotaBucket{{RemainingFraction: (100 - usage.autoUsed) / 100, ResetTime: reset}}}, + {DisplayName: "API", Buckets: []quotaBucket{{RemainingFraction: (100 - usage.apiUsed) / 100, ResetTime: reset}}}, + }}) +} + +func observeCursorQuota(ctx context.Context, client *http.Client, target url.URL, key string) (includedUsage, error) { + exchange, err := quotaPost(ctx, client, target, "/auth/exchange_user_api_key", key, false) + if err != nil { + return includedUsage{}, errors.New("cursor quota exchange failed") + } + fields, err := quotaObject(exchange, "accessToken") + var token string + if err != nil || json.Unmarshal(fields["accessToken"], &token) != nil || strings.TrimSpace(token) == "" { + return includedUsage{}, errors.New("cursor quota exchange response is invalid") + } + me, err := quotaPost(ctx, client, target, quotaDashboardPath+"GetMe", token, true) + if err != nil { + return includedUsage{}, errors.New("cursor quota identity read failed") + } + if err := validateQuotaIdentity(me); err != nil { + return includedUsage{}, errors.New("cursor quota identity response is invalid") + } + rawUsage, err := quotaPost(ctx, client, target, quotaDashboardPath+"GetCurrentPeriodUsage", token, true) + if err != nil { + return includedUsage{}, errors.New("cursor quota usage read failed") + } + usage, err := parseCursorUsage(rawUsage) + if err != nil { + return includedUsage{}, errors.New("cursor quota usage response is invalid") + } + hardLimit, err := quotaPost(ctx, client, target, quotaDashboardPath+"GetHardLimit", token, true) + if err != nil { + return includedUsage{}, errors.New("cursor quota limit read failed") + } + if err := validateQuotaHardLimit(hardLimit); err != nil { + return includedUsage{}, errors.New("cursor quota limit response is invalid") + } + // The local completion clock is a conservative display check, not a provider clock. + now := time.Now().UnixMilli() + if ctx.Err() != nil || now < usage.periodStart || now >= usage.periodEnd { + return includedUsage{}, errors.New("cursor quota period is not current at completion") + } + return usage, nil +} + +func quotaPost(ctx context.Context, client *http.Client, target url.URL, path, bearer string, dashboard bool) ([]byte, error) { + target.Path = path + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), strings.NewReader("{}")) + if err != nil { + return nil, errors.New("invalid request") + } + // A replayable body lets net/http retry a failed write on a reused connection. + req.GetBody = nil + req.Header.Set("Authorization", "Bearer "+bearer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if dashboard { + req.Header.Set("Connect-Protocol-Version", "1") + } + resp, err := client.Do(req) + if err != nil { + return nil, errors.New("request failed") + } + defer resp.Body.Close() + // Unary Connect errors use non-200 statuses and JSON error bodies, never a result. + if resp.StatusCode != http.StatusOK { + return nil, errors.New("source refused") + } + mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + return nil, errors.New("unexpected response encoding") + } + body, err := io.ReadAll(io.LimitReader(resp.Body, quotaResponseMaxBytes+1)) + if err != nil || len(body) > quotaResponseMaxBytes || ctx.Err() != nil { + return nil, errors.New("invalid response body") + } + return body, nil +} + +// quotaObject retains recognized fields before defaults erase absence. Both spellings name +// one semantic field, so repeated keys and camelCase/snake_case collisions both refuse. +func quotaObject(raw []byte, names ...string) (map[string]json.RawMessage, error) { + aliases := make(map[string]string, len(names)*2) + for _, name := range names { + aliases[name] = name + var snake strings.Builder + for _, c := range name { + if c >= 'A' && c <= 'Z' { + snake.WriteByte('_') + c += 'a' - 'A' + } + snake.WriteRune(c) + } + aliases[snake.String()] = name + } + d := json.NewDecoder(bytes.NewReader(raw)) + token, err := d.Token() + if err != nil || token != json.Delim('{') { + return nil, errors.New("expected object") + } + fields := make(map[string]json.RawMessage, len(names)) + for d.More() { + token, err := d.Token() + if err != nil { + return nil, errors.New("invalid object") + } + var value json.RawMessage + if err := d.Decode(&value); err != nil { + return nil, errors.New("invalid field") + } + if name, recognized := aliases[token.(string)]; recognized { + if _, duplicate := fields[name]; duplicate { + return nil, errors.New("duplicate field") + } + fields[name] = value + } + } + if _, err := d.Token(); err != nil { + return nil, errors.New("invalid object end") + } + if _, err := d.Token(); err != io.EOF { + return nil, errors.New("trailing data") + } + return fields, nil +} + +func quotaInteger(raw []byte, bits int) (int64, error) { + text := string(raw) + if len(text) > 0 && text[0] == '"' { + if err := json.Unmarshal(raw, &text); err != nil { + return 0, errors.New("invalid integer") + } + } + value, err := strconv.ParseInt(text, 10, bits) + if err != nil || strconv.FormatInt(value, 10) != text { + return 0, errors.New("invalid integer") + } + return value, nil +} + +func validateQuotaIdentity(raw []byte) error { + fields, err := quotaObject(raw, "userId", "teamId") + if err != nil { + return err + } + user, err := quotaInteger(fields["userId"], 32) + if err != nil || user <= 0 { + return errors.New("invalid user") + } + if team, present := fields["teamId"]; present { + value, err := quotaInteger(team, 32) + if err != nil || value <= 0 { + return errors.New("invalid team") + } + } + return nil +} + +func parseCursorUsage(raw []byte) (includedUsage, error) { + fields, err := quotaObject(raw, "planUsage", "billingCycleStart", "billingCycleEnd", "autoBucketModels") + if err != nil { + return includedUsage{}, err + } + plan, err := quotaObject(fields["planUsage"], "autoPercentUsed", "apiPercentUsed") + if err != nil { + return includedUsage{}, err + } + usage := includedUsage{} + for name, dest := range map[string]*float64{"autoPercentUsed": &usage.autoUsed, "apiPercentUsed": &usage.apiUsed} { + value := plan[name] + if len(value) == 0 || value[0] == '"' || bytes.Equal(value, []byte("null")) || json.Unmarshal(value, dest) != nil || + math.IsNaN(*dest) || math.IsInf(*dest, 0) || *dest < 0 || *dest > 100 { + return includedUsage{}, errors.New("invalid percentage") + } + } + usage.periodStart, err = quotaInteger(fields["billingCycleStart"], 64) + if err != nil { + return includedUsage{}, err + } + usage.periodEnd, err = quotaInteger(fields["billingCycleEnd"], 64) + if err != nil || usage.periodStart >= usage.periodEnd || usage.periodStart < -62135596800000 || usage.periodEnd > 253402300799999 { + return includedUsage{}, errors.New("invalid period") + } + if models, present := fields["autoBucketModels"]; present { + var items []json.RawMessage + if len(models) == 0 || models[0] != '[' || json.Unmarshal(models, &items) != nil { + return includedUsage{}, errors.New("invalid model list") + } + for _, item := range items { + var model string + if len(item) == 0 || item[0] != '"' || json.Unmarshal(item, &model) != nil { + return includedUsage{}, errors.New("invalid model name") + } + } + } + return usage, nil +} + +func validateQuotaHardLimit(raw []byte) error { + intFields := []string{"hardLimit", "hardLimitPerUser", "perUserMonthlyLimitDollars", "setupPromoAmountCents", + "setupPromoCreditValidityDays", "perUserFirstPartyModelsAdditionalBudgetDollars"} + boolFields := []string{"noUsageBasedAllowed", "isDynamicTeamLimit", "hasPendingSetupOnboardingCreditClaim", + "isSetupPromoActive", "onDemandSpendDisabledByOrganization", "perUserFirstPartyModelsAdditionalBudgetUnlimited"} + names := append(append([]string{}, intFields...), boolFields...) + fields, err := quotaObject(raw, names...) + if err != nil { + return err + } + for _, name := range intFields { + if value, present := fields[name]; present { + if _, err := quotaInteger(value, 32); err != nil { + return err + } + } + } + for _, name := range boolFields { + if value, present := fields[name]; present && !bytes.Equal(value, []byte("true")) && !bytes.Equal(value, []byte("false")) { + return errors.New("invalid policy boolean") + } + } + return nil +} diff --git a/auth-cursor/go/quota_test.go b/auth-cursor/go/quota_test.go new file mode 100644 index 0000000..6c7d386 --- /dev/null +++ b/auth-cursor/go/quota_test.go @@ -0,0 +1,651 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" +) + +const quotaTestKey = "synthetic-selected-key-canary" +const quotaTestToken = "synthetic-exchanged-token-canary" + +var quotaTestPaths = []string{ + "/auth/exchange_user_api_key", + "/aiserver.v1.DashboardService/GetMe", + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage", + "/aiserver.v1.DashboardService/GetHardLimit", +} + +type quotaTestTransport func(*http.Request) (*http.Response, error) + +func (f quotaTestTransport) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func quotaTestSetup(t *testing.T) { + t.Helper() + for _, name := range egressProxyEnvVars { + t.Setenv(name, "") + } + t.Setenv("CURSOR_BACKEND_URL", "") + t.Setenv("CURSOR_API_KEY", "synthetic-environment-key-canary") + t.Setenv("CURSOR_SDK_BRIDGE_BIN", "/synthetic-missing-bridge") + previousConfig := loadedConfig() + previousFactory := quotaTransportFactory + currentConfig.Store(defaultPluginConfig()) + t.Cleanup(func() { + currentConfig.Store(previousConfig) + quotaTransportFactory = previousFactory + }) +} + +func quotaTestRequest(t *testing.T, storage string) []byte { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "auth_index": "selected-index", "auth_id": "selected-id", "provider": "cursor", + "storage_json": []byte(storage), + "metadata": map[string]any{"api_key": "synthetic-metadata-key-canary", "access_token": "synthetic-stale-token-canary"}, + "attributes": map[string]string{"proxy_url": "http://ignored-attribute-proxy:8123"}, + "host": map[string]any{"ProxyURL": "http://ignored-host-proxy:8124"}, + "host_callback_id": "synthetic-callback", + }) + if err != nil { + t.Fatal(err) + } + return raw +} + +func quotaTestUsage() string { + now := time.Now().UTC() + raw, _ := json.Marshal(map[string]any{ + "billingCycleStart": now.Add(-time.Hour).UnixMilli(), + "billingCycleEnd": now.Add(time.Hour).UnixMilli(), + "planUsage": map[string]any{"autoPercentUsed": 25, "apiPercentUsed": 75}, + "autoBucketModels": []string{"synthetic-model-canary"}, + }) + return string(raw) +} + +func quotaTestBodies() []string { + return []string{`{"accessToken":"` + quotaTestToken + `"}`, `{"userId":17,"teamId":23}`, quotaTestUsage(), `{}`} +} + +func quotaTestCapture(t *testing.T, bodies []string) *[]string { + t.Helper() + paths := []string{} + quotaTransportFactory = func(proxy *url.URL) http.RoundTripper { + if proxy != nil { + t.Fatal("unexpected proxy") + } + return quotaTestTransport(func(req *http.Request) (*http.Response, error) { + i := len(paths) + paths = append(paths, req.URL.Path) + if i >= len(quotaTestPaths) || req.URL.Path != quotaTestPaths[i] { + t.Fatalf("unexpected outbound operation %d at %s", i, req.URL.Path) + } + if req.Method != "POST" || req.URL.Scheme != "https" || req.URL.Host != "api2.cursor.sh" || req.URL.RawQuery != "" { + t.Fatal("unexpected method or destination") + } + body, err := io.ReadAll(req.Body) + if err != nil || string(body) != "{}" { + t.Fatal("request must use the empty default-team object") + } + bearer := quotaTestToken + if i == 0 { + bearer = quotaTestKey + } + if req.Header.Get("Authorization") != "Bearer "+bearer { + t.Fatal("wrong credential or token") + } + if req.Header.Get("Content-Type") != "application/json" { + t.Fatal("request must explicitly select JSON") + } + if i > 0 && req.Header.Get("Connect-Protocol-Version") != "1" { + t.Fatal("missing Connect unary version") + } + if req.GetBody != nil { + t.Fatal("transport must not receive a replayable credential request") + } + for name := range req.Header { + if strings.Contains(strings.ToLower(name), "team") { + t.Fatal("unexpected team selector") + } + } + if _, ok := req.Context().Deadline(); !ok { + t.Fatal("unbounded observation") + } + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(bodies[i])), Request: req}, nil + }) + } + return &paths +} + +func quotaTestFetch(t *testing.T, request []byte) []byte { + t.Helper() + raw, err := handleMethod("quota.fetch", request) + if err != nil { + t.Fatalf("dispatch escaped the sanitized envelope: %v", err) + } + return raw +} + +func quotaTestRefused(t *testing.T, raw []byte) { + t.Helper() + env := decodeEnvelope(t, raw) + if env.OK || len(env.Result) != 0 || env.Error == nil { + t.Fatalf("expected refusal with no quota result: %s", raw) + } + if env.Error.Code == "unknown_method" { + t.Fatal("quota.fetch is not dispatched") + } + if !map[string]bool{"invalid_quota_request": true, "invalid_auth": true, "quota_transport": true, "quota_unavailable": true}[env.Error.Code] { + t.Fatal("unexpected error code") + } + for _, canary := range []string{quotaTestKey, quotaTestToken, "synthetic-metadata-key-canary", "synthetic-stale-token-canary", "synthetic-provider-body-canary", "synthetic-proxy-secret-canary"} { + if strings.Contains(string(raw), canary) { + t.Fatal("refusal leaked a synthetic secret") + } + } + if strings.Contains(string(raw), "remainingFraction") || strings.Contains(string(raw), "groups") { + t.Fatal("refusal retained quota numbers") + } +} + +func quotaTestFractions(t *testing.T, raw []byte, wantAuto, wantAPI float64) { + t.Helper() + env := decodeEnvelope(t, raw) + if !env.OK { + t.Fatalf("quota.fetch failed: %s", raw) + } + var result map[string]json.RawMessage + if err := json.Unmarshal(env.Result, &result); err != nil { + t.Fatal(err) + } + if len(result) != 1 || result["groups"] == nil { + t.Fatal("unexpected public result fields") + } + var groups []struct { + DisplayName string `json:"displayName"` + Buckets []map[string]json.RawMessage `json:"buckets"` + } + if err := json.Unmarshal(result["groups"], &groups); err != nil { + t.Fatal(err) + } + if len(groups) != 2 { + t.Fatal("expected distinct Auto and API groups") + } + for i, want := range []float64{wantAuto, wantAPI} { + if groups[i].DisplayName != []string{"Auto", "API"}[i] || len(groups[i].Buckets) != 1 { + t.Fatal("unexpected group") + } + bucket := groups[i].Buckets[0] + fraction, present := bucket["remainingFraction"] + if !present { + t.Fatal("remainingFraction must be emitted even when zero") + } + var got float64 + if err := json.Unmarshal(fraction, &got); err != nil || got != want { + t.Fatalf("fraction %s, want %v", fraction, want) + } + var reset string + if err := json.Unmarshal(bucket["resetTime"], &reset); err != nil { + t.Fatal(err) + } + parsed, err := time.Parse(time.RFC3339Nano, reset) + if err != nil || !parsed.After(time.Now()) { + t.Fatal("missing valid cycle reset") + } + } +} + +func TestQuotaRegistration(t *testing.T) { + quotaTestSetup(t) + calls := quotaTestCapture(t, quotaTestBodies()) + raw, err := handleMethod(pluginabi.MethodPluginRegister, nil) + if err != nil { + t.Fatal(err) + } + var reg struct { + SchemaVersion uint32 `json:"schema_version"` + } + env := decodeEnvelope(t, raw) + if err := json.Unmarshal(env.Result, ®); err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + json.Unmarshal(env.Result, &fields) + var capabilities map[string]json.RawMessage + json.Unmarshal(fields["capabilities"], &capabilities) + if reg.SchemaVersion != pluginabi.SchemaVersion || string(capabilities["quota_provider"]) != "true" { + t.Fatal("registration must preserve the SDK schema and advertise quota") + } + for _, capability := range []string{"auth_provider", "model_provider", "executor", "command_line_plugin"} { + if string(capabilities[capability]) != "true" { + t.Fatal("existing capability lost") + } + } + for _, method := range []string{"quota.identifier", "quota.describe", "quota.reset"} { + out, err := handleMethod(method, nil) + if err != nil { + t.Fatal(err) + } + env = decodeEnvelope(t, out) + if method == "quota.reset" { + if env.OK || env.Error == nil || env.Error.Code != "unsupported" { + t.Fatal("reset must explicitly refuse") + } + } else if !env.OK { + t.Fatalf("missing dispatch for %s", method) + } + if method == "quota.identifier" && string(env.Result) != `{"identifier":"cursor"}` { + t.Fatal("wrong quota provider") + } + if method == "quota.describe" { + var description struct { + Providers []string `json:"supported_providers"` + Reset bool `json:"supports_reset"` + } + if err := json.Unmarshal(env.Result, &description); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(description.Providers, []string{"cursor"}) || description.Reset { + t.Fatal("unexpected quota capabilities") + } + } + } + if len(*calls) != 0 { + t.Fatal("registration or reset performed outbound work") + } +} + +func TestQuotaFetchSelectedStorage(t *testing.T) { + quotaTestSetup(t) + bodies := quotaTestBodies() + paths := quotaTestCapture(t, bodies) + disk := filepath.Join(t.TempDir(), "selected.json") + if err := os.WriteFile(disk, []byte(`{"type":"cursor","api_key":"synthetic-disk-key-canary"}`), 0600); err != nil { + t.Fatal(err) + } + request := quotaTestRequest(t, `{"type":"cursor","api_key":" `+quotaTestKey+` ","apiKey":"synthetic-other-key-canary"}`) + var fields map[string]any + json.Unmarshal(request, &fields) + fields["auth_id"] = disk + request, _ = json.Marshal(fields) + raw := quotaTestFetch(t, request) + quotaTestFractions(t, raw, .75, .25) + var usage struct { + End int64 `json:"billingCycleEnd"` + } + if err := json.Unmarshal([]byte(bodies[2]), &usage); err != nil { + t.Fatal(err) + } + var result struct { + Groups []struct { + Buckets []struct { + Reset string `json:"resetTime"` + } `json:"buckets"` + } `json:"groups"` + } + if err := json.Unmarshal(decodeEnvelope(t, raw).Result, &result); err != nil { + t.Fatal(err) + } + for _, group := range result.Groups { + if group.Buckets[0].Reset != time.UnixMilli(usage.End).UTC().Format(time.RFC3339Nano) { + t.Fatal("reset must preserve the exact provider end") + } + } + if !reflect.DeepEqual(*paths, quotaTestPaths) { + t.Fatalf("required request sequence = %v", *paths) + } + for _, canary := range []string{quotaTestKey, quotaTestToken, "synthetic-model-canary", "synthetic-disk-key-canary"} { + if strings.Contains(string(raw), canary) { + t.Fatal("success leaked provider data") + } + } +} + +func TestQuotaFetchRefusesCredentialContext(t *testing.T) { + for name, edit := range map[string]func(map[string]any){ + "no index": func(m map[string]any) { delete(m, "auth_index") }, + "blank index": func(m map[string]any) { m["auth_index"] = " " }, + "no id": func(m map[string]any) { delete(m, "auth_id") }, + "wrong provider": func(m map[string]any) { m["provider"] = "other" }, + "missing storage": func(m map[string]any) { delete(m, "storage_json") }, + "embedded storage object": func(m map[string]any) { m["storage_json"] = map[string]any{"api_key": quotaTestKey} }, + "empty selected key": func(m map[string]any) { m["storage_json"] = []byte(`{"type":"cursor"}`) }, + } { + t.Run(name, func(t *testing.T) { + quotaTestSetup(t) + paths := quotaTestCapture(t, quotaTestBodies()) + var req map[string]any + json.Unmarshal(quotaTestRequest(t, `{"api_key":"`+quotaTestKey+`"}`), &req) + edit(req) + raw, _ := json.Marshal(req) + quotaTestRefused(t, quotaTestFetch(t, raw)) + if len(*paths) != 0 { + t.Fatal("invalid context sent a credential") + } + }) + } +} + +func TestQuotaFetchPercentagePresence(t *testing.T) { + for _, tc := range []struct { + name, plan string + success bool + auto, api float64 + }{ + {"explicit zero and full", `{"autoPercentUsed":0,"apiPercentUsed":100}`, true, 1, 0}, + {"snake aliases", `{"auto_percent_used":100,"api_percent_used":0}`, true, 0, 1}, + {"missing auto", `{"apiPercentUsed":25}`, false, 0, 0}, + {"missing api", `{"autoPercentUsed":25}`, false, 0, 0}, + {"null", `{"autoPercentUsed":null,"apiPercentUsed":25}`, false, 0, 0}, + {"null api", `{"autoPercentUsed":25,"apiPercentUsed":null}`, false, 0, 0}, + {"string", `{"autoPercentUsed":"0","apiPercentUsed":25}`, false, 0, 0}, + {"negative", `{"autoPercentUsed":-1,"apiPercentUsed":25}`, false, 0, 0}, + {"over full", `{"autoPercentUsed":101,"apiPercentUsed":25}`, false, 0, 0}, + {"negative api", `{"autoPercentUsed":25,"apiPercentUsed":-1}`, false, 0, 0}, + {"over full api", `{"autoPercentUsed":25,"apiPercentUsed":101}`, false, 0, 0}, + {"overflow", `{"autoPercentUsed":1e10000,"apiPercentUsed":25}`, false, 0, 0}, + {"named infinity", `{"autoPercentUsed":"Infinity","apiPercentUsed":25}`, false, 0, 0}, + {"duplicate", `{"autoPercentUsed":0,"autoPercentUsed":0,"apiPercentUsed":25}`, false, 0, 0}, + {"alias collision", `{"autoPercentUsed":0,"auto_percent_used":0,"apiPercentUsed":25}`, false, 0, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + quotaTestSetup(t) + bodies := quotaTestBodies() + bodies[2] = `{"billingCycleStart":1,"billingCycleEnd":"253402300799999","planUsage":` + tc.plan + `}` + paths := quotaTestCapture(t, bodies) + raw := quotaTestFetch(t, quotaTestRequest(t, `{"api_key":"`+quotaTestKey+`"}`)) + if tc.success { + quotaTestFractions(t, raw, tc.auto, tc.api) + } else { + quotaTestRefused(t, raw) + if len(*paths) != 3 { + t.Fatal("invalid usage did not stop before HardLimit") + } + } + }) + } +} + +func TestQuotaFetchRequiredFailures(t *testing.T) { + for step := range quotaTestPaths { + for _, status := range []int{401, 403, 500} { + t.Run(quotaTestPaths[step]+http.StatusText(status), func(t *testing.T) { + quotaTestSetup(t) + bodies := quotaTestBodies() + paths := quotaTestCapture(t, bodies) + factory := quotaTransportFactory + quotaTransportFactory = func(proxy *url.URL) http.RoundTripper { + next := factory(proxy) + return quotaTestTransport(func(req *http.Request) (*http.Response, error) { + resp, err := next.RoundTrip(req) + if len(*paths) == step+1 { + resp.StatusCode = status + resp.Body = io.NopCloser(strings.NewReader(`{"code":"permission_denied","message":"synthetic-provider-body-canary"}`)) + } + return resp, err + }) + } + quotaTestRefused(t, quotaTestFetch(t, quotaTestRequest(t, `{"api_key":"`+quotaTestKey+`"}`))) + if len(*paths) != step+1 { + t.Fatal("required failure continued or retried") + } + }) + } + } +} + +func TestQuotaFetchUsesChosenProxyWithoutDirectRetry(t *testing.T) { + for _, tc := range []struct { + name, storage, plugin string + env map[string]string + want string + }{ + {"credential", `,"proxy_url":"http://credential-proxy:8101"`, "http://plugin-proxy:8102", map[string]string{"HTTPS_PROXY": "http://environment-proxy:8103"}, "credential-proxy:8101"}, + {"plugin", "", "http://plugin-proxy:8102", map[string]string{"HTTPS_PROXY": "http://environment-proxy:8103"}, "plugin-proxy:8102"}, + {"environment priority", "", "", map[string]string{"HTTPS_PROXY": "http://first-proxy:8103", "https_proxy": "http://second-proxy:8104", "ALL_PROXY": "http://last-proxy:8105"}, "first-proxy:8103"}, + {"lowercase environment", "", "", map[string]string{"all_proxy": "socks5h://socks-proxy:8106"}, "socks-proxy:8106"}, + {"direct", "", "", nil, "api2.cursor.sh:443"}, + } { + t.Run(tc.name, func(t *testing.T) { + quotaTestSetup(t) + cfg := defaultPluginConfig() + cfg.ProxyURL = tc.plugin + currentConfig.Store(cfg) + for key, value := range tc.env { + t.Setenv(key, value) + } + dials := []string{} + quotaTransportFactory = func(proxy *url.URL) http.RoundTripper { + transport := egressTransport(proxy) + transport.DialContext = func(_ context.Context, _, address string) (net.Conn, error) { + dials = append(dials, address) + return nil, errors.New("synthetic-proxy-secret-canary") + } + t.Cleanup(transport.CloseIdleConnections) + return transport + } + quotaTestRefused(t, quotaTestFetch(t, quotaTestRequest(t, `{"api_key":"`+quotaTestKey+`"`+tc.storage+`}`))) + if !reflect.DeepEqual(dials, []string{tc.want}) { + t.Fatalf("dial destinations = %v, want one chosen hop %s", dials, tc.want) + } + }) + } +} + +func TestQuotaFetchProviderValidation(t *testing.T) { + for _, tc := range []struct { + name string + step int + body string + success bool + }{ + {"empty token", 0, `{"accessToken":" "}`, false}, + {"wrong token type", 0, `{"accessToken":17}`, false}, + {"duplicate token", 0, `{"accessToken":"a","accessToken":"b"}`, false}, + {"no identity", 1, `{}`, false}, + {"zero user", 1, `{"userId":0}`, false}, + {"overflow user", 1, `{"userId":2147483648}`, false}, + {"null team", 1, `{"userId":17,"teamId":null}`, false}, + {"negative team", 1, `{"userId":17,"teamId":-1}`, false}, + {"identity aliases", 1, `{"user_id":17,"team_id":23}`, true}, + {"identity collision", 1, `{"userId":17,"user_id":17}`, false}, + {"no plan", 2, `{"billingCycleStart":1,"billingCycleEnd":253402300799999}`, false}, + {"null plan", 2, `{"billingCycleStart":1,"billingCycleEnd":253402300799999,"planUsage":null}`, false}, + {"limit array", 3, `[]`, false}, + {"limit null", 3, `null`, false}, + {"limit boolean string", 3, `{"noUsageBasedAllowed":"true"}`, false}, + {"limit boolean null", 3, `{"noUsageBasedAllowed":null}`, false}, + {"limit integer overflow", 3, `{"hardLimit":2147483648}`, false}, + {"limit fractional", 3, `{"hardLimit":1.5}`, false}, + {"limit alias collision", 3, `{"hardLimit":0,"hard_limit":0}`, false}, + {"limit duplicate", 3, `{"noUsageBasedAllowed":true,"noUsageBasedAllowed":true}`, false}, + {"policy default omission", 3, `{}`, true}, + {"policy false zero", 3, `{"hardLimit":0,"noUsageBasedAllowed":false}`, true}, + {"policy true historical limit", 3, `{"hard_limit":100,"no_usage_based_allowed":true}`, true}, + {"unlimited negative scalar", 3, `{"hardLimit":-1}`, true}, + {"unrelated fields ignored", 3, `{"futurePolicy":{"unknown":"synthetic-provider-body-canary"}}`, true}, + {"additional budget bool invalid", 3, `{"perUserFirstPartyModelsAdditionalBudgetUnlimited":1}`, false}, + {"additional budget scalar invalid", 3, `{"per_user_first_party_models_additional_budget_dollars":null}`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + quotaTestSetup(t) + bodies := quotaTestBodies() + bodies[tc.step] = tc.body + paths := quotaTestCapture(t, bodies) + raw := quotaTestFetch(t, quotaTestRequest(t, `{"api_key":"`+quotaTestKey+`"}`)) + if tc.success { + quotaTestFractions(t, raw, .75, .25) + } else { + quotaTestRefused(t, raw) + if len(*paths) != tc.step+1 { + t.Fatal("malformed response did not stop observation") + } + } + }) + } +} + +func TestQuotaFetchPeriodsAndModelLists(t *testing.T) { + for _, tc := range []struct { + name, extra string + success bool + }{ + {"exact canonical strings", `"billing_cycle_start":"1","billing_cycle_end":"253402300799999","auto_bucket_models":["exact-model"]`, true}, + {"exact canonical numbers", `"billingCycleStart":1,"billingCycleEnd":253402300799999`, true}, + {"fractional period", `"billingCycleStart":1.0,"billingCycleEnd":253402300799999`, false}, + {"exponent period", `"billingCycleStart":1e0,"billingCycleEnd":253402300799999`, false}, + {"leading zero string", `"billingCycleStart":"01","billingCycleEnd":253402300799999`, false}, + {"negative zero string", `"billingCycleStart":"-0","billingCycleEnd":253402300799999`, false}, + {"int64 overflow", `"billingCycleStart":1,"billingCycleEnd":9223372036854775808`, false}, + {"lossy large integer", `"billingCycleStart":1,"billingCycleEnd":9007199254740993`, false}, + {"not representable", `"billingCycleStart":1,"billingCycleEnd":253402300800000`, false}, + {"before representable", `"billingCycleStart":-62135596800001,"billingCycleEnd":253402300799999`, false}, + {"reversed", `"billingCycleStart":10,"billingCycleEnd":1`, false}, + {"equal", `"billingCycleStart":1,"billingCycleEnd":1`, false}, + {"past product refusal", `"billingCycleStart":1,"billingCycleEnd":2`, false}, + {"future product refusal", `"billingCycleStart":253402300799998,"billingCycleEnd":253402300799999`, false}, + {"missing end", `"billingCycleStart":1`, false}, + {"period aliases collide", `"billingCycleStart":1,"billing_cycle_start":1,"billingCycleEnd":253402300799999`, false}, + {"plan aliases collide", `"billingCycleStart":1,"billingCycleEnd":253402300799999,"plan_usage":{}`, false}, + {"models null", `"billingCycleStart":1,"billingCycleEnd":253402300799999,"autoBucketModels":null`, false}, + {"models wrong scalar", `"billingCycleStart":1,"billingCycleEnd":253402300799999,"autoBucketModels":[17]`, false}, + {"models null item", `"billingCycleStart":1,"billingCycleEnd":253402300799999,"autoBucketModels":[null]`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + quotaTestSetup(t) + bodies := quotaTestBodies() + bodies[2] = `{"planUsage":{"autoPercentUsed":25,"apiPercentUsed":75},` + tc.extra + `}` + quotaTestCapture(t, bodies) + raw := quotaTestFetch(t, quotaTestRequest(t, `{"api_key":"`+quotaTestKey+`"}`)) + if tc.success { + quotaTestFractions(t, raw, .75, .25) + } else { + quotaTestRefused(t, raw) + } + }) + } +} + +type quotaTestBrokenBody struct{} + +func (quotaTestBrokenBody) Read([]byte) (int, error) { + return 0, errors.New("synthetic-provider-body-canary") +} +func (quotaTestBrokenBody) Close() error { return nil } + +func TestQuotaFetchHTTPFailures(t *testing.T) { + for step := range quotaTestPaths { + for _, failure := range []string{"network", "canceled", "deadline", "body error", "large body", "binary", "missing media", "malformed media", "malformed JSON", "trailing JSON", "redirect", "non-200 success"} { + t.Run(quotaTestPaths[step]+"/"+failure, func(t *testing.T) { + quotaTestSetup(t) + paths := quotaTestCapture(t, quotaTestBodies()) + factory := quotaTransportFactory + quotaTransportFactory = func(proxy *url.URL) http.RoundTripper { + next := factory(proxy) + return quotaTestTransport(func(req *http.Request) (*http.Response, error) { + resp, err := next.RoundTrip(req) + if len(*paths) != step+1 { + return resp, err + } + switch failure { + case "network": + return nil, errors.New("synthetic-proxy-secret-canary") + case "canceled": + quiesce() + return nil, req.Context().Err() + case "deadline": + return nil, context.DeadlineExceeded + case "body error": + resp.Body = quotaTestBrokenBody{} + case "large body": + resp.Body = io.NopCloser(strings.NewReader(strings.Repeat(" ", (1<<20)+1))) + case "binary": + resp.Header.Set("Content-Type", "application/proto") + case "missing media": + resp.Header.Del("Content-Type") + case "malformed media": + resp.Header.Set("Content-Type", "application/json;broken") + case "malformed JSON": + resp.Body = io.NopCloser(strings.NewReader("{")) + case "trailing JSON": + resp.Body = io.NopCloser(strings.NewReader(`{} {}`)) + case "redirect": + resp.StatusCode = 307 + resp.Header.Set("Location", "https://synthetic-redirect.invalid/secret") + case "non-200 success": + resp.StatusCode = 201 + } + return resp, err + }) + } + quotaTestRefused(t, quotaTestFetch(t, quotaTestRequest(t, `{"api_key":"`+quotaTestKey+`"}`))) + if len(*paths) != step+1 { + t.Fatal("failed HTTP boundary followed another request") + } + }) + } + } +} + +func TestQuotaFetchRefusesTransportBeforeExchange(t *testing.T) { + for _, tc := range []struct{ name, proxy, backend string }{ + {"invalid scheme", "ftp://synthetic-proxy-secret-canary", ""}, + {"invalid proxy", "http://%synthetic-proxy-secret-canary", ""}, + {"missing proxy host", "http:///synthetic-proxy-secret-canary", ""}, + {"backend override", "", "https://synthetic-unsupported-backend.invalid"}, + } { + t.Run(tc.name, func(t *testing.T) { + quotaTestSetup(t) + t.Setenv("CURSOR_BACKEND_URL", tc.backend) + cfg := defaultPluginConfig() + cfg.ProxyURL = tc.proxy + currentConfig.Store(cfg) + calls := 0 + quotaTransportFactory = func(*url.URL) http.RoundTripper { + calls++ + t.Fatal("invalid transport must refuse before construction") + return nil + } + quotaTestRefused(t, quotaTestFetch(t, quotaTestRequest(t, `{"api_key":"`+quotaTestKey+`"}`))) + if calls != 0 { + t.Fatal("invalid transport sent credential") + } + }) + } +} + +func TestQuotaFetchDoesNotReuseEarlierSuccess(t *testing.T) { + quotaTestSetup(t) + request := quotaTestRequest(t, `{"api_key":"`+quotaTestKey+`"}`) + quotaTestCapture(t, quotaTestBodies()) + quotaTestFractions(t, quotaTestFetch(t, request), .75, .25) + paths := quotaTestCapture(t, quotaTestBodies()) + factory := quotaTransportFactory + quotaTransportFactory = func(proxy *url.URL) http.RoundTripper { + next := factory(proxy) + return quotaTestTransport(func(req *http.Request) (*http.Response, error) { + resp, err := next.RoundTrip(req) + if len(*paths) == 4 { + resp.StatusCode = 403 + } + return resp, err + }) + } + quotaTestRefused(t, quotaTestFetch(t, request)) + if len(*paths) != 4 { + t.Fatal("fresh observation was not complete through refusal") + } +} diff --git a/auth-cursor/go/register.go b/auth-cursor/go/register.go index 9415fd8..3356d95 100644 --- a/auth-cursor/go/register.go +++ b/auth-cursor/go/register.go @@ -65,6 +65,7 @@ type registration struct { type registrationCapability struct { AuthProvider bool `json:"auth_provider"` + QuotaProvider bool `json:"quota_provider"` ModelProvider bool `json:"model_provider"` CommandLinePlugin bool `json:"command_line_plugin"` Executor bool `json:"executor"` @@ -194,6 +195,7 @@ func pluginRegistration() registration { }, Capabilities: registrationCapability{ AuthProvider: true, + QuotaProvider: true, ModelProvider: true, CommandLinePlugin: true, Executor: true,