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 @@
11import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
22import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
33import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";
4import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage";
45import {
56 isCompatibleUsageContractVersion,
67 isModelCostUnknown,
......@@ -102,9 +103,7 @@ export function UsageRouteScreen() {
102103 );
103104 const isFocused = useIsFocused();
104105 const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits");
105 const cursorAccessEnvironments = selectedEnvironments.filter(
106 (environment) => environment.needsCursorKeychainAccess,
107 );
106 const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments);
108107 const refreshAfterCursorEnable = () => {
109108 void refresh();
110109 void limits.refreshAfterEnable();
apps/server/src/provider/cursorCredentialStore.test.ts+25-1
......@@ -1,6 +1,9 @@
11import { assert, describe, it } from "@effect/vitest";
22
3import { makeCachedCursorAccessTokenReader } from "./cursorCredentialStore.ts";
3import {
4 CursorKeychainTimeoutError,
5 makeCachedCursorAccessTokenReader,
6} from "./cursorCredentialStore.ts";
47
58describe("Cursor Keychain reader", () => {
69 it("shares concurrent reads and rechecks after the cache expires", async () => {
......@@ -19,4 +22,25 @@ describe("Cursor Keychain reader", () => {
1922 time = 5 * 60_000;
2023 assert.strictEqual(await read(), "token-2");
2124 });
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 });
2246});
apps/server/src/provider/cursorCredentialStore.ts+26-4
......@@ -4,17 +4,31 @@ const CACHE_MS = 5 * 60_000;
44
55const 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 */
822export function makeCachedCursorAccessTokenReader(
923 read: () => Promise<string | null>,
1024 now: () => number = Date.now,
25 timeoutMs = 30_000,
1126): () => Promise<string | null> {
1227 let cached: { token: string; until: number } | null = null;
1328 let pending: Promise<string | null> | null = null;
1429 return () => {
1530 if (cached && cached.until > now()) return Promise.resolve(cached.token);
16 if (pending) return pending;
17 pending = read()
31 pending ??= read()
1832 .then((token) => {
1933 cached = token ? { token, until: now() + CACHE_MS } : null;
2034 return token;
......@@ -22,7 +36,15 @@ export function makeCachedCursorAccessTokenReader(
2236 .finally(() => {
2337 pending = null;
2438 });
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 ]);
2648 };
2749}
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";
55import * as NodeTimersPromises from "node:timers/promises";
66
77import type { UsageRecord } from "./usageTranscripts.ts";
8import { readMacCursorAccessToken } from "../provider/cursorCredentialStore.ts";
8import {
9 CursorKeychainTimeoutError,
10 readMacCursorAccessToken,
11} from "../provider/cursorCredentialStore.ts";
912
1013function object(value: unknown): Record<string, unknown> {
1114 return typeof value === "object" && value !== null && !Array.isArray(value)
......@@ -86,7 +89,9 @@ export async function readCursorAccountUsage(
8689 ? null
8790 : typeof credentialSource === "string"
8891 ? "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.",
9095 };
9196 }
9297 if (typeof accessToken !== "string" || !accessToken) {
apps/web/src/components/usage/UsagePage.tsx+5-4
......@@ -13,7 +13,10 @@ import {
1313 SlidersHorizontalIcon,
1414} from "lucide-react";
1515import { 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
1821import {
1922 isCompatibleUsageContractVersion,
......@@ -129,9 +132,7 @@ export function UsagePage() {
129132 selectedEnvironmentIds,
130133 );
131134 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
132 const cursorAccessEnvironments = selectedEnvironments.filter(
133 (environment) => environment.needsCursorKeychainAccess,
134 );
135 const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments);
135136 const sourceMessages = [
136137 ...new Set(
137138 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";
1313
1414import type { EnvironmentPresentation } from "../connection/presentation.ts";
1515import { 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
1823const input = {
1924 sinceDay: UsageDay.make("2026-09-05"),
......@@ -285,4 +290,34 @@ describe("needsCursorKeychainAccess", () => {
285290 expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false);
286291 expect(needsCursorKeychainAccess(summary, [cursor("ready")])).toBe(false);
287292 });
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 });
288323});
packages/client-runtime/src/state/usage.ts+21
......@@ -26,6 +26,27 @@ export function needsCursorKeychainAccess(
2626 );
2727}
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
2950const limitsRefreshAfter = new Map<EnvironmentId, number>();
3051const limitsRefreshes = new Map<EnvironmentId, Promise<unknown>>();
3152