mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
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:
@@ -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"];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user