Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 65 additions & 12 deletions plugins/cursor/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,36 @@
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,
Comment on lines +99 to +103

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fallback to sqlite when preferred keychain token is stale

In loadAuthState, this branch prefers keychain auth whenever the subjects differ and SQLite looks free, but it does not verify that the keychain credentials are still usable. If the keychain access token is expired and its refresh token is missing/invalid (while SQLite still has a valid token), probe will now fail with auth errors instead of using the working SQLite session, which is a regression introduced by this new preference logic.

Useful? React with 👍 / 👎.

source: "keychain",
}
}

return {
accessToken: sqliteAccessToken,
refreshToken: sqliteRefreshToken,
source: "sqlite",
}
}

const keychainAccessToken = readKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE)
const keychainRefreshToken = readKeychainValue(ctx, KEYCHAIN_REFRESH_TOKEN_SERVICE)
if (keychainAccessToken || keychainRefreshToken) {
return {
accessToken: keychainAccessToken,
Expand All @@ -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)
Expand Down Expand Up @@ -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"
)
Expand All @@ -465,14 +502,24 @@
}

const needsFallbackWithoutPlanInfo = usage.enabled !== false &&
!usage.planUsage &&
(!hasPlanUsage || planUsageLimitMissing) &&
!hasTotalUsagePercent &&
!normalizedPlanName &&
planInfoUnavailable
if (needsFallbackWithoutPlanInfo) {
ctx.host.log.info("plan info unavailable with missing planUsage, attempting REST usage API fallback")
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."
Expand Down Expand Up @@ -521,16 +568,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

Expand All @@ -550,6 +599,10 @@
)

if (isTeamAccount) {
if (!hasPlanUsageLimit) {
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",
used: ctx.fmt.dollars(planUsed),
Expand Down
237 changes: 236 additions & 1 deletion plugins/cursor/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -222,6 +276,85 @@ 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("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" }]))
Expand Down Expand Up @@ -413,6 +546,108 @@ 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("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 })
Expand Down