| author | |
| committer | |
| log | 679c34c096193446ceed62e633387d91fe2cfff8 |
| tree | 80d4c893d033ac3b65b866a81cb938328aa60279 |
| parent | ea7d46ac107e6a3461b81065ebc86dfd5483095f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | 2 | import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; |
| 3 | 3 | import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; |
| 4 | import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage"; | |
| 4 | 5 | import { |
| 5 | 6 | isCompatibleUsageContractVersion, |
| 6 | 7 | isModelCostUnknown, |
| ... | ... | @@ -102,9 +103,7 @@ export function UsageRouteScreen() { |
| 102 | 103 | ); |
| 103 | 104 | const isFocused = useIsFocused(); |
| 104 | 105 | const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); |
| 105 | const cursorAccessEnvironments = selectedEnvironments.filter( | |
| 106 | (environment) => environment.needsCursorKeychainAccess, | |
| 107 | ); | |
| 106 | const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments); | |
| 108 | 107 | const refreshAfterCursorEnable = () => { |
| 109 | 108 | void refresh(); |
| 110 | 109 | void limits.refreshAfterEnable(); |
apps/server/src/provider/cursorCredentialStore.test.ts+25-1| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | import { makeCachedCursorAccessTokenReader } from "./cursorCredentialStore.ts"; | |
| 3 | import { | |
| 4 | CursorKeychainTimeoutError, | |
| 5 | makeCachedCursorAccessTokenReader, | |
| 6 | } from "./cursorCredentialStore.ts"; | |
| 4 | 7 | |
| 5 | 8 | describe("Cursor Keychain reader", () => { |
| 6 | 9 | it("shares concurrent reads and rechecks after the cache expires", async () => { |
| ... | ... | @@ -19,4 +22,25 @@ describe("Cursor Keychain reader", () => { |
| 19 | 22 | time = 5 * 60_000; |
| 20 | 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 | 4 | |
| 5 | 5 | const requireForKeyring = NodeModule.createRequire(import.meta.url); |
| 6 | 6 | |
| 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. */ | |
| 8 | export 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 | */ | |
| 8 | 22 | export function makeCachedCursorAccessTokenReader( |
| 9 | 23 | read: () => Promise<string | null>, |
| 10 | 24 | now: () => number = Date.now, |
| 25 | timeoutMs = 30_000, | |
| 11 | 26 | ): () => Promise<string | null> { |
| 12 | 27 | let cached: { token: string; until: number } | null = null; |
| 13 | 28 | let pending: Promise<string | null> | null = null; |
| 14 | 29 | return () => { |
| 15 | 30 | if (cached && cached.until > now()) return Promise.resolve(cached.token); |
| 16 | if (pending) return pending; | |
| 17 | pending = read() | |
| 31 | pending ??= read() | |
| 18 | 32 | .then((token) => { |
| 19 | 33 | cached = token ? { token, until: now() + CACHE_MS } : null; |
| 20 | 34 | return token; |
| ... | ... | @@ -22,7 +36,15 @@ export function makeCachedCursorAccessTokenReader( |
| 22 | 36 | .finally(() => { |
| 23 | 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 | } |
| 28 | 50 |
apps/server/src/usage/cursorUsageReader.test.ts created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { CursorKeychainTimeoutError } from "../provider/cursorCredentialStore.ts"; | |
| 4 | import { readCursorAccountUsage } from "./cursorUsageReader.ts"; | |
| 5 | ||
| 6 | describe("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 | 5 | import * as NodeTimersPromises from "node:timers/promises"; |
| 6 | 6 | |
| 7 | 7 | import type { UsageRecord } from "./usageTranscripts.ts"; |
| 8 | import { readMacCursorAccessToken } from "../provider/cursorCredentialStore.ts"; | |
| 8 | import { | |
| 9 | CursorKeychainTimeoutError, | |
| 10 | readMacCursorAccessToken, | |
| 11 | } from "../provider/cursorCredentialStore.ts"; | |
| 9 | 12 | |
| 10 | 13 | function object(value: unknown): Record<string, unknown> { |
| 11 | 14 | return typeof value === "object" && value !== null && !Array.isArray(value) |
| ... | ... | @@ -86,7 +89,9 @@ export async function readCursorAccountUsage( |
| 86 | 89 | ? null |
| 87 | 90 | : typeof credentialSource === "string" |
| 88 | 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 | 97 | if (typeof accessToken !== "string" || !accessToken) { |
apps/web/src/components/usage/UsagePage.tsx+5-4| ... | ... | @@ -13,7 +13,10 @@ import { |
| 13 | 13 | SlidersHorizontalIcon, |
| 14 | 14 | } from "lucide-react"; |
| 15 | 15 | import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| 16 | import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; | |
| 16 | import { | |
| 17 | cursorKeychainAccessEnvironments, | |
| 18 | refreshUsageLimits, | |
| 19 | } from "@t3tools/client-runtime/state/usage"; | |
| 17 | 20 | |
| 18 | 21 | import { |
| 19 | 22 | isCompatibleUsageContractVersion, |
| ... | ... | @@ -129,9 +132,7 @@ export function UsagePage() { |
| 129 | 132 | selectedEnvironmentIds, |
| 130 | 133 | ); |
| 131 | 134 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 132 | const cursorAccessEnvironments = selectedEnvironments.filter( | |
| 133 | (environment) => environment.needsCursorKeychainAccess, | |
| 134 | ); | |
| 135 | const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments); | |
| 135 | 136 | const sourceMessages = [ |
| 136 | 137 | ...new Set( |
| 137 | 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 | 13 | |
| 14 | 14 | import type { EnvironmentPresentation } from "../connection/presentation.ts"; |
| 15 | 15 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; |
| 16 | import { needsCursorKeychainAccess, refreshUsage, refreshUsageLimits } from "./usage.ts"; | |
| 16 | import { | |
| 17 | cursorKeychainAccessEnvironments, | |
| 18 | needsCursorKeychainAccess, | |
| 19 | refreshUsage, | |
| 20 | refreshUsageLimits, | |
| 21 | } from "./usage.ts"; | |
| 17 | 22 | |
| 18 | 23 | const input = { |
| 19 | 24 | sinceDay: UsageDay.make("2026-09-05"), |
| ... | ... | @@ -285,4 +290,34 @@ describe("needsCursorKeychainAccess", () => { |
| 285 | 290 | expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false); |
| 286 | 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 | 26 | ); |
| 27 | 27 | } |
| 28 | 28 | |
| 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 | */ | |
| 36 | export 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 | ||
| 29 | 50 | const limitsRefreshAfter = new Map<EnvironmentId, number>(); |
| 30 | 51 | const limitsRefreshes = new Map<EnvironmentId, Promise<unknown>>(); |
| 31 | 52 |