From 2347242cdfddef3097b47386efffecf2671f8077 Mon Sep 17 00:00:00 2001 From: Lite <166734470+Lite-G@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:46:15 +0800 Subject: [PATCH] fix(usage): read Cursor plan usage from cursor-agent auth.json (#3486) Headless hosts only have ~/.config/cursor/auth.json from a CLI login, not the desktop state.vscdb token. Without that fallback Settings usage stayed unavailable on VPS-style machines. Co-authored-by: Cursor --- docs/providers.md | 2 + .../quota-fetcher/providers/cursor.ts | 25 +++++++-- .../services/quota-fetcher/service.test.ts | 52 +++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index bd348dfba..9ae54d5fb 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -141,6 +141,8 @@ Keep the protocol shape provider-agnostic. Do not add provider-specific renderer Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs. +Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file. + --- ## ACP Provider Checklist diff --git a/packages/server/src/services/quota-fetcher/providers/cursor.ts b/packages/server/src/services/quota-fetcher/providers/cursor.ts index 98b499b8d..fb1cf9493 100644 --- a/packages/server/src/services/quota-fetcher/providers/cursor.ts +++ b/packages/server/src/services/quota-fetcher/providers/cursor.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import type { Logger } from "pino"; @@ -14,11 +15,13 @@ import { unavailableUsage, } from "../usage.js"; -// 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 +// 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 // 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 +// ~/.config/cursor/auth.json instead. const CURSOR_ACCESS_TOKEN_KEY = "cursorAuth/accessToken"; const CURSOR_LEGACY_AUTH_KEY = "cursorAuthStatus"; @@ -157,6 +160,21 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promi return null; } +async function readCursorTokenFromAuthJson( + homeDir: string, + logger: Logger, +): Promise { + const path = join(homeDir, ".config", "cursor", "auth.json"); + if (!existsSync(path)) return null; + try { + const parsed = CursorAuthStatusSchema.parse(JSON.parse(await readFile(path, "utf8"))); + return parsed.accessToken?.trim() || null; + } catch (err) { + logger.debug({ err, path }, "Failed to read Cursor token from auth.json"); + return null; + } +} + export class CursorQuotaProvider implements ProviderUsageFetcher { readonly providerId = "cursor"; readonly displayName = "Cursor"; @@ -175,7 +193,8 @@ export class CursorQuotaProvider implements ProviderUsageFetcher { const token = process.env["CURSOR_ACCESS_TOKEN"] || process.env["CURSOR_TOKEN"] || - (await readCursorTokenFromSqlite(this.homeDir, this.logger)); + (await readCursorTokenFromSqlite(this.homeDir, this.logger)) || + (await readCursorTokenFromAuthJson(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 3d4c6ed29..47d095988 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -84,6 +84,12 @@ function writeCursorStateDb(homeDir: string, rows: Record): void { mkdirSync(join(home, ".grok"), { recursive: true }); writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth)); @@ -780,6 +786,52 @@ describe("real provider usage fetchers", () => { expect(cursor.status).toBe("available"); }); + it("reads the Cursor token from cursor-agent ~/.config/cursor/auth.json when desktop state is absent", async () => { + writeCursorAuthJson(homeDir, "cursor_cli_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_cli_jwt"); + expect(cursor).toMatchObject({ + status: "available", + balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })], + }); + }); + + it("prefers the desktop state.vscdb token over cursor-agent auth.json", async () => { + writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_desktop_jwt" }); + writeCursorAuthJson(homeDir, "cursor_cli_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: "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_desktop_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 });