authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-09-26 16:23:10-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-26 16:23:10-05:00
log679c34c096193446ceed62e633387d91fe2cfff8
tree80d4c893d033ac3b65b866a81cb938328aa60279
parentea7d46ac107e6a3461b81065ebc86dfd5483095f
signature Signed by PGP key B5690EEEBB952194

fix(clients): hide duplicate Cursor Keychain prompts (#13870)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

8 files changed, 144 insertions(+), 15 deletions(-)

apps/mobile/src/features/usage/UsageRouteScreen.tsx+2-3
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";2import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
3import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";3import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";
4import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage";
4import {5import {
5 isCompatibleUsageContractVersion,6 isCompatibleUsageContractVersion,
6 isModelCostUnknown,7 isModelCostUnknown,
...@@ -102,9 +103,7 @@ export function UsageRouteScreen() {...@@ -102,9 +103,7 @@ export function UsageRouteScreen() {
102 );103 );
103 const isFocused = useIsFocused();104 const isFocused = useIsFocused();
104 const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits");105 const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits");
105 const cursorAccessEnvironments = selectedEnvironments.filter(106 const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments);
106 (environment) => environment.needsCursorKeychainAccess,
107 );
108 const refreshAfterCursorEnable = () => {107 const refreshAfterCursorEnable = () => {
109 void refresh();108 void refresh();
110 void limits.refreshAfterEnable();109 void limits.refreshAfterEnable();
apps/server/src/provider/cursorCredentialStore.test.ts+25-1
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
22
3import { makeCachedCursorAccessTokenReader } from "./cursorCredentialStore.ts";3import {
4 CursorKeychainTimeoutError,
5 makeCachedCursorAccessTokenReader,
6} from "./cursorCredentialStore.ts";
47
5describe("Cursor Keychain reader", () => {8describe("Cursor Keychain reader", () => {
6 it("shares concurrent reads and rechecks after the cache expires", async () => {9 it("shares concurrent reads and rechecks after the cache expires", async () => {
...@@ -19,4 +22,25 @@ describe("Cursor Keychain reader", () => {...@@ -19,4 +22,25 @@ describe("Cursor Keychain reader", () => {
19 time = 5 * 60_000;22 time = 5 * 60_000;
20 assert.strictEqual(await read(), "token-2");23 assert.strictEqual(await read(), "token-2");
21 });24 });
25
26 it("gives up on an unanswered prompt and reuses it on the next read", async () => {
27 let reads = 0;
28 let allow: (token: string) => void = () => {};
29 const read = makeCachedCursorAccessTokenReader(
30 () => {
31 reads++;
32 return new Promise((resolve) => {
33 allow = resolve;
34 });
35 },
36 () => 0,
37 1,
38 );
39 const error = await read().catch((cause: unknown) => cause);
40 assert.instanceOf(error, CursorKeychainTimeoutError);
41 const retry = read();
42 allow("token");
43 assert.strictEqual(await retry, "token");
44 assert.strictEqual(reads, 1);
45 });
22});46});
apps/server/src/provider/cursorCredentialStore.ts+26-4
...@@ -4,17 +4,31 @@ const CACHE_MS = 5 * 60_000;...@@ -4,17 +4,31 @@ const CACHE_MS = 5 * 60_000;
44
5const requireForKeyring = NodeModule.createRequire(import.meta.url);5const requireForKeyring = NodeModule.createRequire(import.meta.url);
66
7/** Share one Keychain request across usage history and limits in this server process. */7/** Rejected when nobody answers the macOS Keychain prompt in time. */
8export class CursorKeychainTimeoutError extends Error {
9 constructor() {
10 super("Timed out waiting for Keychain access.");
11 }
12}
13
14/**
15 * Share one Keychain request across usage history and limits in this server process.
16 *
17 * macOS shows the access prompt on the server's own screen, which a remote
18 * client cannot answer, so callers give up after `timeoutMs`. The read stays in
19 * flight: the next call reuses it instead of stacking a second prompt, and picks
20 * up the token once someone allows access.
21 */
8export function makeCachedCursorAccessTokenReader(22export function makeCachedCursorAccessTokenReader(
9 read: () => Promise<string | null>,23 read: () => Promise<string | null>,
10 now: () => number = Date.now,24 now: () => number = Date.now,
25 timeoutMs = 30_000,
11): () => Promise<string | null> {26): () => Promise<string | null> {
12 let cached: { token: string; until: number } | null = null;27 let cached: { token: string; until: number } | null = null;
13 let pending: Promise<string | null> | null = null;28 let pending: Promise<string | null> | null = null;
14 return () => {29 return () => {
15 if (cached && cached.until > now()) return Promise.resolve(cached.token);30 if (cached && cached.until > now()) return Promise.resolve(cached.token);
16 if (pending) return pending;31 pending ??= read()
17 pending = read()
18 .then((token) => {32 .then((token) => {
19 cached = token ? { token, until: now() + CACHE_MS } : null;33 cached = token ? { token, until: now() + CACHE_MS } : null;
20 return token;34 return token;
...@@ -22,7 +36,15 @@ export function makeCachedCursorAccessTokenReader(...@@ -22,7 +36,15 @@ export function makeCachedCursorAccessTokenReader(
22 .finally(() => {36 .finally(() => {
23 pending = null;37 pending = null;
24 });38 });
25 return pending;39 const deadline = AbortSignal.timeout(timeoutMs);
40 return Promise.race([
41 pending,
42 new Promise<never>((_, reject) => {
43 deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), {
44 once: true,
45 });
46 }),
47 ]);
26 };48 };
27}49}
2850
apps/server/src/usage/cursorUsageReader.test.ts created+22
...@@ -0,0 +1,22 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { CursorKeychainTimeoutError } from "../provider/cursorCredentialStore.ts";
4import { readCursorAccountUsage } from "./cursorUsageReader.ts";
5
6describe("readCursorAccountUsage", () => {
7 it("asks for Keychain approval when the prompt goes unanswered", async () => {
8 const result = await readCursorAccountUsage(
9 { kind: "keychain" },
10 0,
11 1,
12 () => Promise.reject(new Error("no network expected")),
13 () => Promise.reject(new CursorKeychainTimeoutError()),
14 );
15 assert.deepStrictEqual(result, {
16 accountKey: null,
17 records: [],
18 missing: false,
19 error: "Allow Keychain access on the Mac running T3 Code, then refresh.",
20 });
21 });
22});
apps/server/src/usage/cursorUsageReader.ts+7-2
...@@ -5,7 +5,10 @@ import * as NodeCrypto from "node:crypto";...@@ -5,7 +5,10 @@ import * as NodeCrypto from "node:crypto";
5import * as NodeTimersPromises from "node:timers/promises";5import * as NodeTimersPromises from "node:timers/promises";
66
7import type { UsageRecord } from "./usageTranscripts.ts";7import type { UsageRecord } from "./usageTranscripts.ts";
8import { readMacCursorAccessToken } from "../provider/cursorCredentialStore.ts";8import {
9 CursorKeychainTimeoutError,
10 readMacCursorAccessToken,
11} from "../provider/cursorCredentialStore.ts";
912
10function object(value: unknown): Record<string, unknown> {13function object(value: unknown): Record<string, unknown> {
11 return typeof value === "object" && value !== null && !Array.isArray(value)14 return typeof value === "object" && value !== null && !Array.isArray(value)
...@@ -86,7 +89,9 @@ export async function readCursorAccountUsage(...@@ -86,7 +89,9 @@ export async function readCursorAccountUsage(
86 ? null89 ? null
87 : typeof credentialSource === "string"90 : typeof credentialSource === "string"
88 ? "Cursor credentials could not be read."91 ? "Cursor credentials could not be read."
89 : "Cursor Keychain credentials could not be read.",92 : cause instanceof CursorKeychainTimeoutError
93 ? "Allow Keychain access on the Mac running T3 Code, then refresh."
94 : "Cursor Keychain credentials could not be read.",
90 };95 };
91 }96 }
92 if (typeof accessToken !== "string" || !accessToken) {97 if (typeof accessToken !== "string" || !accessToken) {
apps/web/src/components/usage/UsagePage.tsx+5-4
...@@ -13,7 +13,10 @@ import {...@@ -13,7 +13,10 @@ import {
13 SlidersHorizontalIcon,13 SlidersHorizontalIcon,
14} from "lucide-react";14} from "lucide-react";
15import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";15import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
16import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";16import {
17 cursorKeychainAccessEnvironments,
18 refreshUsageLimits,
19} from "@t3tools/client-runtime/state/usage";
1720
18import {21import {
19 isCompatibleUsageContractVersion,22 isCompatibleUsageContractVersion,
...@@ -129,9 +132,7 @@ export function UsagePage() {...@@ -129,9 +132,7 @@ export function UsagePage() {
129 selectedEnvironmentIds,132 selectedEnvironmentIds,
130 );133 );
131 const presentations = useAtomValue(environmentPresentations.presentationsAtom);134 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
132 const cursorAccessEnvironments = selectedEnvironments.filter(135 const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments);
133 (environment) => environment.needsCursorKeychainAccess,
134 );
135 const sourceMessages = [136 const sourceMessages = [
136 ...new Set(137 ...new Set(
137 selectedEnvironments.flatMap(138 selectedEnvironments.flatMap(
packages/client-runtime/src/state/usage.test.ts+36-1
...@@ -13,7 +13,12 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test";...@@ -13,7 +13,12 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test";
1313
14import type { EnvironmentPresentation } from "../connection/presentation.ts";14import type { EnvironmentPresentation } from "../connection/presentation.ts";
15import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";15import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";
16import { needsCursorKeychainAccess, refreshUsage, refreshUsageLimits } from "./usage.ts";16import {
17 cursorKeychainAccessEnvironments,
18 needsCursorKeychainAccess,
19 refreshUsage,
20 refreshUsageLimits,
21} from "./usage.ts";
1722
18const input = {23const input = {
19 sinceDay: UsageDay.make("2026-09-05"),24 sinceDay: UsageDay.make("2026-09-05"),
...@@ -285,4 +290,34 @@ describe("needsCursorKeychainAccess", () => {...@@ -285,4 +290,34 @@ describe("needsCursorKeychainAccess", () => {
285 expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false);290 expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false);
286 expect(needsCursorKeychainAccess(summary, [cursor("ready")])).toBe(false);291 expect(needsCursorKeychainAccess(summary, [cursor("ready")])).toBe(false);
287 });292 });
293
294 it("stops offering access once any environment reads the Cursor account", () => {
295 const off = { summary: cursorPrompt, needsCursorKeychainAccess: true };
296 const account: UsageSummary = {
297 ...summary,
298 sources: [
299 {
300 fingerprint: {
301 hostId: "cursor.com",
302 provider: "cursor",
303 resolvedHomePath: "cursor-account:abc",
304 volumeId: "abc",
305 },
306 status: "ok",
307 scannedFiles: 1,
308 skippedFiles: 0,
309 malformedRecords: 0,
310 distinctSessions: 1,
311 message: null,
312 },
313 ],
314 };
315 expect(cursorKeychainAccessEnvironments([off, off])).toEqual([off, off]);
316 expect(
317 cursorKeychainAccessEnvironments([
318 off,
319 { summary: account, needsCursorKeychainAccess: false },
320 ]),
321 ).toEqual([]);
322 });
288});323});
packages/client-runtime/src/state/usage.ts+21
...@@ -26,6 +26,27 @@ export function needsCursorKeychainAccess(...@@ -26,6 +26,27 @@ export function needsCursorKeychainAccess(
26 );26 );
27}27}
2828
29/**
30 * Environments to offer the Cursor Keychain prompt in the usage summary.
31 *
32 * Any environment reading a Cursor account already reports that account's
33 * history from every machine, so the prompt only adds duplicates there. A
34 * different account on another machine stays reachable from provider settings.
35 */
36export function cursorKeychainAccessEnvironments<
37 E extends { readonly summary: UsageSummary | null; readonly needsCursorKeychainAccess: boolean },
38>(environments: readonly E[]): readonly E[] {
39 const hasCursorAccount = environments.some((environment) =>
40 environment.summary?.sources.some(
41 (source) =>
42 source.fingerprint.provider === "cursor" && source.fingerprint.hostId === "cursor.com",
43 ),
44 );
45 return hasCursorAccount
46 ? []
47 : environments.filter((environment) => environment.needsCursorKeychainAccess);
48}
49
29const limitsRefreshAfter = new Map<EnvironmentId, number>();50const limitsRefreshAfter = new Map<EnvironmentId, number>();
30const limitsRefreshes = new Map<EnvironmentId, Promise<unknown>>();51const limitsRefreshes = new Map<EnvironmentId, Promise<unknown>>();
3152