fix(usage): read Cursor token from modern state.vscdb key via node:sqlite (#2704)
* fix(usage): read Cursor token from modern state.vscdb key via node:sqlite Settings → Usage showed Cursor as unavailable for a normal Cursor desktop login. The quota fetcher read the access token from the legacy `cursorAuthStatus` key, but current Cursor builds store a plain JWT under `cursorAuth/accessToken` with no such legacy row, so the lookup returned null. Read the token with built-in node:sqlite instead of shelling out to a `sqlite3` CLI (absent by default on Windows) — a second, independent cause of the same "token not found → unavailable" failure. Prefer the modern key, fall back to the legacy `cursorAuthStatus` blob for older builds, and log read failures at debug so an unavailable card stays diagnosable. Fixes #2586 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(usage): cover Cursor sqlite text and blob auth --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Mohamed Boudra
parent
6ed364ffe2
commit
2c5c629db8
@@ -1,8 +1,6 @@
|
|||||||
import { execFile } from "node:child_process";
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
|
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
|
||||||
@@ -16,8 +14,25 @@ import {
|
|||||||
unavailableUsage,
|
unavailableUsage,
|
||||||
} from "../usage.js";
|
} from "../usage.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
// Cursor stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep the
|
||||||
const CURSOR_SQLITE_TIMEOUT_MS = 2_000;
|
// 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<string, unknown> | undefined;
|
||||||
|
}
|
||||||
|
interface CursorStateDatabase {
|
||||||
|
prepare(sql: string): CursorStateStatement;
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
interface NodeSqliteModule {
|
||||||
|
DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => CursorStateDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
const CursorBillingCycleTimestampSchema = z.preprocess(
|
const CursorBillingCycleTimestampSchema = z.preprocess(
|
||||||
(value) => (typeof value === "string" || typeof value === "number" ? value : null),
|
(value) => (typeof value === "string" || typeof value === "number" ? value : null),
|
||||||
@@ -47,6 +62,7 @@ type CursorUsageResponse = z.infer<typeof CursorUsageResponseSchema>;
|
|||||||
interface CursorQuotaProviderOptions {
|
interface CursorQuotaProviderOptions {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
fetch?: ProviderApiFetch;
|
fetch?: ProviderApiFetch;
|
||||||
|
homeDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCursorBillingCycleTimestamp(
|
function parseCursorBillingCycleTimestamp(
|
||||||
@@ -70,14 +86,38 @@ function centsToDollars(value: number | null): number | null {
|
|||||||
return value === null ? null : value / 100;
|
return value === null ? null : value / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readCursorTokenFromSqlite(): Promise<string | null> {
|
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<string | null> {
|
||||||
const dbPaths: string[] = [];
|
const dbPaths: string[] = [];
|
||||||
if (process.env["APPDATA"]) {
|
if (process.env["APPDATA"]) {
|
||||||
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
|
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
|
||||||
}
|
}
|
||||||
dbPaths.push(
|
dbPaths.push(
|
||||||
join(
|
join(
|
||||||
homedir(),
|
homeDir,
|
||||||
"Library",
|
"Library",
|
||||||
"Application Support",
|
"Application Support",
|
||||||
"Cursor",
|
"Cursor",
|
||||||
@@ -86,22 +126,32 @@ async function readCursorTokenFromSqlite(): Promise<string | null> {
|
|||||||
"state.vscdb",
|
"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) {
|
for (const path of dbPaths) {
|
||||||
if (!existsSync(path)) continue;
|
if (!existsSync(path)) continue;
|
||||||
|
let db: CursorStateDatabase | undefined;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(
|
db = new sqlite.DatabaseSync(path, { readOnly: true });
|
||||||
"sqlite3",
|
const token = cursorTokenFromDb(db);
|
||||||
[path, "SELECT value FROM ItemTable WHERE key = 'cursorAuthStatus'"],
|
if (token) return token;
|
||||||
{ timeout: CURSOR_SQLITE_TIMEOUT_MS },
|
} catch (err) {
|
||||||
);
|
// Locked/permission/corrupt/schema failures all land here; log so an
|
||||||
if (stdout) {
|
// unavailable Cursor card is diagnosable, then try the next candidate.
|
||||||
const parsed = CursorAuthStatusSchema.parse(JSON.parse(stdout.trim()));
|
logger.debug({ err, path }, "Failed to read Cursor token from state.vscdb");
|
||||||
if (parsed.accessToken) return parsed.accessToken;
|
} finally {
|
||||||
}
|
db?.close();
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -113,17 +163,19 @@ export class CursorQuotaProvider implements ProviderUsageFetcher {
|
|||||||
|
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly fetchApi: ProviderApiFetch;
|
private readonly fetchApi: ProviderApiFetch;
|
||||||
|
private readonly homeDir: string;
|
||||||
|
|
||||||
constructor(options: CursorQuotaProviderOptions) {
|
constructor(options: CursorQuotaProviderOptions) {
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
this.fetchApi = options.fetch ?? fetch;
|
this.fetchApi = options.fetch ?? fetch;
|
||||||
|
this.homeDir = options.homeDir ?? homedir();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchUsage(): Promise<ProviderUsage> {
|
async fetchUsage(): Promise<ProviderUsage> {
|
||||||
const token =
|
const token =
|
||||||
process.env["CURSOR_ACCESS_TOKEN"] ||
|
process.env["CURSOR_ACCESS_TOKEN"] ||
|
||||||
process.env["CURSOR_TOKEN"] ||
|
process.env["CURSOR_TOKEN"] ||
|
||||||
(await readCursorTokenFromSqlite());
|
(await readCursorTokenFromSqlite(this.homeDir, this.logger));
|
||||||
|
|
||||||
if (!token) return unavailableUsage(this);
|
if (!token) return unavailableUsage(this);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { promises as fsPromises } from "node:fs";
|
import { promises as fsPromises } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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<string, string | Uint8Array>): 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<string, unknown>): void {
|
function writeGrokAuth(home: string, auth: Record<string, unknown>): void {
|
||||||
mkdirSync(join(home, ".grok"), { recursive: true });
|
mkdirSync(join(home, ".grok"), { recursive: true });
|
||||||
writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth));
|
writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth));
|
||||||
@@ -372,6 +398,7 @@ describe("real provider usage fetchers", () => {
|
|||||||
platform?: typeof process.platform;
|
platform?: typeof process.platform;
|
||||||
keychain?: () => Promise<unknown | null>;
|
keychain?: () => Promise<unknown | null>;
|
||||||
kimiHomeDir?: string;
|
kimiHomeDir?: string;
|
||||||
|
cursorHomeDir?: string;
|
||||||
miniMaxConfigPath?: string;
|
miniMaxConfigPath?: string;
|
||||||
miniMaxCredentialsPath?: string;
|
miniMaxCredentialsPath?: string;
|
||||||
} = {},
|
} = {},
|
||||||
@@ -392,7 +419,11 @@ describe("real provider usage fetchers", () => {
|
|||||||
}),
|
}),
|
||||||
new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }),
|
new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }),
|
||||||
new CopilotQuotaProvider({ logger, 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 ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||||
new GrokQuotaProvider({
|
new GrokQuotaProvider({
|
||||||
logger,
|
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<string, string> | 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<string, string> | 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<typeof vi.fn> }).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 () => {
|
it("fetches Z.ai usage from ZAI_API_KEY", async () => {
|
||||||
process.env["ZAI_API_KEY"] = "zai_test_token";
|
process.env["ZAI_API_KEY"] = "zai_test_token";
|
||||||
fetchApi = mockFetch(
|
fetchApi = mockFetch(
|
||||||
|
|||||||
Reference in New Issue
Block a user