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,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<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 {
|
||||
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<unknown | null>;
|
||||
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<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 () => {
|
||||
process.env["ZAI_API_KEY"] = "zai_test_token";
|
||||
fetchApi = mockFetch(
|
||||
|
||||
Reference in New Issue
Block a user