| 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,6 +1,7 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; | 2 | import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; |
| 3 | import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; | 3 | import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; |
| 4 | import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage"; | ||
| 4 | import { | 5 | import { |
| 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 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | 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 | describe("Cursor Keychain reader", () => { | 8 | describe("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; |
| 4 | 4 | ||
| 5 | const requireForKeyring = NodeModule.createRequire(import.meta.url); | 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 | export function makeCachedCursorAccessTokenReader( | 22 | export 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 | } |
| 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,7 +5,10 @@ import * as NodeCrypto from "node:crypto"; |
| 5 | import * as NodeTimersPromises from "node:timers/promises"; | 5 | import * as NodeTimersPromises from "node:timers/promises"; |
| 6 | 6 | ||
| 7 | import type { UsageRecord } from "./usageTranscripts.ts"; | 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 | function object(value: unknown): Record<string, unknown> { | 13 | function 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 | ? null | 89 | ? 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"; |
| 15 | import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; | 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 | import { | 21 | import { |
| 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"; |
| 13 | 13 | ||
| 14 | import type { EnvironmentPresentation } from "../connection/presentation.ts"; | 14 | import type { EnvironmentPresentation } from "../connection/presentation.ts"; |
| 15 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; | 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 | const input = { | 23 | const 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 | } |
| 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 | const limitsRefreshAfter = new Map<EnvironmentId, number>(); | 50 | const limitsRefreshAfter = new Map<EnvironmentId, number>(); |
| 30 | const limitsRefreshes = new Map<EnvironmentId, Promise<unknown>>(); | 51 | const limitsRefreshes = new Map<EnvironmentId, Promise<unknown>>(); |
| 31 | 52 |