diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index f0173691c8..da9d52eaeb 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -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 } + >(); + const spends = new Map(); + 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>) => + 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; diff --git a/plugins/cursor-usage-source/server/usage.test.ts b/plugins/cursor-usage-source/server/usage.test.ts index 91b657c264..b05159cd12 100644 --- a/plugins/cursor-usage-source/server/usage.test.ts +++ b/plugins/cursor-usage-source/server/usage.test.ts @@ -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"]; diff --git a/plugins/cursor-usage-source/server/usage.ts b/plugins/cursor-usage-source/server/usage.ts index c5d2ab1391..2265dbb556 100644 --- a/plugins/cursor-usage-source/server/usage.ts +++ b/plugins/cursor-usage-source/server/usage.ts @@ -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 { 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 { const home = homedir(); const candidates: UsageInput[] = ["CURSOR_ACCESS_TOKEN", "CURSOR_TOKEN"].map((locator) => ({ @@ -237,12 +258,14 @@ export async function discover(): Promise { ].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, 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