Skip to content
Merged
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
107 changes: 75 additions & 32 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
Copy link
Copy Markdown

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 @@ -520,37 +567,22 @@
}))
}

const su = usage.spendLimitUsage
const isTeamAccount = (
normalizedPlanName === "team" ||
(su && su.limitType === "team") ||
(su && typeof su.pooledLimit === "number")
)
const hasFiniteLimit = typeof pu.limit === "number" && Number.isFinite(pu.limit)
const hasFinitePercent = Number.isFinite(pu.totalPercentUsed)

// Free/individual plans can be percent-only; team rendering still needs dollars.
if (isTeamAccount && !hasFiniteLimit) {
// Total usage (always present) - fallback primary metric
if (!hasPlanUsageLimit && !hasTotalUsagePercent) {
throw "Total usage limit missing from API response."
}
if (!isTeamAccount && !hasFiniteLimit && !hasFinitePercent) {
throw "Total usage limit missing from API response."
}

const planUsed = hasFiniteLimit
? (typeof pu.totalSpend === "number" ? pu.totalSpend : pu.limit - (pu.remaining ?? 0))
: null
const computedPercentUsed = hasFiniteLimit
? (pu.limit > 0 ? (planUsed / pu.limit) * 100 : 0)
: null
const totalUsagePercent = hasFinitePercent
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 = hasTotalUsagePercent
? pu.totalPercentUsed
: computedPercentUsed

if (!isTeamAccount && !hasFiniteLimit && hasFinitePercent) {
ctx.host.log.info("total usage limit missing; using totalPercentUsed for individual account")
}

// Calculate billing cycle period duration
var billingPeriodMs = 30 * 24 * 60 * 60 * 1000 // 30 days default
var cycleStart = Number(usage.billingCycleStart)
Expand All @@ -559,7 +591,18 @@
billingPeriodMs = cycleEnd - cycleStart // already in ms
}

const su = usage.spendLimitUsage
const isTeamAccount = (
normalizedPlanName === "team" ||
(su && su.limitType === "team") ||
(su && typeof su.pooledLimit === "number")
)

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
Loading
Loading