From 052754b7ae20d516eabf91f74281a352fb97dd93 Mon Sep 17 00:00:00 2001 From: drewwells Date: Tue, 3 Mar 2026 09:56:01 -0600 Subject: [PATCH 1/3] fix(cursor): prefer enterprise auth and handle missing limits This change fixes a multi-account Cursor edge case by preferring keychain auth when SQLite indicates a free account and the token subjects differ, ensuring enterprise usage is attributed to the correct account. It also handles percent-only usage payloads when planUsage.limit is missing, avoiding false errors while still rendering valid usage lines. Regression tests were added for both auth-source selection and missing-limit percent usage. --- plugins/cursor/plugin.js | 75 ++++++++++++++--- plugins/cursor/plugin.test.js | 151 +++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 13 deletions(-) diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index 5dfd35b..15aa6d0 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -82,7 +82,29 @@ function loadAuthState(ctx) { const sqliteAccessToken = readStateValue(ctx, "cursorAuth/accessToken") const sqliteRefreshToken = readStateValue(ctx, "cursorAuth/refreshToken") + const sqliteMembershipTypeRaw = readStateValue(ctx, "cursorAuth/stripeMembershipType") + const sqliteMembershipType = typeof sqliteMembershipTypeRaw === "string" + ? sqliteMembershipTypeRaw.trim().toLowerCase() + : null + + const keychainAccessToken = readKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE) + const keychainRefreshToken = readKeychainValue(ctx, KEYCHAIN_REFRESH_TOKEN_SERVICE) + + const sqliteSubject = getTokenSubject(ctx, sqliteAccessToken) + const keychainSubject = getTokenSubject(ctx, keychainAccessToken) + const hasDifferentSubjects = !!sqliteSubject && !!keychainSubject && sqliteSubject !== keychainSubject + const sqliteLooksFree = sqliteMembershipType === "free" + if (sqliteAccessToken || sqliteRefreshToken) { + if ((keychainAccessToken || keychainRefreshToken) && sqliteLooksFree && hasDifferentSubjects) { + ctx.host.log.info("sqlite auth looks free and differs from keychain account; preferring keychain token") + return { + accessToken: keychainAccessToken, + refreshToken: keychainRefreshToken, + source: "keychain", + } + } + return { accessToken: sqliteAccessToken, refreshToken: sqliteRefreshToken, @@ -90,8 +112,6 @@ } } - const keychainAccessToken = readKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE) - const keychainRefreshToken = readKeychainValue(ctx, KEYCHAIN_REFRESH_TOKEN_SERVICE) if (keychainAccessToken || keychainRefreshToken) { return { accessToken: keychainAccessToken, @@ -107,6 +127,14 @@ } } + function getTokenSubject(ctx, token) { + if (!token) return null + const payload = ctx.jwt.decodePayload(token) + if (!payload || typeof payload.sub !== "string") return null + const subject = payload.sub.trim() + return subject || null + } + function persistAccessToken(ctx, source, accessToken) { if (source === "keychain") { return writeKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE, accessToken) @@ -449,9 +477,18 @@ ? planName.toLowerCase() : "" - // Enterprise and some Team request-based accounts return no planUsage from - // the Connect API. Detect them and use the REST usage API instead. - const needsRequestBasedFallback = usage.enabled !== false && !usage.planUsage && ( + const hasPlanUsage = !!usage.planUsage + const hasPlanUsageLimit = hasPlanUsage && + typeof usage.planUsage.limit === "number" && + Number.isFinite(usage.planUsage.limit) + const planUsageLimitMissing = hasPlanUsage && !hasPlanUsageLimit + const hasTotalUsagePercent = hasPlanUsage && + typeof usage.planUsage.totalPercentUsed === "number" && + Number.isFinite(usage.planUsage.totalPercentUsed) + + // Enterprise and some Team request-based accounts can return no planUsage + // or a planUsage object without limit from the Connect API. + const needsRequestBasedFallback = usage.enabled !== false && (!hasPlanUsage || planUsageLimitMissing) && ( normalizedPlanName === "enterprise" || normalizedPlanName === "team" ) @@ -465,7 +502,7 @@ } const needsFallbackWithoutPlanInfo = usage.enabled !== false && - !usage.planUsage && + (!hasPlanUsage || planUsageLimitMissing) && !normalizedPlanName && planInfoUnavailable if (needsFallbackWithoutPlanInfo) { @@ -473,6 +510,15 @@ return buildUnknownRequestBasedResult(ctx, accessToken, planName) } + if (usage.enabled !== false && planUsageLimitMissing && !hasTotalUsagePercent) { + ctx.host.log.warn("planUsage.limit missing, attempting REST usage API fallback") + try { + return buildUnknownRequestBasedResult(ctx, accessToken, planName) + } catch (e) { + ctx.host.log.warn("REST usage fallback unavailable: " + String(e)) + } + } + // Team plans may omit `enabled` even with valid plan usage data. if (usage.enabled === false || !usage.planUsage) { throw "No active Cursor subscription." @@ -521,16 +567,18 @@ } // Total usage (always present) - fallback primary metric - if (typeof pu.limit !== "number") { + if (!hasPlanUsageLimit && !hasTotalUsagePercent) { throw "Total usage limit missing from API response." } - const planUsed = typeof pu.totalSpend === "number" - ? pu.totalSpend - : pu.limit - (pu.remaining ?? 0) - const computedPercentUsed = pu.limit > 0 + const planUsed = hasPlanUsageLimit + ? (typeof pu.totalSpend === "number" + ? pu.totalSpend + : pu.limit - (pu.remaining ?? 0)) + : 0 + const computedPercentUsed = hasPlanUsageLimit && pu.limit > 0 ? (planUsed / pu.limit) * 100 : 0 - const totalUsagePercent = Number.isFinite(pu.totalPercentUsed) + const totalUsagePercent = hasTotalUsagePercent ? pu.totalPercentUsed : computedPercentUsed @@ -550,6 +598,9 @@ ) if (isTeamAccount) { + if (!hasPlanUsageLimit) { + throw "Total usage limit missing from API response." + } lines.push(ctx.line.progress({ label: "Total usage", used: ctx.fmt.dollars(planUsed), diff --git a/plugins/cursor/plugin.test.js b/plugins/cursor/plugin.test.js index 30a2db3..ce14c75 100644 --- a/plugins/cursor/plugin.test.js +++ b/plugins/cursor/plugin.test.js @@ -136,7 +136,61 @@ describe("cursor plugin", () => { const result = plugin.probe(ctx) expect(result.lines.find((line) => line.label === "Total usage")).toBeTruthy() - expect(ctx.host.keychain.readGenericPassword).not.toHaveBeenCalled() + expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledWith("cursor-access-token") + expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledWith("cursor-refresh-token") + }) + + it("prefers keychain when sqlite looks free and token subjects differ", async () => { + const ctx = makeCtx() + const sqlitePayload = Buffer.from( + JSON.stringify({ exp: 9999999999, sub: "google-oauth2|sqlite-user" }), + "utf8" + ) + .toString("base64") + .replace(/=+$/g, "") + const sqliteToken = `a.${sqlitePayload}.c` + + const keychainPayload = Buffer.from( + JSON.stringify({ exp: 9999999999, sub: "auth0|keychain-user" }), + "utf8" + ) + .toString("base64") + .replace(/=+$/g, "") + const keychainToken = `a.${keychainPayload}.c` + + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) { + return JSON.stringify([{ value: sqliteToken }]) + } + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "sqlite-refresh-token" }]) + } + if (String(sql).includes("cursorAuth/stripeMembershipType")) { + return JSON.stringify([{ value: "free" }]) + } + return JSON.stringify([]) + }) + ctx.host.keychain.readGenericPassword.mockImplementation((service) => { + if (service === "cursor-access-token") return keychainToken + if (service === "cursor-refresh-token") return "keychain-refresh-token" + return null + }) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("GetCurrentPeriodUsage")) { + expect(opts.headers.Authorization).toBe("Bearer " + keychainToken) + } + return { + status: 200, + bodyText: JSON.stringify({ + enabled: true, + planUsage: { totalSpend: 1200, limit: 2400 }, + }), + } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines.find((line) => line.label === "Total usage")).toBeTruthy() }) it("throws on sqlite errors when reading token", async () => { @@ -222,6 +276,49 @@ describe("cursor plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Total usage limit missing") }) + it("uses percent-only usage when totalPercentUsed exists but limit is missing", async () => { + const ctx = makeCtx() + ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }])) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("GetCurrentPeriodUsage")) { + return { + status: 200, + bodyText: JSON.stringify({ + enabled: true, + billingCycleStart: "1772556293029", + billingCycleEnd: "1775234693029", + planUsage: { + autoPercentUsed: 0, + apiPercentUsed: 0, + totalPercentUsed: 0, + }, + }), + } + } + if (String(opts.url).includes("GetPlanInfo")) { + return { + status: 200, + bodyText: JSON.stringify({ + planInfo: { planName: "Free" }, + }), + } + } + if (String(opts.url).includes("cursor.com/api/usage")) { + throw new Error("unexpected REST usage fallback") + } + return { status: 200, bodyText: "{}" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.plan).toBe("Free") + const totalLine = result.lines.find((line) => line.label === "Total usage") + expect(totalLine).toBeTruthy() + expect(totalLine.format).toEqual({ kind: "percent" }) + expect(totalLine.used).toBe(0) + expect(totalLine.limit).toBe(100) + }) + it("falls back to computed percent when totalSpend missing and no totalPercentUsed", async () => { const ctx = makeCtx() ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }])) @@ -413,6 +510,58 @@ describe("cursor plugin", () => { expect(reqLine.format).toEqual({ kind: "count", suffix: "requests" }) }) + it("falls back to enterprise request-based usage when planUsage.limit is missing", async () => { + const ctx = makeCtx() + const accessToken = makeJwt({ sub: "google-oauth2|user_abc123", exp: 9999999999 }) + + ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: accessToken }])) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("GetCurrentPeriodUsage")) { + return { + status: 200, + bodyText: JSON.stringify({ + enabled: true, + billingCycleStart: "1770539602363", + billingCycleEnd: "1770539602363", + planUsage: { + totalSpend: 1234, + totalPercentUsed: 12, + }, + }), + } + } + if (String(opts.url).includes("GetPlanInfo")) { + return { + status: 200, + bodyText: JSON.stringify({ + planInfo: { planName: "Enterprise" }, + }), + } + } + if (String(opts.url).includes("cursor.com/api/usage")) { + return { + status: 200, + bodyText: JSON.stringify({ + "gpt-4": { + numRequests: 211, + maxRequestUsage: 500, + }, + startOfMonth: "2026-02-01T06:12:57.000Z", + }), + } + } + return { status: 200, bodyText: "{}" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.plan).toBe("Enterprise") + const reqLine = result.lines.find((line) => line.label === "Requests") + expect(reqLine).toBeTruthy() + expect(reqLine.used).toBe(211) + expect(reqLine.limit).toBe(500) + }) + it("handles team account with request-based usage", async () => { const ctx = makeCtx() const accessToken = makeJwt({ sub: "google-oauth2|user_abc123", exp: 9999999999 }) From cbb7b082046d479d0ad5fcaa4102135cd91270d0 Mon Sep 17 00:00:00 2001 From: drewwells Date: Tue, 3 Mar 2026 11:45:19 -0600 Subject: [PATCH 2/3] fix(cursor): skip fallback when percent usage is available --- plugins/cursor/plugin.js | 1 + plugins/cursor/plugin.test.js | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index 15aa6d0..eaef1c1 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -503,6 +503,7 @@ const needsFallbackWithoutPlanInfo = usage.enabled !== false && (!hasPlanUsage || planUsageLimitMissing) && + !hasTotalUsagePercent && !normalizedPlanName && planInfoUnavailable if (needsFallbackWithoutPlanInfo) { diff --git a/plugins/cursor/plugin.test.js b/plugins/cursor/plugin.test.js index ce14c75..a070698 100644 --- a/plugins/cursor/plugin.test.js +++ b/plugins/cursor/plugin.test.js @@ -319,6 +319,42 @@ describe("cursor plugin", () => { expect(totalLine.limit).toBe(100) }) + it("renders percent-only usage when plan info is unavailable", async () => { + const ctx = makeCtx() + ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }])) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("GetCurrentPeriodUsage")) { + return { + status: 200, + bodyText: JSON.stringify({ + enabled: true, + billingCycleStart: "1772556293029", + billingCycleEnd: "1775234693029", + planUsage: { + totalPercentUsed: 42, + }, + }), + } + } + if (String(opts.url).includes("GetPlanInfo")) { + return { status: 503, bodyText: "" } + } + if (String(opts.url).includes("cursor.com/api/usage")) { + throw new Error("unexpected REST usage fallback") + } + return { status: 200, bodyText: "{}" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.plan).toBeNull() + const totalLine = result.lines.find((line) => line.label === "Total usage") + expect(totalLine).toBeTruthy() + expect(totalLine.format).toEqual({ kind: "percent" }) + expect(totalLine.used).toBe(42) + expect(totalLine.limit).toBe(100) + }) + it("falls back to computed percent when totalSpend missing and no totalPercentUsed", async () => { const ctx = makeCtx() ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }])) From ad4ed733a6898129d8d279d2de75fff660b7aeaa Mon Sep 17 00:00:00 2001 From: drewwells Date: Wed, 4 Mar 2026 07:10:41 -0600 Subject: [PATCH 3/3] fix(cursor): use REST fallback for team-inferred accounts missing planUsage.limit When an account is team-inferred via spendLimitUsage but planUsage.limit is missing and plan info is unavailable, the previous code threw a hard failure. Now falls back to the REST usage API instead. Made-with: Cursor --- plugins/cursor/plugin.js | 3 ++- plugins/cursor/plugin.test.js | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index eaef1c1..4229919 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -600,7 +600,8 @@ if (isTeamAccount) { if (!hasPlanUsageLimit) { - throw "Total usage limit missing from API response." + ctx.host.log.warn("team-inferred account missing planUsage.limit, attempting REST usage API fallback") + return buildUnknownRequestBasedResult(ctx, accessToken, planName) } lines.push(ctx.line.progress({ label: "Total usage", diff --git a/plugins/cursor/plugin.test.js b/plugins/cursor/plugin.test.js index a070698..88ca3f6 100644 --- a/plugins/cursor/plugin.test.js +++ b/plugins/cursor/plugin.test.js @@ -598,6 +598,56 @@ describe("cursor plugin", () => { expect(reqLine.limit).toBe(500) }) + it("falls back to REST usage for team-inferred account with percent-only and unavailable plan info", async () => { + const ctx = makeCtx() + const accessToken = makeJwt({ sub: "google-oauth2|user_abc123", exp: 9999999999 }) + + ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: accessToken }])) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("GetCurrentPeriodUsage")) { + return { + status: 200, + bodyText: JSON.stringify({ + enabled: true, + billingCycleStart: "1772556293029", + billingCycleEnd: "1775234693029", + planUsage: { + totalPercentUsed: 35, + }, + spendLimitUsage: { + limitType: "team", + pooledLimit: 5000, + }, + }), + } + } + if (String(opts.url).includes("GetPlanInfo")) { + return { status: 503, bodyText: "" } + } + if (String(opts.url).includes("cursor.com/api/usage")) { + return { + status: 200, + bodyText: JSON.stringify({ + "gpt-4": { + numRequests: 150, + maxRequestUsage: 500, + }, + startOfMonth: "2026-02-01T06:12:57.000Z", + }), + } + } + return { status: 200, bodyText: "{}" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const reqLine = result.lines.find((l) => l.label === "Requests") + expect(reqLine).toBeTruthy() + expect(reqLine.used).toBe(150) + expect(reqLine.limit).toBe(500) + expect(reqLine.format).toEqual({ kind: "count", suffix: "requests" }) + }) + it("handles team account with request-based usage", async () => { const ctx = makeCtx() const accessToken = makeJwt({ sub: "google-oauth2|user_abc123", exp: 9999999999 })