diff --git a/plugins/cursor-usage-source/server/usage.test.ts b/plugins/cursor-usage-source/server/usage.test.ts index b05159cd12..772f8bfe19 100644 --- a/plugins/cursor-usage-source/server/usage.test.ts +++ b/plugins/cursor-usage-source/server/usage.test.ts @@ -250,6 +250,84 @@ describe("cursor usage source", () => { expect(cursor.status).toBe("available"); }); + describe.each([ + ["modern state.vscdb", "v1"], + ["modern state.vscdb", "v99"], + ["legacy cursorAuthStatus", "v1"], + ["auth.json", "v1"], + ["CURSOR_ACCESS_TOKEN", "v1"], + ["CURSOR_TOKEN", "v1"], + ])("encrypted %s (%s)", (store, version) => { + beforeEach(() => { + const encrypted = `cursor-safe-storage:${version}:${Buffer.from("ciphertext").toString("base64")}`; + if (store === "modern state.vscdb") { + writeCursorStateDb(homeDir, { "cursorAuth/accessToken": encrypted }); + } else if (store === "legacy cursorAuthStatus") { + writeCursorStateDb(homeDir, { + cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: encrypted }), "utf8"), + }); + } else if (store === "auth.json") { + writeCursorAuthJson(homeDir, encrypted); + } else { + process.env[store] = encrypted; + } + }); + + it("discovers no account when the only credential is encrypted", async () => { + expect(await discover()).toEqual([]); + }); + + it("never sends an encrypted bearer token when fetching from a stored locator", async () => { + let input: Parameters[0]; + if (store === "modern state.vscdb" || store === "legacy cursorAuthStatus") { + input = { + store: "sqlite", + locator: join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb"), + }; + } else if (store === "auth.json") { + input = { store: "file", locator: join(homeDir, ".config", "cursor", "auth.json") }; + } else { + input = { store: "env", locator: store }; + } + const requests: RequestInit[] = []; + fetchApi = async (_url, init) => { + requests.push(init ?? {}); + return new Response(null, { status: 401 }); + }; + + await expect(fetchUsage(input, fetchApi)).rejects.toThrow( + "Cursor login store no longer exists", + ); + expect(requests).toEqual([]); + }); + }); + + it("discovers and uses auth.json after skipping an encrypted desktop token", async () => { + writeCursorStateDb(homeDir, { + "cursorAuth/accessToken": `cursor-safe-storage:v1:${Buffer.from("ciphertext").toString("base64")}`, + }); + writeCursorAuthJson(homeDir, "cursor_cli_jwt"); + expect(await discover()).toEqual([ + { + key: expect.stringMatching(/^store-[a-f0-9]{64}$/), + input: { store: "file", locator: join(homeDir, ".config", "cursor", "auth.json") }, + }, + ]); + const authorizations: string[] = []; + fetchApi = async (_url, init) => { + authorizations.push(new Headers(init?.headers).get("Authorization") ?? ""); + return jsonResponse({ + planUsage: { totalSpend: "0", remaining: "100", limit: "100" }, + billingCycleStart: null, + billingCycleEnd: null, + }); + }; + + const cursor = findProvider(await service().listUsage(), "cursor"); + expect(authorizations).toEqual(["Bearer cursor_cli_jwt"]); + expect(cursor.status).toBe("available"); + }); + it("omits unreadable state.vscdb", async () => { const directory = join(homeDir, ".config", "Cursor", "User", "globalStorage"); mkdirSync(directory, { recursive: true }); diff --git a/plugins/cursor-usage-source/server/usage.ts b/plugins/cursor-usage-source/server/usage.ts index 58d5b48ec2..14da499ca9 100644 --- a/plugins/cursor-usage-source/server/usage.ts +++ b/plugins/cursor-usage-source/server/usage.ts @@ -27,9 +27,11 @@ function toIsoStringOrNull(timestampMs: number): string | null { return Number.isFinite(date.getTime()) ? date.toISOString() : null; } -// Cursor desktop stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep -// the access token as a plain JWT string under `cursorAuth/accessToken`; older builds -// kept a JSON blob under `cursorAuthStatus`. Read it with node:sqlite so we don't +// Cursor desktop stores auth in VS Code's ItemTable (state.vscdb). The modern +// `cursorAuth/accessToken` value is a plain JWT or encrypted at rest. Any +// `cursor-safe-storage:` value (any version) is Electron safeStorage ciphertext +// and is skipped. Older builds kept a JSON blob under `cursorAuthStatus`. +// Read it with node:sqlite so we don't // depend on a `sqlite3` CLI, which isn't installed by default on Windows (or on many // Linux hosts) — a missing binary silently rendered Cursor usage unavailable. // Headless hosts (VPS, cursor-agent only) have no desktop db; their session lives in @@ -106,15 +108,21 @@ function readItemTableValue(db: CursorStateDatabase, key: string): string | null return null; } +function usableCursorToken(value: string | null | undefined): string | null { + const token = value?.trim(); + // Electron safeStorage ciphertext cannot authenticate an API request. + return token && !token.startsWith("cursor-safe-storage:") ? token : null; +} + function cursorTokenFromDb(db: CursorStateDatabase): string | null { - const modern = readItemTableValue(db, CURSOR_ACCESS_TOKEN_KEY)?.trim(); + const modern = usableCursorToken(readItemTableValue(db, CURSOR_ACCESS_TOKEN_KEY)); if (modern) return modern; const legacy = readItemTableValue(db, CURSOR_LEGACY_AUTH_KEY); if (legacy) { try { const parsed = CursorAuthStatusSchema.parse(JSON.parse(legacy)); - if (parsed.accessToken) return parsed.accessToken; + return usableCursorToken(parsed.accessToken); } catch { // ignore a malformed legacy blob } @@ -149,7 +157,7 @@ async function readCursorTokenFromAuthJson(path: string): Promise if (!existsSync(path)) return null; try { const parsed = CursorAuthStatusSchema.parse(JSON.parse(await readFile(path, "utf8"))); - return parsed.accessToken?.trim() || null; + return usableCursorToken(parsed.accessToken); } catch { return null; } @@ -230,7 +238,7 @@ export async function fetchUsage( } async function readToken(input: UsageInput): Promise { - if (input.store === "env") return process.env[input.locator]; + if (input.store === "env") return usableCursorToken(process.env[input.locator]) ?? undefined; const token = input.store === "sqlite" ? await readCursorTokenFromSqlite(input.locator)