mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 01:02:41 +00:00
Cursor discovery used one constant account key across all credential stores. Derive that key from the token subject in both discovery paths, and isolate unreadable identities by store, so host merging preserves each account while shared accounts still deduplicate. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
440 lines
17 KiB
TypeScript
440 lines
17 KiB
TypeScript
import { mkdirSync, writeFileSync, mkdtempSync, rmSync } 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";
|
|
import { discover, discoverSession, fetchUsage } from "./usage.js";
|
|
import type { UsageReport } from "@getpaseo/plugin/server/usage";
|
|
|
|
// 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 writeCursorAuthJson(homeDir: string, accessToken: string): void {
|
|
const dir = join(homeDir, ".config", "cursor");
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(dir, "auth.json"), JSON.stringify({ accessToken }));
|
|
}
|
|
|
|
function mockFetch(handlers: Map<string, () => Response>): typeof fetch {
|
|
return vi.fn(async (url: RequestInfo | URL) => {
|
|
const key = url.toString();
|
|
const handler = handlers.get(key);
|
|
if (!handler) throw new Error(`Unmocked fetch: ${key}`);
|
|
return handler();
|
|
}) as unknown as typeof fetch;
|
|
}
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
describe("cursor usage source", () => {
|
|
let homeDir: string;
|
|
let fetchApi: typeof fetch;
|
|
let originalEnv: Record<string, string | undefined>;
|
|
beforeEach(() => {
|
|
homeDir = mkdtempSync(join(tmpdir(), "usage-home-"));
|
|
originalEnv = { ...process.env };
|
|
process.env["HOME"] = homeDir;
|
|
process.env["USERPROFILE"] = homeDir;
|
|
for (const key of [
|
|
"APPDATA",
|
|
"COPILOT_TOKEN",
|
|
"GITHUB_TOKEN",
|
|
"GITHUB_PAT",
|
|
"CURSOR_ACCESS_TOKEN",
|
|
"CURSOR_TOKEN",
|
|
"ZAI_API_KEY",
|
|
"GLM_API_KEY",
|
|
"GROK_API_KEY",
|
|
"GROK_TOKEN",
|
|
"KIMI_TOKEN",
|
|
"KIMI_API_KEY",
|
|
"KIMI_CODE_HOME",
|
|
"MINIMAX_API_KEY",
|
|
"MINIMAX_BASE_URL",
|
|
])
|
|
delete process.env[key];
|
|
fetchApi = mockFetch(new Map());
|
|
});
|
|
afterEach(() => {
|
|
rmSync(homeDir, { recursive: true, force: true });
|
|
for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key];
|
|
for (const key in originalEnv) process.env[key] = originalEnv[key];
|
|
});
|
|
function service(
|
|
_options: {
|
|
platform?: typeof process.platform;
|
|
keychain?: () => Promise<unknown | null>;
|
|
cursorHomeDir?: string;
|
|
kimiHomeDir?: string;
|
|
} = {},
|
|
) {
|
|
return {
|
|
listUsage: async () => {
|
|
const report = await fetchFirst((url, init) => fetchApi(url, init));
|
|
return {
|
|
providers: [
|
|
{
|
|
providerId: "cursor",
|
|
...report,
|
|
error: report.status === "error" ? report.error : null,
|
|
planLabel: report.status === "available" ? (report.planLabel ?? null) : null,
|
|
},
|
|
],
|
|
};
|
|
},
|
|
};
|
|
}
|
|
function findProvider(
|
|
result: { providers: Array<{ providerId: string } & UsageReport> },
|
|
id: string,
|
|
) {
|
|
const report = result.providers.find((item) => item.providerId === id);
|
|
if (!report) throw new Error(`Missing usage source ${id}`);
|
|
return report;
|
|
}
|
|
it("fetches Cursor usage and normalizes malformed billing dates to null", async () => {
|
|
process.env["CURSOR_ACCESS_TOKEN"] = "cursor_test_token";
|
|
fetchApi = mockFetch(
|
|
new Map([
|
|
[
|
|
"https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
|
|
() =>
|
|
jsonResponse({
|
|
planUsage: {
|
|
totalSpend: "1500",
|
|
includedSpend: "1000",
|
|
bonusSpend: "500",
|
|
remaining: "2500",
|
|
limit: "4000",
|
|
},
|
|
billingCycleStart: "2026-01-14T12:42:14.000Z",
|
|
billingCycleEnd: "not-a-date",
|
|
}),
|
|
],
|
|
]),
|
|
);
|
|
|
|
const cursor = findProvider(await service().listUsage(), "cursor");
|
|
|
|
expect(cursor).toMatchObject({
|
|
status: "available",
|
|
balances: [
|
|
expect.objectContaining({
|
|
id: "plan_usage",
|
|
used: 15,
|
|
remaining: 25,
|
|
limit: 40,
|
|
resetsAt: null,
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
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("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<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_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<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_desktop_jwt");
|
|
expect(cursor.status).toBe("available");
|
|
});
|
|
|
|
it("omits unreadable state.vscdb", async () => {
|
|
const directory = join(homeDir, ".config", "Cursor", "User", "globalStorage");
|
|
mkdirSync(directory, { recursive: true });
|
|
writeFileSync(join(directory, "state.vscdb"), "invalid database");
|
|
expect(await discover()).toEqual([]);
|
|
});
|
|
|
|
describe("session discovery", () => {
|
|
let sessionHome: string;
|
|
beforeEach(() => {
|
|
sessionHome = mkdtempSync(join(tmpdir(), "usage-session-home-"));
|
|
});
|
|
afterEach(() => {
|
|
rmSync(sessionHome, { recursive: true, force: true });
|
|
});
|
|
function cursorSession(env: Record<string, string>, provider = "cursor") {
|
|
return { kind: "session" as const, provider, env: { HOME: sessionHome, ...env } };
|
|
}
|
|
function writeAuthJson(path: string): void {
|
|
mkdirSync(join(path, ".."), { recursive: true });
|
|
writeFileSync(path, JSON.stringify({ accessToken: "session-token" }));
|
|
}
|
|
|
|
it("keeps different accounts separate while joining global and session logins for the same account", async () => {
|
|
function token(sub: string, nonce: string) {
|
|
return `e30.${Buffer.from(JSON.stringify({ sub, nonce })).toString("base64url")}.signature`;
|
|
}
|
|
writeCursorAuthJson(homeDir, token("user-a", "desktop"));
|
|
writeCursorAuthJson(sessionHome, token("user-b", "session"));
|
|
const globalAccounts = await discover();
|
|
const differentAccount = await discoverSession(cursorSession({}), "linux");
|
|
expect(globalAccounts).toHaveLength(1);
|
|
expect(differentAccount).toHaveLength(1);
|
|
expect(differentAccount[0]!.key).not.toBe(globalAccounts[0]!.key);
|
|
|
|
writeCursorAuthJson(sessionHome, token("user-a", "refreshed-session"));
|
|
const sameAccount = await discoverSession(cursorSession({}), "linux");
|
|
expect(sameAccount).toHaveLength(1);
|
|
expect(sameAccount[0]!.key).toBe(globalAccounts[0]!.key);
|
|
});
|
|
|
|
it("isolates logins without a readable subject by store and keeps their key across token rotation", async () => {
|
|
writeCursorAuthJson(homeDir, "opaque-global-token");
|
|
writeCursorAuthJson(sessionHome, "opaque-session-token");
|
|
const globalAccounts = await discover();
|
|
const sessionAccounts = await discoverSession(cursorSession({}), "linux");
|
|
expect(sessionAccounts).toHaveLength(1);
|
|
expect(globalAccounts).toHaveLength(1);
|
|
expect(sessionAccounts[0]!.key).not.toBe(globalAccounts[0]!.key);
|
|
writeCursorAuthJson(sessionHome, "e30.invalid-json.signature");
|
|
expect(await discoverSession(cursorSession({}), "linux")).toEqual(sessionAccounts);
|
|
writeCursorAuthJson(sessionHome, "e30.e30.signature");
|
|
expect(await discoverSession(cursorSession({}), "linux")).toEqual(sessionAccounts);
|
|
});
|
|
|
|
it("reads the login cursor-agent uses under the session's XDG_CONFIG_HOME", async () => {
|
|
const configHome = join(sessionHome, "xdg");
|
|
const authPath = join(configHome, "cursor", "auth.json");
|
|
writeAuthJson(authPath);
|
|
expect(
|
|
await discoverSession(cursorSession({ XDG_CONFIG_HOME: configHome }), "linux"),
|
|
).toEqual([
|
|
{
|
|
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
|
|
input: { store: "file", locator: authPath },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("reads the login under the session's HOME, not the daemon's", async () => {
|
|
writeCursorAuthJson(homeDir, "daemon-token");
|
|
writeCursorAuthJson(sessionHome, "session-token");
|
|
expect(await discoverSession(cursorSession({}), "linux")).toEqual([
|
|
{
|
|
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
|
|
input: { store: "file", locator: join(sessionHome, ".config", "cursor", "auth.json") },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("reads %APPDATA%/Cursor/auth.json on Windows", async () => {
|
|
const appData = join(sessionHome, "AppData", "Roaming");
|
|
const authPath = join(appData, "Cursor", "auth.json");
|
|
writeAuthJson(authPath);
|
|
expect(await discoverSession(cursorSession({ APPDATA: appData }), "win32")).toEqual([
|
|
{
|
|
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
|
|
input: { store: "file", locator: authPath },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("reads ~/.cursor/auth.json on macOS only when cursor-agent uses the file store", async () => {
|
|
const authPath = join(sessionHome, ".cursor", "auth.json");
|
|
writeAuthJson(authPath);
|
|
expect(await discoverSession(cursorSession({}), "darwin")).toEqual([]);
|
|
expect(
|
|
await discoverSession(cursorSession({ AGENT_CLI_CREDENTIAL_STORE: "file" }), "darwin"),
|
|
).toEqual([
|
|
{
|
|
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
|
|
input: { store: "file", locator: authPath },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
["another provider", cursorSession({}, "claude")],
|
|
["a CURSOR_API_KEY session", cursorSession({ CURSOR_API_KEY: "key" })],
|
|
["a CURSOR_AUTH_TOKEN session", cursorSession({ CURSOR_AUTH_TOKEN: "token" })],
|
|
["an in-memory credential store", cursorSession({ AGENT_CLI_CREDENTIAL_STORE: "memory" })],
|
|
])("returns no login for %s", async (_scenario, scope) => {
|
|
writeCursorAuthJson(sessionHome, "session-token");
|
|
expect(await discoverSession(scope, "linux")).toEqual([]);
|
|
});
|
|
|
|
it("never falls back to the daemon's default stores", async () => {
|
|
writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "desktop-token" });
|
|
writeCursorAuthJson(homeDir, "daemon-token");
|
|
process.env["CURSOR_ACCESS_TOKEN"] = "daemon-env-token";
|
|
expect(await discoverSession(cursorSession({}), "linux")).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
it("discovery returns a locator when fetch finds cursor credentials", async () => {
|
|
const previous = process.env["CURSOR_ACCESS_TOKEN"];
|
|
try {
|
|
process.env["CURSOR_ACCESS_TOKEN"] = "fixture-token";
|
|
let requested = false;
|
|
await fetchFirst(async () => {
|
|
requested = true;
|
|
return new Response(null, { status: 401 });
|
|
});
|
|
expect(requested).toBe(true);
|
|
expect(await discover()).toEqual([
|
|
{
|
|
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
|
|
input: { store: "env", locator: "CURSOR_ACCESS_TOKEN" },
|
|
},
|
|
]);
|
|
} finally {
|
|
if (previous === undefined) delete process.env["CURSOR_ACCESS_TOKEN"];
|
|
else process.env["CURSOR_ACCESS_TOKEN"] = previous;
|
|
}
|
|
});
|
|
|
|
describe("account discovery", () => {
|
|
it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => {
|
|
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
|
const directory = await mkdtemp(join(tmpdir(), "usage-empty-"));
|
|
const original = { ...process.env };
|
|
try {
|
|
for (const key of Object.keys(process.env)) delete process.env[key];
|
|
process.env.HOME = directory;
|
|
process.env.USERPROFILE = directory;
|
|
if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}");
|
|
expect(await discover()).toEqual([]);
|
|
} finally {
|
|
for (const key of Object.keys(process.env)) delete process.env[key];
|
|
Object.assign(process.env, original);
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
async function fetchFirst(fetchApi: typeof fetch) {
|
|
const accounts = await discover();
|
|
const account = accounts[0];
|
|
if (!account) throw new Error("No configured account");
|
|
return fetchUsage(account.input as Parameters<typeof fetchUsage>[0], fetchApi);
|
|
}
|
|
|
|
it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => {
|
|
const previous = process.env["CURSOR_ACCESS_TOKEN"];
|
|
try {
|
|
process.env["CURSOR_ACCESS_TOKEN"] = "fixture-rejected-login";
|
|
const report = await fetchUsage(
|
|
{ store: "env", locator: "CURSOR_ACCESS_TOKEN" },
|
|
async () => new Response(null, { status }),
|
|
);
|
|
expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } });
|
|
} finally {
|
|
if (previous === undefined) delete process.env["CURSOR_ACCESS_TOKEN"];
|
|
else process.env["CURSOR_ACCESS_TOKEN"] = previous;
|
|
}
|
|
});
|