fix(usage): keep distinct Cursor accounts in separate cards

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.
This commit is contained in:
paseo-bot[bot]
2026-10-19 00:11:33 +02:00
parent 08853039fb
commit cef724e793
3 changed files with 148 additions and 9 deletions
@@ -1,5 +1,72 @@
import { expect, test } from "vitest";
import { UsageSourceRegistry } from "./index.js";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
discoverSession as discoverCursorSession,
fetchUsage as fetchCursorUsage,
} from "../../../../../../plugins/cursor-usage-source/server/usage.js";
import { inputSchema as cursorInputSchema } from "../../../../../../plugins/cursor-usage-source/shared/input.js";
test("#7142: host usage retains distinct Cursor accounts after their individual details are read", async () => {
const root = await mkdtemp(join(tmpdir(), "cursor-accounts-"));
try {
const sessions = new Map<
string,
{ provider: string; sessionKey: string; env: Record<string, string> }
>();
const spends = new Map<string, number>();
for (const [agentId, account, spend] of [
["a", "user-a", 100],
["b", "user-b", 200],
["a-copy", "user-a", 100],
] as const) {
const configHome = join(root, agentId);
const token = `e30.${Buffer.from(JSON.stringify({ sub: account, nonce: agentId })).toString("base64url")}.signature`;
await mkdir(join(configHome, "cursor"), { recursive: true });
await writeFile(
join(configHome, "cursor", "auth.json"),
JSON.stringify({ accessToken: token }),
);
spends.set(`Bearer ${token}`, spend);
sessions.set(agentId, {
provider: "cursor",
sessionKey: agentId,
env: { XDG_CONFIG_HOME: configHome },
});
}
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
hasAgent: (id) => sessions.has(id),
usageSession: (id) => sessions.get(id) ?? null,
});
const fetchApi: typeof fetch = async (_url, init) => {
const authorization = new Headers(init?.headers).get("Authorization") ?? "";
const spend = spends.get(authorization);
if (spend === undefined) throw new Error("Unexpected fixture login");
return new Response(JSON.stringify({ planUsage: { totalSpend: spend, limit: 1000 } }));
};
registry.register({
id: "cursor",
label: "Cursor",
discover: (scope) =>
scope.kind === "session" ? discoverCursorSession(scope, "linux") : Promise.resolve([]),
fetch: (input) => fetchCursorUsage(cursorInputSchema.parse(input), fetchApi),
});
const spent = (reports: Awaited<ReturnType<typeof registry.listReports>>) =>
reports.map((entry) => {
if (entry.report.status !== "available")
throw new Error("Expected available fixture usage");
return entry.report.balances?.[0]?.used;
});
expect(spent(await registry.listReports({ agentId: "a" }))).toEqual([1]);
expect(spent(await registry.listReports({ agentId: "b" }))).toEqual([2]);
expect(spent(await registry.listReports({ agentId: "a-copy" }))).toEqual([1]);
expect(spent(await registry.listReports())).toEqual([1, 2]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
function source(input: {
id: string;
@@ -273,13 +273,50 @@ describe("cursor usage source", () => {
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: "default", input: { store: "file", locator: authPath } }]);
).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 () => {
@@ -287,7 +324,7 @@ describe("cursor usage source", () => {
writeCursorAuthJson(sessionHome, "session-token");
expect(await discoverSession(cursorSession({}), "linux")).toEqual([
{
key: "default",
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
input: { store: "file", locator: join(sessionHome, ".config", "cursor", "auth.json") },
},
]);
@@ -298,7 +335,10 @@ describe("cursor usage source", () => {
const authPath = join(appData, "Cursor", "auth.json");
writeAuthJson(authPath);
expect(await discoverSession(cursorSession({ APPDATA: appData }), "win32")).toEqual([
{ key: "default", input: { store: "file", locator: authPath } },
{
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
input: { store: "file", locator: authPath },
},
]);
});
@@ -308,7 +348,12 @@ describe("cursor usage source", () => {
expect(await discoverSession(cursorSession({}), "darwin")).toEqual([]);
expect(
await discoverSession(cursorSession({ AGENT_CLI_CREDENTIAL_STORE: "file" }), "darwin"),
).toEqual([{ key: "default", input: { store: "file", locator: authPath } }]);
).toEqual([
{
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
input: { store: "file", locator: authPath },
},
]);
});
it.each([
@@ -341,7 +386,10 @@ it("discovery returns a locator when fetch finds cursor credentials", async () =
});
expect(requested).toBe(true);
expect(await discover()).toEqual([
{ key: "default", input: { store: "env", locator: "CURSOR_ACCESS_TOKEN" } },
{
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"];
+28 -4
View File
@@ -1,4 +1,5 @@
import type { UsageInput } from "../shared/input.js";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
@@ -211,6 +212,26 @@ async function readToken(input: UsageInput): Promise<string | undefined> {
return token ?? undefined;
}
const CursorIdentitySchema = z.object({ sub: z.string().min(1) });
function cursorAccount(token: string, input: UsageInput): UsageAccount {
const parts = token.split(".");
if (parts.length === 3) {
try {
const payload: unknown = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
const identity = CursorIdentitySchema.safeParse(payload);
if (identity.success) {
const key = createHash("sha256").update(identity.data.sub).digest("hex");
return { key: `account-${key}`, input };
}
} catch {
// Non-JWT logins still have a usage card, isolated to their credential store.
}
}
const key = createHash("sha256").update(JSON.stringify(input)).digest("hex");
return { key: `store-${key}`, input };
}
export async function discover(): Promise<UsageAccount[]> {
const home = homedir();
const candidates: UsageInput[] = ["CURSOR_ACCESS_TOKEN", "CURSOR_TOKEN"].map((locator) => ({
@@ -237,12 +258,14 @@ export async function discover(): Promise<UsageAccount[]> {
].map((locator) => ({ store: "sqlite" as const, locator })),
{ store: "file", locator: join(home, ".config", "cursor", "auth.json") },
);
for (const input of candidates) if (await readToken(input)) return [{ key: "default", input }];
for (const input of candidates) {
const token = await readToken(input);
if (token) return [cursorAccount(token, input)];
}
return [];
}
// Resolves the login a cursor-agent session runs on. The key matches global discovery so
// the session's login joins the host's Cursor card instead of adding a second one.
// Account identity joins global and session logins without merging different users.
export async function discoverSession(
scope: Extract<UsageScope, { kind: "session" }>,
platform: NodeJS.Platform = process.platform,
@@ -251,7 +274,8 @@ export async function discoverSession(
const locator = cursorAgentAuthPath(scope.env, platform);
if (!locator) return [];
const input: UsageInput = { store: "file", locator };
return (await readToken(input)) ? [{ key: "default", input }] : [];
const token = await readToken(input);
return token ? [cursorAccount(token, input)] : [];
}
// Mirrors cursor-agent's credential store. Env credentials win over the stored login and