diff --git a/packages/server/src/services/quota-fetcher/providers/cursor.ts b/packages/server/src/services/quota-fetcher/providers/cursor.ts index 5f30cae5f..98b499b8d 100644 --- a/packages/server/src/services/quota-fetcher/providers/cursor.ts +++ b/packages/server/src/services/quota-fetcher/providers/cursor.ts @@ -1,8 +1,6 @@ -import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { promisify } from "node:util"; import type { Logger } from "pino"; import { z } from "zod"; import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js"; @@ -16,8 +14,25 @@ import { unavailableUsage, } from "../usage.js"; -const execFileAsync = promisify(execFile); -const CURSOR_SQLITE_TIMEOUT_MS = 2_000; +// Cursor 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 +// 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. +const CURSOR_ACCESS_TOKEN_KEY = "cursorAuth/accessToken"; +const CURSOR_LEGACY_AUTH_KEY = "cursorAuthStatus"; + +// @types/node@20 predates the node:sqlite typings; declare the slice we use. +interface CursorStateStatement { + get(...params: unknown[]): Record | undefined; +} +interface CursorStateDatabase { + prepare(sql: string): CursorStateStatement; + close(): void; +} +interface NodeSqliteModule { + DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => CursorStateDatabase; +} const CursorBillingCycleTimestampSchema = z.preprocess( (value) => (typeof value === "string" || typeof value === "number" ? value : null), @@ -47,6 +62,7 @@ type CursorUsageResponse = z.infer; interface CursorQuotaProviderOptions { logger: Logger; fetch?: ProviderApiFetch; + homeDir?: string; } function parseCursorBillingCycleTimestamp( @@ -70,14 +86,38 @@ function centsToDollars(value: number | null): number | null { return value === null ? null : value / 100; } -async function readCursorTokenFromSqlite(): Promise { +function readItemTableValue(db: CursorStateDatabase, key: string): string | null { + const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key); + const value = row?.["value"]; + if (typeof value === "string") return value; + if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8"); + return null; +} + +function cursorTokenFromDb(db: CursorStateDatabase): string | null { + const modern = readItemTableValue(db, CURSOR_ACCESS_TOKEN_KEY)?.trim(); + 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; + } catch { + // ignore a malformed legacy blob + } + } + return null; +} + +async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promise { const dbPaths: string[] = []; if (process.env["APPDATA"]) { dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb")); } dbPaths.push( join( - homedir(), + homeDir, "Library", "Application Support", "Cursor", @@ -86,22 +126,32 @@ async function readCursorTokenFromSqlite(): Promise { "state.vscdb", ), ); - dbPaths.push(join(homedir(), ".config", "Cursor", "User", "globalStorage", "state.vscdb")); + dbPaths.push(join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb")); + + // Held in a variable so TypeScript skips module resolution: @types/node@20 has no + // node:sqlite typings yet, while the runtime (Node 22+ / Electron) provides it. + const sqliteSpecifier: string = "node:sqlite"; + let sqlite: NodeSqliteModule; + try { + sqlite = (await import(sqliteSpecifier)) as unknown as NodeSqliteModule; + } catch (err) { + logger.debug({ err }, "node:sqlite unavailable; cannot read Cursor state.vscdb"); + return null; // runtime without node:sqlite + } for (const path of dbPaths) { if (!existsSync(path)) continue; + let db: CursorStateDatabase | undefined; try { - const { stdout } = await execFileAsync( - "sqlite3", - [path, "SELECT value FROM ItemTable WHERE key = 'cursorAuthStatus'"], - { timeout: CURSOR_SQLITE_TIMEOUT_MS }, - ); - if (stdout) { - const parsed = CursorAuthStatusSchema.parse(JSON.parse(stdout.trim())); - if (parsed.accessToken) return parsed.accessToken; - } - } catch { - continue; + db = new sqlite.DatabaseSync(path, { readOnly: true }); + const token = cursorTokenFromDb(db); + if (token) return token; + } catch (err) { + // Locked/permission/corrupt/schema failures all land here; log so an + // unavailable Cursor card is diagnosable, then try the next candidate. + logger.debug({ err, path }, "Failed to read Cursor token from state.vscdb"); + } finally { + db?.close(); } } return null; @@ -113,17 +163,19 @@ export class CursorQuotaProvider implements ProviderUsageFetcher { private readonly logger: Logger; private readonly fetchApi: ProviderApiFetch; + private readonly homeDir: string; constructor(options: CursorQuotaProviderOptions) { this.logger = options.logger; this.fetchApi = options.fetch ?? fetch; + this.homeDir = options.homeDir ?? homedir(); } async fetchUsage(): Promise { const token = process.env["CURSOR_ACCESS_TOKEN"] || process.env["CURSOR_TOKEN"] || - (await readCursorTokenFromSqlite()); + (await readCursorTokenFromSqlite(this.homeDir, this.logger)); if (!token) return unavailableUsage(this); diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts index 087f7fcf7..3d4c6ed29 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs"; import { promises as fsPromises } from "node:fs"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -58,6 +59,31 @@ function writeKimiCredentials(dir: string, accessToken: string, overrides: objec ); } +// node:sqlite has no @types/node@20 typings; require it with a narrow local type. +const testRequire = createRequire(import.meta.url); +interface TestSqliteDb { + exec(sql: string): void; + prepare(sql: string): { run(...params: unknown[]): void }; + close(): void; +} + +// Cursor builds have stored ItemTable values as both TEXT and BLOB. Keys map to the +// real layouts: a plain modern token or the legacy JSON object. +function writeCursorStateDb(homeDir: string, rows: Record): void { + const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage"); + mkdirSync(dir, { recursive: true }); + const { DatabaseSync } = testRequire("node:sqlite") as { + DatabaseSync: new (path: string) => TestSqliteDb; + }; + const db = new DatabaseSync(join(dir, "state.vscdb")); + db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); + const insert = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)"); + for (const [key, value] of Object.entries(rows)) { + insert.run(key, value); + } + db.close(); +} + function writeGrokAuth(home: string, auth: Record): void { mkdirSync(join(home, ".grok"), { recursive: true }); writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth)); @@ -372,6 +398,7 @@ describe("real provider usage fetchers", () => { platform?: typeof process.platform; keychain?: () => Promise; kimiHomeDir?: string; + cursorHomeDir?: string; miniMaxConfigPath?: string; miniMaxCredentialsPath?: string; } = {}, @@ -392,7 +419,11 @@ describe("real provider usage fetchers", () => { }), new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }), new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }), - new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }), + new CursorQuotaProvider({ + logger, + fetch: fetchThroughTestDouble, + homeDir: options.cursorHomeDir, + }), new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }), new GrokQuotaProvider({ logger, @@ -702,6 +733,75 @@ describe("real provider usage fetchers", () => { }); }); + it("reads the Cursor token from the modern cursorAuth/accessToken key in state.vscdb", async () => { + writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_state_jwt" }); + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + planUsage: { + totalSpend: "1500", + includedSpend: "1000", + bonusSpend: "500", + remaining: "2500", + limit: "4000", + }, + billingCycleStart: "2026-01-14T12:42:14.000Z", + billingCycleEnd: "2026-02-14T12:42:14.000Z", + }); + }) as unknown as typeof fetch; + + const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); + + expect(authorization).toBe("Bearer cursor_state_jwt"); + expect(cursor).toMatchObject({ + status: "available", + balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })], + }); + }); + + it("falls back to the legacy cursorAuthStatus JSON blob when the modern key is absent", async () => { + writeCursorStateDb(homeDir, { + cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: "cursor_legacy_jwt" }), "utf8"), + }); + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + planUsage: { totalSpend: "0", remaining: "100", limit: "100" }, + billingCycleStart: null, + billingCycleEnd: null, + }); + }) as unknown as typeof fetch; + + const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); + + expect(authorization).toBe("Bearer cursor_legacy_jwt"); + expect(cursor.status).toBe("available"); + }); + + it("logs a debug diagnostic and stays unavailable when state.vscdb is unreadable", async () => { + const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "state.vscdb"), "not a sqlite database"); + const logger = createLogger(); + const provider = new CursorQuotaProvider({ + logger, + fetch: (() => { + throw new Error("usage API should not be called without a token"); + }) as unknown as typeof fetch, + homeDir, + }); + + const usage = await provider.fetchUsage(); + + expect(usage.status).toBe("unavailable"); + expect((logger as unknown as { debug: ReturnType }).debug).toHaveBeenCalledWith( + expect.objectContaining({ path: expect.stringContaining("state.vscdb") }), + expect.stringContaining("Failed to read Cursor token"), + ); + }); + it("fetches Z.ai usage from ZAI_API_KEY", async () => { process.env["ZAI_API_KEY"] = "zai_test_token"; fetchApi = mockFetch(