|
1 | 1 | (function () { |
2 | 2 | const STATE_DB = |
3 | 3 | "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb" |
| 4 | + const KEYCHAIN_ACCESS_TOKEN_SERVICE = "cursor-access-token" |
| 5 | + const KEYCHAIN_REFRESH_TOKEN_SERVICE = "cursor-refresh-token" |
4 | 6 | const BASE_URL = "https://api2.cursor.sh" |
5 | 7 | const USAGE_URL = BASE_URL + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage" |
6 | 8 | const PLAN_URL = BASE_URL + "/aiserver.v1.DashboardService/GetPlanInfo" |
|
9 | 11 | const REST_USAGE_URL = "https://cursor.com/api/usage" |
10 | 12 | const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB" |
11 | 13 | const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration |
| 14 | + const LOGIN_HINT = "Sign in via Cursor app or run `agent login`." |
12 | 15 |
|
13 | 16 | function readStateValue(ctx, key) { |
14 | 17 | try { |
|
46 | 49 | } |
47 | 50 | } |
48 | 51 |
|
| 52 | + function readKeychainValue(ctx, service) { |
| 53 | + if (!ctx.host.keychain || typeof ctx.host.keychain.readGenericPassword !== "function") { |
| 54 | + return null |
| 55 | + } |
| 56 | + try { |
| 57 | + const value = ctx.host.keychain.readGenericPassword(service) |
| 58 | + if (typeof value !== "string") return null |
| 59 | + const trimmed = value.trim() |
| 60 | + return trimmed || null |
| 61 | + } catch (e) { |
| 62 | + ctx.host.log.info("keychain read failed for " + service + ": " + String(e)) |
| 63 | + return null |
| 64 | + } |
| 65 | + } |
| 66 | + |
| 67 | + function writeKeychainValue(ctx, service, value) { |
| 68 | + if (!ctx.host.keychain || typeof ctx.host.keychain.writeGenericPassword !== "function") { |
| 69 | + ctx.host.log.warn("keychain write unsupported") |
| 70 | + return false |
| 71 | + } |
| 72 | + try { |
| 73 | + ctx.host.keychain.writeGenericPassword(service, String(value)) |
| 74 | + return true |
| 75 | + } catch (e) { |
| 76 | + ctx.host.log.warn("keychain write failed for " + service + ": " + String(e)) |
| 77 | + return false |
| 78 | + } |
| 79 | + } |
| 80 | + |
| 81 | + function loadAuthState(ctx) { |
| 82 | + const sqliteAccessToken = readStateValue(ctx, "cursorAuth/accessToken") |
| 83 | + const sqliteRefreshToken = readStateValue(ctx, "cursorAuth/refreshToken") |
| 84 | + if (sqliteAccessToken || sqliteRefreshToken) { |
| 85 | + return { |
| 86 | + accessToken: sqliteAccessToken, |
| 87 | + refreshToken: sqliteRefreshToken, |
| 88 | + source: "sqlite", |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + const keychainAccessToken = readKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE) |
| 93 | + const keychainRefreshToken = readKeychainValue(ctx, KEYCHAIN_REFRESH_TOKEN_SERVICE) |
| 94 | + if (keychainAccessToken || keychainRefreshToken) { |
| 95 | + return { |
| 96 | + accessToken: keychainAccessToken, |
| 97 | + refreshToken: keychainRefreshToken, |
| 98 | + source: "keychain", |
| 99 | + } |
| 100 | + } |
| 101 | + |
| 102 | + return { |
| 103 | + accessToken: null, |
| 104 | + refreshToken: null, |
| 105 | + source: null, |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + function persistAccessToken(ctx, source, accessToken) { |
| 110 | + if (source === "keychain") { |
| 111 | + return writeKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE, accessToken) |
| 112 | + } |
| 113 | + return writeStateValue(ctx, "cursorAuth/accessToken", accessToken) |
| 114 | + } |
| 115 | + |
49 | 116 | function getTokenExpiration(ctx, token) { |
50 | 117 | const payload = ctx.jwt.decodePayload(token) |
51 | 118 | if (!payload || typeof payload.exp !== "number") return null |
|
62 | 129 | }) |
63 | 130 | } |
64 | 131 |
|
65 | | - function refreshToken(ctx, refreshTokenValue) { |
| 132 | + function refreshToken(ctx, refreshTokenValue, source) { |
66 | 133 | if (!refreshTokenValue) { |
67 | 134 | ctx.host.log.warn("refresh skipped: no refresh token") |
68 | 135 | return null |
|
88 | 155 | const shouldLogout = errorInfo && errorInfo.shouldLogout === true |
89 | 156 | ctx.host.log.error("refresh failed: status=" + resp.status + " shouldLogout=" + shouldLogout) |
90 | 157 | if (shouldLogout) { |
91 | | - throw "Session expired. Sign in via Cursor app." |
| 158 | + throw "Session expired. " + LOGIN_HINT |
92 | 159 | } |
93 | | - throw "Token expired. Sign in via Cursor app." |
| 160 | + throw "Token expired. " + LOGIN_HINT |
94 | 161 | } |
95 | 162 |
|
96 | 163 | if (resp.status < 200 || resp.status >= 300) { |
|
107 | 174 | // Check if server wants us to logout |
108 | 175 | if (body.shouldLogout === true) { |
109 | 176 | ctx.host.log.error("refresh response indicates shouldLogout=true") |
110 | | - throw "Session expired. Sign in via Cursor app." |
| 177 | + throw "Session expired. " + LOGIN_HINT |
111 | 178 | } |
112 | 179 |
|
113 | 180 | const newAccessToken = body.access_token |
|
116 | 183 | return null |
117 | 184 | } |
118 | 185 |
|
119 | | - // Persist updated access token to SQLite |
120 | | - writeStateValue(ctx, "cursorAuth/accessToken", newAccessToken) |
| 186 | + // Persist updated access token to source where auth was loaded from. |
| 187 | + persistAccessToken(ctx, source, newAccessToken) |
121 | 188 | ctx.host.log.info("refresh succeeded, token persisted") |
122 | 189 |
|
123 | 190 | // Note: Cursor refresh returns access_token which is used as both |
|
221 | 288 | } |
222 | 289 |
|
223 | 290 | function probe(ctx) { |
224 | | - let accessToken = readStateValue(ctx, "cursorAuth/accessToken") |
225 | | - const refreshTokenValue = readStateValue(ctx, "cursorAuth/refreshToken") |
| 291 | + const authState = loadAuthState(ctx) |
| 292 | + let accessToken = authState.accessToken |
| 293 | + const refreshTokenValue = authState.refreshToken |
| 294 | + const authSource = authState.source |
226 | 295 |
|
227 | 296 | if (!accessToken && !refreshTokenValue) { |
228 | | - ctx.host.log.error("probe failed: no access or refresh token in sqlite") |
229 | | - throw "Not logged in. Sign in via Cursor app." |
| 297 | + ctx.host.log.error("probe failed: no access or refresh token in sqlite/keychain") |
| 298 | + throw "Not logged in. " + LOGIN_HINT |
230 | 299 | } |
231 | | - |
232 | | - ctx.host.log.info("tokens loaded: accessToken=" + (accessToken ? "yes" : "no") + " refreshToken=" + (refreshTokenValue ? "yes" : "no")) |
| 300 | + |
| 301 | + ctx.host.log.info("tokens loaded from " + authSource + ": accessToken=" + (accessToken ? "yes" : "no") + " refreshToken=" + (refreshTokenValue ? "yes" : "no")) |
233 | 302 |
|
234 | 303 | const nowMs = Date.now() |
235 | 304 |
|
|
238 | 307 | ctx.host.log.info("token needs refresh (expired or expiring soon)") |
239 | 308 | let refreshed = null |
240 | 309 | try { |
241 | | - refreshed = refreshToken(ctx, refreshTokenValue) |
| 310 | + refreshed = refreshToken(ctx, refreshTokenValue, authSource) |
242 | 311 | } catch (e) { |
243 | 312 | // If refresh fails but we have an access token, try it anyway |
244 | 313 | ctx.host.log.warn("refresh failed but have access token, will try: " + String(e)) |
|
248 | 317 | accessToken = refreshed |
249 | 318 | } else if (!accessToken) { |
250 | 319 | ctx.host.log.error("refresh failed and no access token available") |
251 | | - throw "Not logged in. Sign in via Cursor app." |
| 320 | + throw "Not logged in. " + LOGIN_HINT |
252 | 321 | } |
253 | 322 | } |
254 | 323 |
|
|
270 | 339 | refresh: () => { |
271 | 340 | ctx.host.log.info("usage returned 401, attempting refresh") |
272 | 341 | didRefresh = true |
273 | | - const refreshed = refreshToken(ctx, refreshTokenValue) |
| 342 | + const refreshed = refreshToken(ctx, refreshTokenValue, authSource) |
274 | 343 | if (refreshed) accessToken = refreshed |
275 | 344 | return refreshed |
276 | 345 | }, |
|
283 | 352 |
|
284 | 353 | if (ctx.util.isAuthStatus(usageResp.status)) { |
285 | 354 | ctx.host.log.error("usage returned auth error after all retries: status=" + usageResp.status) |
286 | | - throw "Token expired. Sign in via Cursor app." |
| 355 | + throw "Token expired. " + LOGIN_HINT |
287 | 356 | } |
288 | 357 |
|
289 | 358 | if (usageResp.status < 200 || usageResp.status >= 300) { |
290 | 359 | ctx.host.log.error("usage returned error: status=" + usageResp.status) |
291 | 360 | throw "Usage request failed (HTTP " + String(usageResp.status) + "). Try again later." |
292 | 361 | } |
293 | | - |
| 362 | + |
294 | 363 | ctx.host.log.info("usage fetch succeeded") |
295 | 364 |
|
296 | 365 | const usage = ctx.util.tryParseJson(usageResp.bodyText) |
|
0 commit comments