From 5a9c664b3ec8214cc5847d561efa130ba213eb38 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 15:26:19 -0700 Subject: [PATCH] refactor(usage): Cursor account usage comes from provider-cursor (#17578) Co-authored-by: Claude Opus 5.5 --- ...viderOrchestrationAdapterInfrastructure.ts | 3 + apps/server/src/provider/builtInDrivers.ts | 5 +- apps/server/src/server.ts | 15 +- apps/server/src/usage/UsageService.test.ts | 58 ++- apps/server/src/usage/UsageService.ts | 285 +-------------- .../src/usage/cursorUsageReader.test.ts | 93 ----- apps/server/src/usage/usagePricing.test.ts | 2 +- .../src/usage/usageTranscriptReader.test.ts | 230 ------------ packages/provider-core/src/server/usage.ts | 3 + packages/provider-cursor/package.json | 16 + packages/provider-cursor/src/server.ts | 6 +- .../src/server/CursorAccountReader.ts | 51 +++ .../src/server/CursorKeychain.test.ts | 54 +++ .../src/server/CursorKeychain.ts | 118 ++++++ .../src/server/CursorUsageAccounts.ts | 342 ++++++++++++++++++ .../src/server/accountCache.test.ts | 2 +- .../src/server/accountCache.ts | 4 +- .../src/server/accountUsage.test.ts | 340 +++++++++++++++++ .../src/server/accountUsage.ts | 48 +-- .../provider-cursor/src/server/driver.test.ts | 6 + packages/provider-cursor/src/server/driver.ts | 30 +- .../src/server/keychainToken.test.ts | 43 --- .../src/server/keychainToken.ts | 55 --- .../src/server/usageLimits.test.ts | 32 +- .../provider-cursor/src/server/usageLimits.ts | 6 +- 25 files changed, 1074 insertions(+), 773 deletions(-) delete mode 100644 apps/server/src/usage/cursorUsageReader.test.ts create mode 100644 packages/provider-cursor/src/server/CursorAccountReader.ts create mode 100644 packages/provider-cursor/src/server/CursorKeychain.test.ts create mode 100644 packages/provider-cursor/src/server/CursorKeychain.ts create mode 100644 packages/provider-cursor/src/server/CursorUsageAccounts.ts rename apps/server/src/usage/cursorAccountCache.test.ts => packages/provider-cursor/src/server/accountCache.test.ts (96%) rename apps/server/src/usage/cursorAccountCache.ts => packages/provider-cursor/src/server/accountCache.ts (98%) create mode 100644 packages/provider-cursor/src/server/accountUsage.test.ts rename apps/server/src/usage/cursorUsageReader.ts => packages/provider-cursor/src/server/accountUsage.ts (90%) delete mode 100644 packages/provider-cursor/src/server/keychainToken.test.ts delete mode 100644 packages/provider-cursor/src/server/keychainToken.ts diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index 65711612b18c8e05aa80bd2e08a4504ae4ee29a4..f312b3d58320e8c481507889a9962898b0c7a18f 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -4,6 +4,7 @@ import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.t import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk"; +import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; @@ -12,6 +13,7 @@ export type ProviderOrchestrationAdapterInfrastructure = | CodexAdapterV2.CodexAppServerClientFactory | CursorAgentSdk.CursorAgentSdkRunner | CursorSdk.CursorSdk + | CursorKeychain.CursorKeychain | IdAllocator.IdAllocatorV2; /** @@ -24,6 +26,7 @@ export const layer = Layer.mergeAll( ClaudeAdapterV2.layerQueryRunner, CodexAdapterV2.layerAppServerClientFactory, CursorAgentSdk.layer.pipe(Layer.provideMerge(CursorSdk.layer)), + CursorKeychain.layer, IdAllocator.layer, ProviderContinuationRequests.layer, ); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 4576f4ea36bbcbf117b87e14d7eeee0ca5a4b5e3..9279ef326a259d6e03a3c22675d268b999a04ace 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -75,7 +75,8 @@ export type BuiltInUsageReadersEnv = | ProviderUsageReaderEnv | ProviderUsageReaderEnv | ProviderUsageReaderEnv - | ProviderUsageReaderEnv; + | ProviderUsageReaderEnv + | ProviderUsageReaderEnv; /** * The drivers that keep usage history, in the order the usage page reads @@ -84,4 +85,4 @@ export type BuiltInUsageReadersEnv = */ export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray< AnyProviderDriver -> = [ClaudeDriver, CodexDriver, GrokDriver, OpenCodeDriver]; +> = [ClaudeDriver, CodexDriver, GrokDriver, OpenCodeDriver, CursorDriver]; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a5e23ed65b568a53136adfa059b36bfec33c9be7..5659b130168d560fca0272876fd03999b3f1238b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -154,7 +154,9 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; -import * as CursorUsageReader from "./usage/cursorUsageReader.ts"; +import * as CursorAccountReader from "@t3tools/provider-cursor/server/CursorAccountReader"; +import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; +import * as CursorUsageAccounts from "@t3tools/provider-cursor/server/CursorUsageAccounts"; import * as UsageService from "./usage/UsageService.ts"; import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; @@ -252,8 +254,13 @@ const layerBackground = BackgroundPolicy.layer.pipe( ); const layerUsage = UsageService.layer.pipe( + Layer.provide( + CursorUsageAccounts.layer.pipe( + Layer.provide(CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer))), + Layer.provide(ProviderHostLive.layer.pipe(Layer.provide(ServerSecretStore.layer))), + ), + ), Layer.provide(layerServerSettings), - Layer.provide(CursorUsageReader.layer), ); const layerResourceDiagnostics = Layer.mergeAll( @@ -666,9 +673,11 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( // Misc. + // Usage reads provider history through the ProviderHost, which needs the + // background policy below it. + Layer.provideMerge(layerUsage), Layer.provideMerge(layerBackground), Layer.provideMerge(layerResourceDiagnostics), - Layer.provideMerge(layerUsage), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 7e98a546777942bbfcb6801d9a6018e61c0998a8..83bace94138afbbcfe4f06ecb4d48322e2b0c4dd 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -24,6 +24,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as Context from "effect/Context"; import * as Layer from "effect/Layer"; import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; @@ -32,7 +33,12 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; -import * as CursorUsageReader from "./cursorUsageReader.ts"; +import * as CursorAccountReader from "@t3tools/provider-cursor/server/CursorAccountReader"; +import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; +import * as CursorUsageAccounts from "@t3tools/provider-cursor/server/CursorUsageAccounts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as ProviderHostLive from "../provider/ProviderHostLive.ts"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; import * as UsageService from "./UsageService.ts"; @@ -87,6 +93,27 @@ const setup = Effect.gen(function* () { }; }); +const layerCursorUsageAccounts = ( + reader: Layer.Layer, +) => Layer.fresh(CursorUsageAccounts.layer).pipe(Layer.provide(reader)); + +/** + * A service with its own Cursor account caches over `read`, as a fresh server + * process has. `awaitPersisted` also waits for the account cache writes. + */ +const makeWithCursor = (read: CursorAccountReader.CursorAccountReader["Service"]["read"]) => + Effect.gen(function* () { + const context = yield* Layer.build( + layerCursorUsageAccounts(Layer.succeed(CursorAccountReader.CursorAccountReader, { read })), + ); + const accounts = Context.get(context, CursorUsageAccounts.CursorUsageAccounts); + const service = yield* UsageService.make.pipe(Effect.provideContext(context)); + return { + ...service, + awaitPersisted: Effect.andThen(service.awaitPersisted, accounts.awaitPersisted), + }; + }); + const layerService = (input: { readonly prefix: string; readonly home: string; @@ -97,9 +124,14 @@ const layerService = (input: { readonly environment?: NodeJS.ProcessEnv; readonly platform?: NodeJS.Platform; }) => - ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( + layerCursorUsageAccounts( + CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer)), + ).pipe( + Layer.provideMerge(ProviderHostLive.layer), + Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})), + Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: input.prefix })), Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(CursorUsageReader.layer), Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), Layer.provideMerge( @@ -364,9 +396,7 @@ describe("UsageService", () => { const gate = yield* Deferred.make(); cursor.state.gate = gate; yield* Effect.gen(function* () { - const service = yield* UsageService.make.pipe( - Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }), - ); + const service = yield* makeWithCursor(cursor.read); // Cold: nothing cached yet, so Cursor answers empty while it refreshes. const cold = yield* service.readSummary(WINDOW); assert.strictEqual(cursorSource(cold)?.refreshing, true); @@ -421,9 +451,7 @@ describe("UsageService", () => { { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 }, ]; yield* Effect.gen(function* () { - const service = yield* UsageService.make.pipe( - Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }), - ); + const service = yield* makeWithCursor(cursor.read); const read = (input: UsageSummaryInput) => service.readSummary({ ...input, awaitRefresh: true }); assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 19); @@ -470,9 +498,7 @@ describe("UsageService", () => { const cursor = makeFakeCursor(); cursor.state.events = [{ timestampMs: CURSOR_NOW - HOUR_MS * 3, outputTokens: 5 }]; yield* Effect.gen(function* () { - const service = yield* UsageService.make.pipe( - Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }), - ); + const service = yield* makeWithCursor(cursor.read); yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); yield* TestClock.adjust(Duration.minutes(2)); @@ -513,17 +539,13 @@ describe("UsageService", () => { { timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 }, ]; yield* Effect.gen(function* () { - const first = yield* UsageService.make.pipe( - Effect.provideService(CursorUsageReader.CursorAccountReader, { read: before.read }), - ); + const first = yield* makeWithCursor(before.read); const original = yield* first.readSummary({ ...WINDOW, awaitRefresh: true }); yield* first.awaitPersisted; const after = makeFakeCursor(); after.state.events = before.state.events; - const restarted = yield* UsageService.make.pipe( - Effect.provideService(CursorUsageReader.CursorAccountReader, { read: after.read }), - ); + const restarted = yield* makeWithCursor(after.read); const restored = yield* restarted.readSummary(WINDOW); assert.isUndefined(cursorSource(restored)?.refreshing); assert.deepStrictEqual(restored.buckets, original.buckets); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index c8a1891ce8f865d0bc650a829bcce7cf175dbc01..2f48fc5abcab6721f2459c41e51e81502d444f8a 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -12,9 +12,9 @@ * remain visible. Antigravity databases are memoised in memory while the * database and its WAL keep the same `(size, mtime, ctime)`. * - * Cursor's account API is slow, so its source answers from a cache and marks - * itself `refreshing` while a background refresh runs; `awaitRefresh` waits - * for that refresh instead. See `cursorAccountCache`. + * A scan reader with a slow source (an account API) may answer from its own + * cache and mark itself `refreshing` while a background refresh runs; + * `awaitRefresh` waits for that refresh instead. * * @module UsageService */ @@ -32,7 +32,7 @@ import { type UsageSummaryInput, UsageReadError, } from "@t3tools/contracts"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -64,18 +64,6 @@ import type { UsageRecord, } from "@t3tools/provider-core/server/usage"; import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; -import { - CURSOR_ACCOUNT_CACHE_FILE_NAME, - CURSOR_ACCOUNT_TTL_MS, - cursorFetchRange, - decodeCursorAccountCaches, - encodeCursorAccountCaches, - isCursorCacheFresh, - mergeCursorFetch, - type CursorAccountCache, - type CursorCredentialSource, -} from "./cursorAccountCache.ts"; -import * as CursorUsageReader from "./cursorUsageReader.ts"; import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -116,8 +104,6 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; */ const CACHE_RETENTION_DAYS = 92; -const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read."; - /** Transcripts parsed at once. More gains little once the disk stays busy. */ const TRANSCRIPT_READ_CONCURRENCY = 4; @@ -248,8 +234,6 @@ export const make = Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcessEnvironment; - const platform = yield* HostProcessPlatform; - const cursorAccountReader = yield* CursorUsageReader.CursorAccountReader; // The readers yield their own services; scans run them against this context. const readerContext = yield* Effect.context(); @@ -257,20 +241,6 @@ export const make = Effect.gen(function* () { const antigravityCache = makeAntigravityUsageCache(); const sourceCache = new Map(); let cacheDirty = false; - /** Cursor account caches by credential source. */ - const cursorCaches = new Map(); - let cursorCacheDirty = false; - /** - * The last failed refresh per credential source, standing for a TTL so a - * client refetching a broken login does not refetch Cursor each time. A - * `null` error means there is no login: no source to report. - */ - const cursorFailures = new Map< - string, - { readonly atMs: number; readonly error: string | null } - >(); - /** The refresh in flight per credential source, which every read joins. */ - const cursorRefreshes = new Map>(); const isWithinDirectory = (filePath: string, dir: string) => { const relative = path.relative(dir, filePath); return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); @@ -279,7 +249,6 @@ export const make = Effect.gen(function* () { const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME); const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME); - const cursorCachePath = path.join(config.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME); const writeCacheFile = (filePath: string, contents: string) => writeFileStringAtomically({ filePath, contents }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), @@ -453,7 +422,7 @@ export const make = Effect.gen(function* () { }); /** - * Loads the persisted scan and Cursor account caches exactly once per process. + * Loads the persisted scan cache exactly once per process. * * `Effect.cached` makes concurrent first readers await the same load rather * than each seeing a "loaded" flag set before the read finished and cold @@ -466,9 +435,6 @@ export const make = Effect.gen(function* () { Effect.flatMap((raw) => decodeScanCacheFile(raw)), Effect.catchCause(() => Effect.succeed(null)), ); - for (const [key, cache] of decodeCursorAccountCaches(yield* readDocument(cursorCachePath))) { - cursorCaches.set(key, cache); - } let document = yield* readDocument(scanCachePath); if (document === null) { document = yield* readDocument(legacyScanCachePath); @@ -492,7 +458,7 @@ export const make = Effect.gen(function* () { // keeps an older snapshot from landing after a newer one. const persistLock = yield* Semaphore.make(1); - // Each dirty flag is cleared before encoding, so a change while the write is + // The dirty flag is cleared before encoding, so a change while the write is // in flight marks it dirty again. A failed write restores the flag, so the // next persist retries instead of leaving disk stale. A cache we cannot // write is a slower next start, not a failed read. @@ -510,17 +476,6 @@ export const make = Effect.gen(function* () { ), ); } - if (cursorCacheDirty) { - cursorCacheDirty = false; - yield* Effect.sync(() => encodeCursorAccountCaches(cursorCaches)).pipe( - Effect.flatMap((contents) => writeCacheFile(cursorCachePath, contents)), - Effect.catchCause(() => - Effect.sync(() => { - cursorCacheDirty = true; - }), - ), - ); - } }).pipe(persistLock.withPermit, Effect.withSpan("UsageService.persistCaches")); const pendingPersists = new Set>(); @@ -674,162 +629,6 @@ export const make = Effect.gen(function* () { return { provider, dir, volumeId, files: parsedFiles } satisfies ScannedDir; }); - /** Fetches what one account cache is missing, then persists it if anything changed. */ - const refreshCursorAccount = Effect.fn("UsageService.refreshCursorAccount")(function* ( - credential: CursorCredentialSource, - credentialKey: string, - retentionStartMs: number, - ) { - const nowMs = yield* Clock.currentTimeMillis; - const fetchMissing = (cache: CursorAccountCache | undefined) => { - const range = cursorFetchRange(cache, retentionStartMs, nowMs); - return cursorAccountReader - .read(credential, range.sinceMs, range.untilMs) - .pipe(Effect.map((result) => ({ range, result }))); - }; - let base = cursorCaches.get(credentialKey); - let fetched = yield* fetchMissing(base); - // Another login's history replaces the cached account's, even if reading it fails. - if ( - base !== undefined && - fetched.result.accountKey !== null && - fetched.result.accountKey !== base.accountKey - ) { - cursorCaches.delete(credentialKey); - cursorCacheDirty = true; - base = undefined; - fetched = yield* fetchMissing(base); - } - const { range, result } = fetched; - if (result.missing || result.error !== null || result.accountKey === null) { - cursorFailures.set(credentialKey, { - atMs: nowMs, - error: result.missing || result.error !== null ? result.error : CURSOR_ACCOUNT_READ_ERROR, - }); - yield* schedulePersist; - return; - } - const merged = mergeCursorFetch( - base, - result.accountKey, - range, - result.records, - nowMs, - retentionStartMs, - ); - cursorCaches.set(credentialKey, merged.cache); - cursorFailures.delete(credentialKey); - // An unchanged edge is not worth rewriting the file for: after a restart - // the cache just refetches a slightly wider edge. - if (merged.changed) { - cursorCacheDirty = true; - yield* schedulePersist; - } - }); - - /** Joins the refresh in flight, or starts one. */ - const startCursorRefresh = ( - credential: CursorCredentialSource, - credentialKey: string, - retentionStartMs: number, - ) => - // Enrollment and fork are atomic, so an interrupted caller cannot leave a - // registered refresh that nothing will finish. - Effect.uninterruptible( - Effect.gen(function* () { - const current = cursorRefreshes.get(credentialKey); - if (current !== undefined) return current; - const done = Deferred.makeUnsafe(); - cursorRefreshes.set(credentialKey, done); - // Detached: a departing client must not cancel a fetch later reads reuse. - yield* refreshCursorAccount(credential, credentialKey, retentionStartMs).pipe( - Effect.catchCause(() => - Clock.currentTimeMillis.pipe( - Effect.map((atMs) => - cursorFailures.set(credentialKey, { atMs, error: CURSOR_ACCOUNT_READ_ERROR }), - ), - ), - ), - Effect.ensuring( - Effect.suspend(() => { - cursorRefreshes.delete(credentialKey); - return Deferred.succeed(done, undefined); - }), - ), - Effect.forkDetach, - ); - return done; - }), - ); - - /** - * The Cursor account source. A cache inside its TTL, or a refresh that failed - * inside it, answers directly. Otherwise a refresh starts: `awaitRefresh` - * waits for it, and anything else answers from the cache marked `refreshing`. - */ - const cursorAccountSource = Effect.fn("UsageService.cursorAccountSource")(function* ( - credential: CursorCredentialSource, - authPath: string, - windowStartMs: number, - retentionStartMs: number, - awaitRefresh: boolean, - ) { - // No saved login means there is no account source to report, not a setup error. - if ( - typeof credential === "string" && - !(yield* fileSystem.exists(credential).pipe(Effect.orElseSucceed(() => true))) - ) { - return null; - } - const credentialKey = typeof credential === "string" ? credential : "keychain"; - const nowMs = yield* Clock.currentTimeMillis; - const recentFailure = cursorFailures.get(credentialKey); - let refreshing = false; - if ( - (recentFailure === undefined || nowMs - recentFailure.atMs >= CURSOR_ACCOUNT_TTL_MS) && - !isCursorCacheFresh(cursorCaches.get(credentialKey), nowMs) - ) { - const refresh = yield* startCursorRefresh(credential, credentialKey, retentionStartMs); - if (awaitRefresh) yield* Deferred.await(refresh); - else refreshing = true; - } - - const cache = cursorCaches.get(credentialKey); - const failure = refreshing ? undefined : cursorFailures.get(credentialKey); - const failureMessage = failure === undefined ? undefined : failure.error; - if (failureMessage === null) return null; - if (cache === undefined) { - return { - provider: "cursor", - dir: authPath, - volumeId: yield* Effect.promise(() => readDirectoryVolumeId(authPath)), - // Never combine a local fallback with another server's account-wide history. - ...(refreshing - ? { files: [], refreshing: true } - : { files: null, message: failureMessage ?? CURSOR_ACCOUNT_READ_ERROR }), - } satisfies ScannedDir; - } - // The same account includes CLI and desktop history from every machine. - // A stable remote fingerprint prevents connected environments counting it twice. - const source = `cursor-account:${cache.accountKey}`; - return { - provider: "cursor", - dir: source, - hostId: "cursor.com", - volumeId: cache.accountKey, - files: [ - { - path: source, - records: cache.records.filter((record) => record.timestampMs >= windowStartMs), - }, - ], - ...(failureMessage === undefined - ? { status: "ok" } - : { status: "partial", message: failureMessage }), - ...(refreshing ? { refreshing: true } : {}), - } satisfies ScannedDir; - }); - const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( windowStartMs: number, settings: ServerSettingsValue, @@ -862,6 +661,7 @@ export const make = Effect.gen(function* () { reader .scan({ instances: usageInstances(driver, settings), + settings, windowStartMs, retentionCutoffMs, awaitRefresh, @@ -884,7 +684,7 @@ export const make = Effect.gen(function* () { Effect.provideContext(readerContext), ), { concurrency: "unbounded" }, - ).pipe(Effect.map((sources) => sources.flat())); + ); const antigravity = Effect.gen(function* () { const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [ @@ -949,78 +749,19 @@ export const make = Effect.gen(function* () { return scanned; }); - const cursor = Effect.gen(function* () { - const cursorUserHome = - (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; - const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); - const cursorHome = - platform === "darwin" - ? path.join(cursorUserHome, "Library", "Application Support") - : platform === "win32" - ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming") - : configHome && path.isAbsolute(configHome) - ? configHome - : path.join(cursorUserHome, ".config"); - const cursorAuthPath = - platform === "darwin" - ? path.join(cursorUserHome, ".cursor", "auth.json") - : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); - const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; - const loginUnavailable = - Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || - Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) || - credentialStore === "memory"; - const useKeychain = platform === "darwin" && credentialStore !== "file"; - if (useKeychain && !loginUnavailable && !settings.cursorKeychainUsageEnabled) { - return [ - { - provider: "cursor", - dir: cursorAuthPath, - volumeId: "", - files: null, - message: "Cursor account usage is off on this environment.", - action: "enableCursorKeychain", - } satisfies ScannedDir, - ]; - } - if (loginUnavailable) { - return [ - { - provider: "cursor", - dir: cursorAuthPath, - volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)), - files: null, - message: "Cursor account history needs a Cursor CLI login on this server.", - } satisfies ScannedDir, - ]; - } - const source = yield* cursorAccountSource( - useKeychain ? { kind: "keychain" } : cursorAuthPath, - cursorAuthPath, - windowStartMs, - retentionCutoffMs, - awaitRefresh, - ); - return source === null ? [] : [source]; - }); - // Independent sources scan together. Transcript directories go one at a // time, so open files stay at `TRANSCRIPT_READ_CONCURRENCY`. The result // keeps this order, since aggregation keeps the first copy of a duplicate. - const [transcripts, scanDirs, antigravityDirs, cursorDirs] = yield* Effect.all( - [ - Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), - scans, - antigravity, - cursor, - ], + const [transcripts, scanDirs, antigravityDirs] = yield* Effect.all( + [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans, antigravity], { concurrency: "unbounded" }, ); + // Antigravity reads between the other scan readers until it has a reader of its own. const scanned: readonly ScannedDir[] = [ ...transcripts, - ...scanDirs, + ...scanDirs.slice(0, -1).flat(), ...antigravityDirs, - ...cursorDirs, + ...(scanDirs.at(-1) ?? []), ]; return scanned; }); diff --git a/apps/server/src/usage/cursorUsageReader.test.ts b/apps/server/src/usage/cursorUsageReader.test.ts deleted file mode 100644 index 4897f4604a9e30a027a9c6d31ac4973cf1c73f06..0000000000000000000000000000000000000000 --- a/apps/server/src/usage/cursorUsageReader.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { CursorKeychainTimeoutError } from "@t3tools/provider-cursor/server"; -import { readCursorAccountUsage } from "./cursorUsageReader.ts"; - -describe("readCursorAccountUsage", () => { - it("asks for Keychain approval when the prompt goes unanswered", async () => { - const result = await readCursorAccountUsage( - { kind: "keychain" }, - 0, - 1, - () => Promise.reject(new Error("no network expected")), - () => Promise.reject(new CursorKeychainTimeoutError()), - ); - assert.deepStrictEqual(result, { - accountKey: null, - records: [], - missing: false, - error: "Allow Keychain access on the Mac running T3 Code, then refresh.", - }); - }); - - it("reads the pages behind the first together and keeps them in page order", async () => { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - let inFlight = 0; - let mostInFlight = 0; - const result = await readCursorAccountUsage( - { kind: "keychain" }, - 0, - 1781000000000, - async (_url, init) => { - const page: number = JSON.parse(String(init.body)).page; - inFlight++; - mostInFlight = Math.max(mostInFlight, inFlight); - // Later pages answer first, so record order cannot come from arrival order. - for (let turn = page; turn < 9; turn++) await Promise.resolve(); - inFlight--; - return Response.json({ - totalUsageEventsCount: 8001, - usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({ - timestamp: String(1780000000000 + (page - 1) * 1000 + index), - model: "gpt-5", - tokenUsage: { inputTokens: 1 }, - })), - }); - }, - async () => accessToken, - ); - assert.isNull(result.error); - assert.strictEqual(mostInFlight, 6); - assert.deepStrictEqual( - result.records.map((record) => record.timestampMs), - Array.from({ length: 8001 }, (_, index) => 1780000000000 + index), - ); - }); - - it("ends like a page-by-page read when a page fails, leaving no request open", async () => { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const fullPage = { - totalUsageEventsCount: 8001, - usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })), - }; - // Page 3 fails while the pages after it are still waiting on Cursor. - for (const [secondPage, error] of [ - [() => Response.json(fullPage), "Cursor account usage could not be read."], - [() => new Response(null, { status: 401 }), "Sign in to Cursor again to read account usage."], - ] as const) { - let open = 0; - const result = await readCursorAccountUsage( - { kind: "keychain" }, - 0, - 1781000000000, - (_url, init) => { - const page: number = JSON.parse(String(init.body)).page; - if (page === 1) return Promise.resolve(Response.json(fullPage)); - if (page === 2) return Promise.resolve(secondPage()); - if (page === 3) return Promise.reject(new Error("connection reset")); - open++; - return new Promise((_resolve, reject) => { - init.signal?.addEventListener("abort", () => { - open--; - reject(init.signal?.reason); - }); - }); - }, - async () => accessToken, - ); - assert.strictEqual(result.error, error); - assert.deepStrictEqual(result.records, []); - assert.strictEqual(open, 0); - } - }); -}); diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 3afa9ff90824d911ac56dc1063ffae8e15d120c1..b13a06a1c2d0d9d3ca7c6477deb7e15515b7f6e5 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { cursorRateModel } from "./cursorUsageReader.ts"; +import { cursorRateModel } from "@t3tools/provider-cursor/server/accountUsage"; import type { UsageSpeed } from "@t3tools/provider-core/server/usage"; import { cacheSavingsUsd, diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 60fdc8f27367236f45aab6a47ff8c7e47cd428e0..2cac91423697a6ccea4b701fdf206d91987f88da 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -10,7 +10,6 @@ import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; import { TEST_FORMATS } from "./usageTestFormats.ts"; import { readTranscriptRecords } from "./usageTranscriptReader.ts"; -import { readCursorAccountUsage } from "./cursorUsageReader.ts"; import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; let dir: string; @@ -252,235 +251,6 @@ describe("readTranscriptRecords resume", () => { }); describe("SQLite usage readers", () => { - it("reads Cursor account history with the default macOS Keychain login", async () => { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - let keychainReads = 0; - const result = await readCursorAccountUsage( - { kind: "keychain" }, - 0, - 1781000000000, - async (_url, init) => { - assert.include(new Headers(init.headers).get("cookie") ?? "", "demo%3A%3A"); - return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] }); - }, - async () => { - keychainReads++; - return accessToken; - }, - ); - assert.strictEqual(keychainReads, 1); - assert.isNull(result.error); - assert.isFalse(result.missing); - assert.isNotNull(result.accountKey); - }); - - it("reads paginated Cursor account history including headless calls with separate cache tokens", async () => { - const authPath = NodePath.join(dir, "auth.json"); - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; - await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); - const pages: number[] = []; - const signals: AbortSignal[] = []; - const request = async (url: string, init: RequestInit) => { - assert.strictEqual(String(url), "https://cursor.com/api/dashboard/get-filtered-usage-events"); - assert.strictEqual(init?.redirect, "error"); - const headers = new Headers(init?.headers); - assert.strictEqual(headers.get("origin"), "https://cursor.com"); - assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); - const body = JSON.parse(String(init?.body)); - pages.push(body.page); - if (init.signal) signals.push(init.signal); - return Response.json({ - totalUsageEventsCount: 1001, - usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({ - timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000), - model: "claude-sonnet-4-5", - conversationId: `conversation-${body.page}`, - isHeadless: body.page === 2, - chargedCents: 0, - tokenUsage: { - inputTokens: 10, - outputTokens: 5, - cacheReadTokens: 30, - cacheWriteTokens: 2, - totalCents: 25, - }, - })), - }); - }; - const result = await readCursorAccountUsage(authPath, 0, 1781000000000, request); - assert.isNull(result.error); - assert.deepStrictEqual(pages, [1, 2]); - assert.lengthOf(signals, 2); - assert.notStrictEqual(signals[0], signals[1]); - assert.strictEqual(result.records.length, 1001); - assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2"); - assert.deepStrictEqual(result.records[0]?.totals, { - uncachedInputTokens: 10, - cachedInputTokens: 30, - cacheCreationTokens: 2, - outputTokens: 5, - reasoningTokens: 0, - }); - assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25); - assert.isFalse(result.accountKey?.includes("demo") ?? true); - }); - - it("reads Cursor account history beyond 100 pages", async () => { - const authPath = NodePath.join(dir, "auth.json"); - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); - const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null })); - let requests = 0; - const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { - requests += 1; - return Response.json({ - totalUsageEventsCount: 100_001, - usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }], - }); - }); - assert.isNull(result.error); - assert.strictEqual(requests, 101); - assert.deepStrictEqual(result.records, []); - }); - - it("accepts confirmed empty Cursor usage but rejects error envelopes", async () => { - const authPath = NodePath.join(dir, "auth.json"); - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); - for (const body of [ - {}, - { totalUsageEventsCount: 0 }, - { totalUsageEventsCount: 0, usageEventsDisplay: [] }, - ]) { - const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => - Response.json(body), - ); - assert.isNull(result.error); - assert.deepStrictEqual(result.records, []); - assert.isFalse(result.missing); - } - for (const body of [ - { error: "upstream error" }, - { detail: "unknown error envelope" }, - { totalUsageEventsCount: 0, error: "upstream error" }, - null, - [], - "invalid", - 0, - ]) { - const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => - Response.json(body), - ); - assert.isNotNull(result.error); - assert.deepStrictEqual(result.records, []); - } - }); - - it("requires a terminal Cursor page after a full page reaches the reported count", async () => { - const authPath = NodePath.join(dir, "auth.json"); - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); - let requests = 0; - const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { - requests++; - return Response.json( - requests === 1 - ? { - totalUsageEventsCount: 1000, - usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({ - timestamp: String(1780000000000 + index), - model: "gpt-5", - tokenUsage: { inputTokens: 10, outputTokens: 5 }, - })), - } - : { totalUsageEventsCount: 1000 }, - ); - }); - assert.isNull(result.error); - assert.strictEqual(result.records.length, 1000); - assert.strictEqual(requests, 2); - }); - - it("removes only count-proven Cursor boundary copies and preserves identical billed events", async () => { - const authPath = NodePath.join(dir, "auth.json"); - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); - const event = (index: number) => ({ - timestamp: String(1780000000000 + index), - model: "gpt-5", - tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 }, - }); - for (const total of [2000, 2001]) { - let requests = 0; - const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { - requests++; - return Response.json({ - totalUsageEventsCount: total, - usageEventsDisplay: - requests === 1 - ? Array.from({ length: 1000 }, (_, index) => event(index)) - : requests === 2 - ? Array.from({ length: 1000 }, (_, index) => event(999 + index)) - : [event(1999)], - }); - }); - assert.isNull(result.error); - assert.strictEqual(result.records.length, total); - assert.strictEqual(requests, 3); - assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999); - assert.strictEqual( - result.records.filter((record) => record.timestampMs === 1780000000999).length, - total === 2000 ? 1 : 2, - ); - assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total); - } - let requests = 0; - const inconsistent = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { - requests++; - return Response.json({ - totalUsageEventsCount: 1001, - usageEventsDisplay: - requests === 1 - ? Array.from({ length: 1000 }, (_, index) => event(index)) - : [event(500), event(1000)], - }); - }); - assert.isNotNull(inconsistent.error); - assert.deepStrictEqual(inconsistent.records, []); - }); - - it("does not present truncated Cursor account pages or authentication failures as complete history", async () => { - const authPath = NodePath.join(dir, "auth.json"); - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; - await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); - const truncated = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => - Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }), - ); - assert.isNotNull(truncated.error); - assert.deepStrictEqual(truncated.records, []); - const denied = await readCursorAccountUsage( - authPath, - 0, - 1781000000000, - async () => new Response(accessToken, { status: 401 }), - ); - assert.isNotNull(denied.error); - assert.isFalse(denied.error?.includes(accessToken) ?? true); - assert.deepStrictEqual(denied.records, []); - let requested = false; - const missing = await readCursorAccountUsage( - NodePath.join(dir, "missing.json"), - 0, - 1781000000000, - async () => { - requested = true; - return Response.json({}); - }, - ); - assert.isTrue(missing.missing); - assert.isFalse(requested); - }); - it("deduplicates Antigravity generation and step usage while preserving retry model and token buckets", async () => { const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "session-1.db")); const stamp = protoNumber(1, 1780000000); diff --git a/packages/provider-core/src/server/usage.ts b/packages/provider-core/src/server/usage.ts index 9371797073163441e856396e7f8c94ae619d0a51..62ecb5796b1b0dffed7fa08deea3996a660f9d81 100644 --- a/packages/provider-core/src/server/usage.ts +++ b/packages/provider-core/src/server/usage.ts @@ -11,6 +11,7 @@ */ import type { ProviderInstanceId, + ServerSettings, UsageProviderKind, UsageSource, UsageTokenTotals, @@ -146,6 +147,8 @@ export interface ProviderUsageScan { export interface ProviderUsageScanInput { readonly instances: ReadonlyArray>; + /** The settings snapshot the whole read runs against. */ + readonly settings: ServerSettings; /** Files last written before this cannot hold records inside the window. */ readonly windowStartMs: number; /** The oldest usage the server keeps; account caches cover back to here. */ diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json index e7d2a7c8b353bd67e54074c03e11302325ed1d4d..b139d6ecc8dd81e807e618e492fd92a03136cd36 100644 --- a/packages/provider-cursor/package.json +++ b/packages/provider-cursor/package.json @@ -23,6 +23,22 @@ "types": "./src/server/CursorAgentSdk.ts", "import": "./src/server/CursorAgentSdk.ts" }, + "./server/accountUsage": { + "types": "./src/server/accountUsage.ts", + "import": "./src/server/accountUsage.ts" + }, + "./server/CursorAccountReader": { + "types": "./src/server/CursorAccountReader.ts", + "import": "./src/server/CursorAccountReader.ts" + }, + "./server/CursorKeychain": { + "types": "./src/server/CursorKeychain.ts", + "import": "./src/server/CursorKeychain.ts" + }, + "./server/CursorUsageAccounts": { + "types": "./src/server/CursorUsageAccounts.ts", + "import": "./src/server/CursorUsageAccounts.ts" + }, "./server/CursorSdk": { "types": "./src/server/CursorSdk.ts", "import": "./src/server/CursorSdk.ts" diff --git a/packages/provider-cursor/src/server.ts b/packages/provider-cursor/src/server.ts index 471737aa58fd92afc6b127399baa98610bee32a1..cc56dd7978e10712df8cfe87f8b8994029d59a25 100644 --- a/packages/provider-cursor/src/server.ts +++ b/packages/provider-cursor/src/server.ts @@ -1,10 +1,8 @@ /** - * Cursor's server entry: the driver the server registers, its adapter - * driver, the SDK runner layer the server provides once, and the keychain - * token reader the usage scanner shares. + * Cursor's server entry: the driver the server registers and its adapter + * driver. * * @module provider-cursor/server */ export { CursorDriver, type CursorDriverEnv } from "./server/driver.ts"; export { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./server/adapter.ts"; -export { CursorKeychainTimeoutError, readMacCursorAccessToken } from "./server/keychainToken.ts"; diff --git a/packages/provider-cursor/src/server/CursorAccountReader.ts b/packages/provider-cursor/src/server/CursorAccountReader.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6922ef87a53713a3c7b3148cf29dec7e23b46ab --- /dev/null +++ b/packages/provider-cursor/src/server/CursorAccountReader.ts @@ -0,0 +1,51 @@ +/** + * CursorAccountReader - reads one range of Cursor account usage from its + * dashboard API. A service so tests can stand in for Cursor's API. + * + * @module provider-cursor/server/CursorAccountReader + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; + +import type { CursorCredentialSource } from "./accountCache.ts"; +import { readCursorAccountUsage, type CursorAccountUsageReadResult } from "./accountUsage.ts"; +import * as CursorKeychain from "./CursorKeychain.ts"; + +export class CursorAccountReader extends Context.Service< + CursorAccountReader, + { + readonly read: ( + credentialSource: CursorCredentialSource, + sinceMs: number, + untilMs: number, + ) => Effect.Effect; + } +>()("@t3tools/provider-cursor/server/CursorAccountReader") {} + +/** Reads Cursor's dashboard API with the saved CLI or Keychain login. */ +export const layer = Layer.effect( + CursorAccountReader, + Effect.gen(function* () { + const keychain = yield* CursorKeychain.CursorKeychain; + return CursorAccountReader.of({ + read: (credentialSource, sinceMs, untilMs) => + Effect.gen(function* () { + // Only a Keychain login asks the Keychain. The dashboard reader + // turns its outcome, a failure included, into the source's message. + const token = + typeof credentialSource === "string" + ? Result.succeed(null) + : yield* Effect.result(keychain.accessToken); + return yield* Effect.promise(() => + readCursorAccountUsage(credentialSource, sinceMs, untilMs, globalThis.fetch, () => + Result.isSuccess(token) + ? Promise.resolve(token.success) + : Promise.reject(token.failure), + ), + ); + }), + }); + }), +); diff --git a/packages/provider-cursor/src/server/CursorKeychain.test.ts b/packages/provider-cursor/src/server/CursorKeychain.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebdcdbd15fe5329cbd08115a1d728a665a191718 --- /dev/null +++ b/packages/provider-cursor/src/server/CursorKeychain.test.ts @@ -0,0 +1,54 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; + +import * as CursorKeychain from "./CursorKeychain.ts"; + +describe("CursorKeychain", () => { + it.effect("shares concurrent reads and rechecks after the cache expires", () => + Effect.gen(function* () { + let reads = 0; + const keychain = CursorKeychain.make( + Effect.sync(() => { + reads++; + return `token-${reads}`; + }), + ); + assert.deepStrictEqual( + yield* Effect.all([keychain.accessToken, keychain.accessToken], { + concurrency: "unbounded", + }), + ["token-1", "token-1"], + ); + assert.strictEqual(yield* keychain.accessToken, "token-1"); + assert.strictEqual(reads, 1); + yield* TestClock.adjust(Duration.minutes(5)); + assert.strictEqual(yield* keychain.accessToken, "token-2"); + }), + ); + + it.effect("gives up on an unanswered prompt and reuses it on the next read", () => + Effect.gen(function* () { + let reads = 0; + const allow = yield* Deferred.make(); + const keychain = CursorKeychain.make( + Effect.suspend(() => { + reads++; + return Deferred.await(allow); + }), + Duration.millis(1), + ); + const timedOut = yield* keychain.accessToken.pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.millis(1)); + const error = yield* Fiber.join(timedOut).pipe(Effect.flip); + assert.isTrue(CursorKeychain.isCursorKeychainTimeoutError(error)); + const retry = yield* keychain.accessToken.pipe(Effect.forkChild); + yield* Deferred.succeed(allow, "token"); + assert.strictEqual(yield* Fiber.join(retry), "token"); + assert.strictEqual(reads, 1); + }), + ); +}); diff --git a/packages/provider-cursor/src/server/CursorKeychain.ts b/packages/provider-cursor/src/server/CursorKeychain.ts new file mode 100644 index 0000000000000000000000000000000000000000..30beb670343492c39e8d25072d4afc78a0c49632 --- /dev/null +++ b/packages/provider-cursor/src/server/CursorKeychain.ts @@ -0,0 +1,118 @@ +/** + * CursorKeychain - the Cursor CLI's macOS Keychain login, shared by usage + * history and usage limits in one server process. + * + * macOS shows the access prompt on the server's own screen, which a remote + * client cannot answer, so a read gives up after a timeout. The Keychain read + * stays in flight: the next read joins it instead of stacking a second prompt, + * and picks up the token once someone allows access. + * + * @module provider-cursor/server/CursorKeychain + */ +import * as NodeModule from "node:module"; + +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const CACHE_MS = 5 * 60_000; +const DEFAULT_TIMEOUT = Duration.seconds(30); + +const requireForKeyring = NodeModule.createRequire(import.meta.url); + +/** Nobody answered the macOS Keychain prompt in time. */ +export class CursorKeychainTimeoutError extends Schema.TaggedError()( + "CursorKeychainTimeoutError", + {}, +) { + override get message(): string { + return "Timed out waiting for Keychain access."; + } +} + +export const isCursorKeychainTimeoutError = Schema.is(CursorKeychainTimeoutError); + +/** The Keychain could not be read. */ +export class CursorKeychainReadError extends Schema.TaggedError()( + "CursorKeychainReadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Cursor Keychain credentials could not be read."; + } +} + +export class CursorKeychain extends Context.Service< + CursorKeychain, + { + /** The access token, or `null` when the Keychain holds no Cursor login. */ + readonly accessToken: Effect.Effect< + string | null, + CursorKeychainTimeoutError | CursorKeychainReadError + >; + } +>()("@t3tools/provider-cursor/server/CursorKeychain") {} + +/** Reads the Cursor CLI's default macOS credential without invoking the shared security binary. */ +const readKeychainEntry = Effect.tryPromise({ + try: async () => { + const { AsyncEntry } = requireForKeyring( + "@napi-rs/keyring", + ) as typeof import("@napi-rs/keyring"); + return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; + }, + catch: (cause) => new CursorKeychainReadError({ cause }), +}); + +/** A Keychain backed by `read`, which tests replace. */ +export const make = ( + read: Effect.Effect = readKeychainEntry, + timeout: Duration.Input = DEFAULT_TIMEOUT, +): CursorKeychain["Service"] => { + let cached: { readonly token: string; readonly untilMs: number } | null = null; + let pending: Deferred.Deferred | null = null; + + /** Joins the read in flight, or starts one that outlives the caller. */ + const startRead = Effect.uninterruptible( + Effect.gen(function* () { + if (pending !== null) return pending; + const done = Deferred.makeUnsafe(); + pending = done; + yield* read.pipe( + Effect.tap((token) => + Clock.currentTimeMillis.pipe( + Effect.map((nowMs) => { + cached = token ? { token, untilMs: nowMs + CACHE_MS } : null; + }), + ), + ), + Effect.onExit((exit) => + Effect.suspend(() => { + pending = null; + return Deferred.done(done, exit); + }), + ), + Effect.forkDetach, + ); + return done; + }), + ); + + const accessToken = Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis; + if (cached !== null && cached.untilMs > nowMs) return cached.token; + const done = yield* startRead; + const token = yield* Deferred.await(done).pipe(Effect.timeoutOption(timeout)); + if (Option.isNone(token)) return yield* new CursorKeychainTimeoutError(); + return token.value; + }); + + return CursorKeychain.of({ accessToken }); +}; + +export const layer = Layer.sync(CursorKeychain, () => make()); diff --git a/packages/provider-cursor/src/server/CursorUsageAccounts.ts b/packages/provider-cursor/src/server/CursorUsageAccounts.ts new file mode 100644 index 0000000000000000000000000000000000000000..852d746aed0078c35a73456f360a9214ad3f4e9e --- /dev/null +++ b/packages/provider-cursor/src/server/CursorUsageAccounts.ts @@ -0,0 +1,342 @@ +/** + * CursorUsageAccounts - Cursor's account usage as a usage source, answered + * from a cache. + * + * Cursor's account API is slow, so a read answers from the cached account + * history and marks itself `refreshing` while a background refresh runs; + * `awaitRefresh` waits for that refresh instead. The caches persist in the + * server's state directory, so a restart only refetches the newest edge. See + * `accountCache`. + * + * @module provider-cursor/server/CursorUsageAccounts + */ +import * as NodeOS from "node:os"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import type { ProviderUsageScan } from "@t3tools/provider-core/server/usage"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import { + CURSOR_ACCOUNT_CACHE_FILE_NAME, + CURSOR_ACCOUNT_TTL_MS, + cursorFetchRange, + decodeCursorAccountCaches, + encodeCursorAccountCaches, + isCursorCacheFresh, + mergeCursorFetch, + type CursorAccountCache, + type CursorCredentialSource, +} from "./accountCache.ts"; +import * as CursorAccountReader from "./CursorAccountReader.ts"; + +const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read."; + +/** The cache file is narrowed by hand in `accountCache`, so JSON is enough here. */ +const decodeCacheFile = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec), +); + +export class CursorUsageAccounts extends Context.Service< + CursorUsageAccounts, + { + /** This environment's Cursor account usage source, if it has one to report. */ + readonly scan: (input: { + /** From the same settings snapshot as the rest of the usage read. */ + readonly keychainUsageEnabled: boolean; + readonly windowStartMs: number; + readonly retentionCutoffMs: number; + readonly awaitRefresh: boolean; + }) => Effect.Effect>; + /** Waits for every cache write scheduled so far, as a restart would need. */ + readonly awaitPersisted: Effect.Effect; + } +>()("@t3tools/provider-cursor/server/CursorUsageAccounts") {} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const host = yield* ProviderHost.ProviderHost; + const accountReader = yield* CursorAccountReader.CursorAccountReader; + const hostEnvironment = yield* HostProcessEnvironment; + const platform = yield* HostProcessPlatform; + + const cachePath = path.join(host.paths.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME); + /** Account caches by credential source. */ + const caches = new Map(); + let cacheDirty = false; + /** + * The last failed refresh per credential source, standing for a TTL so a + * client refetching a broken login does not refetch Cursor each time. A + * `null` error means there is no login: no source to report. + */ + const failures = new Map(); + /** The refresh in flight per credential source, which every read joins. */ + const refreshes = new Map>(); + + /** Loads the persisted caches once; concurrent first readers await the same load. */ + const ensureLoaded = yield* Effect.cached( + fileSystem.readFileString(cachePath).pipe( + Effect.flatMap((raw) => decodeCacheFile(raw)), + Effect.catchCause(() => Effect.succeed(null)), + Effect.map((document) => { + for (const [key, cache] of decodeCursorAccountCaches(document)) caches.set(key, cache); + }), + ), + ); + + // Serialized so an older snapshot never lands after a newer one. The dirty + // flag is cleared before encoding, so a change while the write is in flight + // marks it dirty again; a failed write restores it so the next persist retries. + const persistLock = yield* Semaphore.make(1); + const persist = Effect.gen(function* () { + if (!cacheDirty) return; + cacheDirty = false; + yield* Effect.sync(() => encodeCursorAccountCaches(caches)).pipe( + Effect.flatMap((contents) => writeFileStringAtomically({ filePath: cachePath, contents })), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.catchCause(() => + Effect.sync(() => { + cacheDirty = true; + }), + ), + ); + }).pipe(persistLock.withPermit, Effect.withSpan("CursorUsageAccounts.persist")); + + const pendingPersists = new Set>(); + /** Writes the caches in the background, after the read that dirtied them answers. */ + const schedulePersist = Effect.forkDetach(persist).pipe( + Effect.map((fiber) => { + pendingPersists.add(fiber); + fiber.addObserver(() => pendingPersists.delete(fiber)); + }), + ); + const awaitPersisted = Effect.suspend(() => Fiber.awaitAll([...pendingPersists])).pipe( + Effect.asVoid, + ); + // A write still running at shutdown finishes first, so the next start keeps it. + yield* Effect.addFinalizer(() => awaitPersisted); + + /** Fetches what one account cache is missing, then persists it if anything changed. */ + const refreshAccount = Effect.fn("CursorUsageAccounts.refreshAccount")(function* ( + credential: CursorCredentialSource, + credentialKey: string, + retentionStartMs: number, + ) { + const nowMs = yield* Clock.currentTimeMillis; + const fetchMissing = (cache: CursorAccountCache | undefined) => { + const range = cursorFetchRange(cache, retentionStartMs, nowMs); + return accountReader + .read(credential, range.sinceMs, range.untilMs) + .pipe(Effect.map((result) => ({ range, result }))); + }; + let base = caches.get(credentialKey); + let fetched = yield* fetchMissing(base); + // Another login's history replaces the cached account's, even if reading it fails. + if ( + base !== undefined && + fetched.result.accountKey !== null && + fetched.result.accountKey !== base.accountKey + ) { + caches.delete(credentialKey); + cacheDirty = true; + base = undefined; + fetched = yield* fetchMissing(base); + } + const { range, result } = fetched; + if (result.missing || result.error !== null || result.accountKey === null) { + failures.set(credentialKey, { + atMs: nowMs, + error: result.missing || result.error !== null ? result.error : CURSOR_ACCOUNT_READ_ERROR, + }); + yield* schedulePersist; + return; + } + const merged = mergeCursorFetch( + base, + result.accountKey, + range, + result.records, + nowMs, + retentionStartMs, + ); + caches.set(credentialKey, merged.cache); + failures.delete(credentialKey); + // An unchanged edge is not worth rewriting the file for: after a restart + // the cache just refetches a slightly wider edge. Persisting anyway retries + // an earlier failed write; it is a no-op when nothing is dirty. + if (merged.changed) cacheDirty = true; + yield* schedulePersist; + }); + + /** Joins the refresh in flight, or starts one. */ + const startRefresh = ( + credential: CursorCredentialSource, + credentialKey: string, + retentionStartMs: number, + ) => + // Enrollment and fork are atomic, so an interrupted caller cannot leave a + // registered refresh that nothing will finish. + Effect.uninterruptible( + Effect.gen(function* () { + const current = refreshes.get(credentialKey); + if (current !== undefined) return current; + const done = Deferred.makeUnsafe(); + refreshes.set(credentialKey, done); + // Detached: a departing client must not cancel a fetch later reads reuse. + yield* refreshAccount(credential, credentialKey, retentionStartMs).pipe( + Effect.catchCause(() => + Clock.currentTimeMillis.pipe( + Effect.map((atMs) => + failures.set(credentialKey, { atMs, error: CURSOR_ACCOUNT_READ_ERROR }), + ), + ), + ), + Effect.ensuring( + Effect.suspend(() => { + refreshes.delete(credentialKey); + return Deferred.succeed(done, undefined); + }), + ), + Effect.forkDetach, + ); + return done; + }), + ); + + /** + * The account source. A cache inside its TTL, or a refresh that failed + * inside it, answers directly. Otherwise a refresh starts: `awaitRefresh` + * waits for it, and anything else answers from the cache marked `refreshing`. + */ + const accountSource = Effect.fn("CursorUsageAccounts.accountSource")(function* ( + credential: CursorCredentialSource, + authPath: string, + windowStartMs: number, + retentionStartMs: number, + awaitRefresh: boolean, + ) { + // No saved login means there is no account source to report, not a setup error. + if ( + typeof credential === "string" && + !(yield* fileSystem.exists(credential).pipe(Effect.orElseSucceed(() => true))) + ) { + return null; + } + yield* ensureLoaded; + const credentialKey = typeof credential === "string" ? credential : "keychain"; + const nowMs = yield* Clock.currentTimeMillis; + const recentFailure = failures.get(credentialKey); + let refreshing = false; + if ( + (recentFailure === undefined || nowMs - recentFailure.atMs >= CURSOR_ACCOUNT_TTL_MS) && + !isCursorCacheFresh(caches.get(credentialKey), nowMs) + ) { + const refresh = yield* startRefresh(credential, credentialKey, retentionStartMs); + if (awaitRefresh) yield* Deferred.await(refresh); + else refreshing = true; + } + + const cache = caches.get(credentialKey); + const failure = refreshing ? undefined : failures.get(credentialKey); + const failureMessage = failure === undefined ? undefined : failure.error; + if (failureMessage === null) return null; + if (cache === undefined) { + return { + dir: authPath, + // Never combine a local fallback with another server's account-wide history. + ...(refreshing + ? { files: [], refreshing: true } + : { files: null, message: failureMessage ?? CURSOR_ACCOUNT_READ_ERROR }), + } satisfies ProviderUsageScan; + } + // The same account includes CLI and desktop history from every machine. + // A stable remote fingerprint prevents connected environments counting it twice. + const source = `cursor-account:${cache.accountKey}`; + return { + dir: source, + hostId: "cursor.com", + volumeId: cache.accountKey, + files: [ + { + path: source, + records: cache.records.filter((record) => record.timestampMs >= windowStartMs), + }, + ], + ...(failureMessage === undefined + ? { status: "ok" } + : { status: "partial", message: failureMessage }), + ...(refreshing ? { refreshing: true } : {}), + } satisfies ProviderUsageScan; + }); + + const scan: CursorUsageAccounts["Service"]["scan"] = Effect.fn("CursorUsageAccounts.scan")( + function* ({ keychainUsageEnabled, windowStartMs, retentionCutoffMs, awaitRefresh }) { + const home = NodeOS.homedir(); + const userHome = + (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; + const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); + const cursorHome = + platform === "darwin" + ? path.join(userHome, "Library", "Application Support") + : platform === "win32" + ? hostEnvironment["APPDATA"] || path.join(userHome, "AppData", "Roaming") + : configHome && path.isAbsolute(configHome) + ? configHome + : path.join(userHome, ".config"); + const authPath = + platform === "darwin" + ? path.join(userHome, ".cursor", "auth.json") + : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); + const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; + const loginUnavailable = + Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || + Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) || + credentialStore === "memory"; + const useKeychain = platform === "darwin" && credentialStore !== "file"; + if (useKeychain && !loginUnavailable && !keychainUsageEnabled) { + return [ + { + dir: authPath, + volumeId: "", + files: null, + message: "Cursor account usage is off on this environment.", + action: "enableCursorKeychain", + }, + ]; + } + if (loginUnavailable) { + return [ + { + dir: authPath, + files: null, + message: "Cursor account history needs a Cursor CLI login on this server.", + }, + ]; + } + const source = yield* accountSource( + useKeychain ? { kind: "keychain" } : authPath, + authPath, + windowStartMs, + retentionCutoffMs, + awaitRefresh, + ); + return source === null ? [] : [source]; + }, + ); + + return CursorUsageAccounts.of({ scan, awaitPersisted }); +}); + +export const layer = Layer.effect(CursorUsageAccounts, make); diff --git a/apps/server/src/usage/cursorAccountCache.test.ts b/packages/provider-cursor/src/server/accountCache.test.ts similarity index 96% rename from apps/server/src/usage/cursorAccountCache.test.ts rename to packages/provider-cursor/src/server/accountCache.test.ts index da87e54c41574fcae0d989e41bbacb862454387f..56da1533ca0bfcfb27f8c5c1b0541aed4c965889 100644 --- a/apps/server/src/usage/cursorAccountCache.test.ts +++ b/packages/provider-cursor/src/server/accountCache.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; -import { cursorFetchRange, mergeCursorFetch } from "./cursorAccountCache.ts"; +import { cursorFetchRange, mergeCursorFetch } from "./accountCache.ts"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; const DAY_MS = 24 * 60 * 60 * 1000; diff --git a/apps/server/src/usage/cursorAccountCache.ts b/packages/provider-cursor/src/server/accountCache.ts similarity index 98% rename from apps/server/src/usage/cursorAccountCache.ts rename to packages/provider-cursor/src/server/accountCache.ts index fd0a8ae6b08dc2b4f7db4f7f940fe0f7a384a8c3..a7322328746bd906e5be9dc7960e1b790f937979 100644 --- a/apps/server/src/usage/cursorAccountCache.ts +++ b/packages/provider-cursor/src/server/accountCache.ts @@ -10,9 +10,9 @@ * that range rather than adding to it, so each timestamp's records always come * from a single fetch and their occurrence indexes cannot collide. * - * @module cursorAccountCache + * @module provider-cursor/server/accountCache */ -import { cursorRateModel } from "./cursorUsageReader.ts"; +import { cursorRateModel } from "./accountUsage.ts"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; export type CursorCredentialSource = string | { readonly kind: "keychain" }; diff --git a/packages/provider-cursor/src/server/accountUsage.test.ts b/packages/provider-cursor/src/server/accountUsage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..32a0f41c0612489c99f677910f0cbc1fbe374ba9 --- /dev/null +++ b/packages/provider-cursor/src/server/accountUsage.test.ts @@ -0,0 +1,340 @@ +// @effect-diagnostics nodeBuiltinImport:off - the reader under test reads a +// real CLI credential file from disk. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; + +import { CursorKeychainTimeoutError } from "./CursorKeychain.ts"; +import { readCursorAccountUsage } from "./accountUsage.ts"; + +let dir: string; + +beforeEach(async () => { + dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-")); +}); + +afterEach(async () => { + await NodeFSP.rm(dir, { recursive: true, force: true }); +}); + +describe("Cursor account history", () => { + it("reads Cursor account history with the default macOS Keychain login", async () => { + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + let keychainReads = 0; + const result = await readCursorAccountUsage( + { kind: "keychain" }, + 0, + 1781000000000, + async (_url, init) => { + assert.include(new Headers(init.headers).get("cookie") ?? "", "demo%3A%3A"); + return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] }); + }, + async () => { + keychainReads++; + return accessToken; + }, + ); + assert.strictEqual(keychainReads, 1); + assert.isNull(result.error); + assert.isFalse(result.missing); + assert.isNotNull(result.accountKey); + }); + + it("reads paginated Cursor account history including headless calls with separate cache tokens", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const pages: number[] = []; + const signals: AbortSignal[] = []; + const request = async (url: string, init: RequestInit) => { + assert.strictEqual(String(url), "https://cursor.com/api/dashboard/get-filtered-usage-events"); + assert.strictEqual(init?.redirect, "error"); + const headers = new Headers(init?.headers); + assert.strictEqual(headers.get("origin"), "https://cursor.com"); + assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); + const body = JSON.parse(String(init?.body)); + pages.push(body.page); + if (init.signal) signals.push(init.signal); + return Response.json({ + totalUsageEventsCount: 1001, + usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({ + timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000), + model: "claude-sonnet-4-5", + conversationId: `conversation-${body.page}`, + isHeadless: body.page === 2, + chargedCents: 0, + tokenUsage: { + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 30, + cacheWriteTokens: 2, + totalCents: 25, + }, + })), + }); + }; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, request); + assert.isNull(result.error); + assert.deepStrictEqual(pages, [1, 2]); + assert.lengthOf(signals, 2); + assert.notStrictEqual(signals[0], signals[1]); + assert.strictEqual(result.records.length, 1001); + assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2"); + assert.deepStrictEqual(result.records[0]?.totals, { + uncachedInputTokens: 10, + cachedInputTokens: 30, + cacheCreationTokens: 2, + outputTokens: 5, + reasoningTokens: 0, + }); + assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25); + assert.isFalse(result.accountKey?.includes("demo") ?? true); + }); + + it("reads Cursor account history beyond 100 pages", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null })); + let requests = 0; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests += 1; + return Response.json({ + totalUsageEventsCount: 100_001, + usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }], + }); + }); + assert.isNull(result.error); + assert.strictEqual(requests, 101); + assert.deepStrictEqual(result.records, []); + }); + + it("accepts confirmed empty Cursor usage but rejects error envelopes", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + for (const body of [ + {}, + { totalUsageEventsCount: 0 }, + { totalUsageEventsCount: 0, usageEventsDisplay: [] }, + ]) { + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => + Response.json(body), + ); + assert.isNull(result.error); + assert.deepStrictEqual(result.records, []); + assert.isFalse(result.missing); + } + for (const body of [ + { error: "upstream error" }, + { detail: "unknown error envelope" }, + { totalUsageEventsCount: 0, error: "upstream error" }, + null, + [], + "invalid", + 0, + ]) { + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => + Response.json(body), + ); + assert.isNotNull(result.error); + assert.deepStrictEqual(result.records, []); + } + }); + + it("requires a terminal Cursor page after a full page reaches the reported count", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + let requests = 0; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests++; + return Response.json( + requests === 1 + ? { + totalUsageEventsCount: 1000, + usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({ + timestamp: String(1780000000000 + index), + model: "gpt-5", + tokenUsage: { inputTokens: 10, outputTokens: 5 }, + })), + } + : { totalUsageEventsCount: 1000 }, + ); + }); + assert.isNull(result.error); + assert.strictEqual(result.records.length, 1000); + assert.strictEqual(requests, 2); + }); + + it("removes only count-proven Cursor boundary copies and preserves identical billed events", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const event = (index: number) => ({ + timestamp: String(1780000000000 + index), + model: "gpt-5", + tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 }, + }); + for (const total of [2000, 2001]) { + let requests = 0; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests++; + return Response.json({ + totalUsageEventsCount: total, + usageEventsDisplay: + requests === 1 + ? Array.from({ length: 1000 }, (_, index) => event(index)) + : requests === 2 + ? Array.from({ length: 1000 }, (_, index) => event(999 + index)) + : [event(1999)], + }); + }); + assert.isNull(result.error); + assert.strictEqual(result.records.length, total); + assert.strictEqual(requests, 3); + assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999); + assert.strictEqual( + result.records.filter((record) => record.timestampMs === 1780000000999).length, + total === 2000 ? 1 : 2, + ); + assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total); + } + let requests = 0; + const inconsistent = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests++; + return Response.json({ + totalUsageEventsCount: 1001, + usageEventsDisplay: + requests === 1 + ? Array.from({ length: 1000 }, (_, index) => event(index)) + : [event(500), event(1000)], + }); + }); + assert.isNotNull(inconsistent.error); + assert.deepStrictEqual(inconsistent.records, []); + }); + + it("does not present truncated Cursor account pages or authentication failures as complete history", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const truncated = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => + Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }), + ); + assert.isNotNull(truncated.error); + assert.deepStrictEqual(truncated.records, []); + const denied = await readCursorAccountUsage( + authPath, + 0, + 1781000000000, + async () => new Response(accessToken, { status: 401 }), + ); + assert.isNotNull(denied.error); + assert.isFalse(denied.error?.includes(accessToken) ?? true); + assert.deepStrictEqual(denied.records, []); + let requested = false; + const missing = await readCursorAccountUsage( + NodePath.join(dir, "missing.json"), + 0, + 1781000000000, + async () => { + requested = true; + return Response.json({}); + }, + ); + assert.isTrue(missing.missing); + assert.isFalse(requested); + }); +}); + +describe("readCursorAccountUsage", () => { + it("asks for Keychain approval when the prompt goes unanswered", async () => { + const result = await readCursorAccountUsage( + { kind: "keychain" }, + 0, + 1, + () => Promise.reject(new Error("no network expected")), + () => Promise.reject(new CursorKeychainTimeoutError()), + ); + assert.deepStrictEqual(result, { + accountKey: null, + records: [], + missing: false, + error: "Allow Keychain access on the Mac running T3 Code, then refresh.", + }); + }); + + it("reads the pages behind the first together and keeps them in page order", async () => { + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + let inFlight = 0; + let mostInFlight = 0; + const result = await readCursorAccountUsage( + { kind: "keychain" }, + 0, + 1781000000000, + async (_url, init) => { + const page: number = JSON.parse(String(init.body)).page; + inFlight++; + mostInFlight = Math.max(mostInFlight, inFlight); + // Later pages answer first, so record order cannot come from arrival order. + for (let turn = page; turn < 9; turn++) await Promise.resolve(); + inFlight--; + return Response.json({ + totalUsageEventsCount: 8001, + usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({ + timestamp: String(1780000000000 + (page - 1) * 1000 + index), + model: "gpt-5", + tokenUsage: { inputTokens: 1 }, + })), + }); + }, + async () => accessToken, + ); + assert.isNull(result.error); + assert.strictEqual(mostInFlight, 6); + assert.deepStrictEqual( + result.records.map((record) => record.timestampMs), + Array.from({ length: 8001 }, (_, index) => 1780000000000 + index), + ); + }); + + it("ends like a page-by-page read when a page fails, leaving no request open", async () => { + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + const fullPage = { + totalUsageEventsCount: 8001, + usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })), + }; + // Page 3 fails while the pages after it are still waiting on Cursor. + for (const [secondPage, error] of [ + [() => Response.json(fullPage), "Cursor account usage could not be read."], + [() => new Response(null, { status: 401 }), "Sign in to Cursor again to read account usage."], + ] as const) { + let open = 0; + const result = await readCursorAccountUsage( + { kind: "keychain" }, + 0, + 1781000000000, + (_url, init) => { + const page: number = JSON.parse(String(init.body)).page; + if (page === 1) return Promise.resolve(Response.json(fullPage)); + if (page === 2) return Promise.resolve(secondPage()); + if (page === 3) return Promise.reject(new Error("connection reset")); + open++; + return new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => { + open--; + reject(init.signal?.reason); + }); + }); + }, + async () => accessToken, + ); + assert.strictEqual(result.error, error); + assert.deepStrictEqual(result.records, []); + assert.strictEqual(open, 0); + } + }); +}); diff --git a/apps/server/src/usage/cursorUsageReader.ts b/packages/provider-cursor/src/server/accountUsage.ts similarity index 90% rename from apps/server/src/usage/cursorUsageReader.ts rename to packages/provider-cursor/src/server/accountUsage.ts index 90d2f6c60cc8e219feb6bd11b4872a0dc3a70126..4ecab1ace8769ee97b79e6dac94358da57cf8f4a 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/packages/provider-cursor/src/server/accountUsage.ts @@ -1,18 +1,19 @@ // Node fs reads CLI credentials, and crypto hashes account IDs for deduplication. // @effect-diagnostics nodeBuiltinImport:off +/** + * Cursor account usage from its dashboard API, read with the saved CLI or + * Keychain login. The dashboard covers CLI, desktop and headless usage from + * every machine on the account. + * + * @module provider-cursor/server/accountUsage + */ import * as NodeFSP from "node:fs/promises"; import * as NodeCrypto from "node:crypto"; import * as NodeTimersPromises from "node:timers/promises"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - import type { UsageRecord } from "@t3tools/provider-core/server/usage"; -import { - CursorKeychainTimeoutError, - readMacCursorAccessToken, -} from "@t3tools/provider-cursor/server"; + +import { isCursorKeychainTimeoutError } from "./CursorKeychain.ts"; function object(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -69,13 +70,17 @@ function boundaryOverlap(previous: readonly string[], current: readonly string[] return lengths.at(-1) ?? 0; } -/** Dashboard usage includes headless agents and reports fresh input separately from cache reads. */ +/** + * Dashboard usage includes headless agents and reports fresh input separately + * from cache reads. `keychainToken` is only called for a Keychain login. + */ export async function readCursorAccountUsage( credentialSource: string | { readonly kind: "keychain" }, sinceMs: number, endDate: number, request: (url: string, init: RequestInit) => Promise = globalThis.fetch, - keychainToken: () => Promise = readMacCursorAccessToken, + keychainToken: () => Promise = () => + Promise.reject(new Error("No Keychain reader")), ): Promise { let accessToken: unknown; try { @@ -93,7 +98,7 @@ export async function readCursorAccountUsage( ? null : typeof credentialSource === "string" ? "Cursor credentials could not be read." - : cause instanceof CursorKeychainTimeoutError + : isCursorKeychainTimeoutError(cause) ? "Allow Keychain access on the Mac running T3 Code, then refresh." : "Cursor Keychain credentials could not be read.", }; @@ -293,24 +298,3 @@ export async function readCursorAccountUsage( cancel.abort(); } } - -/** Reads one range of Cursor account usage. A service so tests can stand in for Cursor's API. */ -export class CursorAccountReader extends Context.Service< - CursorAccountReader, - { - readonly read: ( - credentialSource: string | { readonly kind: "keychain" }, - sinceMs: number, - untilMs: number, - ) => Effect.Effect; - } ->()("t3/usage/cursorUsageReader/CursorAccountReader") {} - -/** Reads Cursor's dashboard API with the saved CLI or Keychain login. */ -export const layer = Layer.succeed( - CursorAccountReader, - CursorAccountReader.of({ - read: (credentialSource, sinceMs, untilMs) => - Effect.promise(() => readCursorAccountUsage(credentialSource, sinceMs, untilMs)), - }), -); diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts index ee0d2ba1ac8508ddc0155756f3e0301e3f487c9b..8215f6563d488ce9cf6626914b4309f6991b6a63 100644 --- a/packages/provider-cursor/src/server/driver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -15,6 +15,7 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import { CursorDriver } from "./driver.ts"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; import * as CursorSdk from "./CursorSdk.ts"; +import * as CursorKeychain from "./CursorKeychain.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -30,6 +31,11 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe( open: () => Effect.die("Maintenance resolution must not open a Cursor session"), }), ), + Layer.provideMerge( + Layer.succeed(CursorKeychain.CursorKeychain, { + accessToken: Effect.die("The driver test must not read the Keychain"), + }), + ), Layer.provideMerge( Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index 698ab5a88538791adba731ab73a02ec7d4d4d522..a9fa4b31acb46fe02cf670e9daf9dba9f3a76249 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -41,6 +41,9 @@ import { makeCursorAuth } from "./auth.ts"; import * as CursorCredentialStore from "./credentialStore.ts"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; import * as CursorSdk from "./CursorSdk.ts"; +import * as CursorKeychain from "./CursorKeychain.ts"; +import * as CursorUsageAccounts from "./CursorUsageAccounts.ts"; + const decodeCursorSettings = Schema.decodeSync(CursorSettings); const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError); @@ -57,9 +60,14 @@ export type CursorDriverEnv = | Path.Path | HttpClient.HttpClient | CursorSdk.CursorSdk - | ProviderHost.ProviderHost; + | ProviderHost.ProviderHost + | CursorKeychain.CursorKeychain; -export const CursorDriver: ProviderDriver = { +export const CursorDriver: ProviderDriver< + CursorSettings, + CursorDriverEnv, + CursorUsageAccounts.CursorUsageAccounts +> = { driverKind: DRIVER_KIND, metadata: { displayName: "Cursor", @@ -67,12 +75,29 @@ export const CursorDriver: ProviderDriver = { }, configSchema: CursorSettings, defaultConfig: (): CursorSettings => decodeCursorSettings({}), + // One account source per environment: the host's Cursor CLI login. + usage: { + kind: "scan", + provider: "cursor", + scan: ({ settings, windowStartMs, retentionCutoffMs, awaitRefresh }) => + CursorUsageAccounts.CursorUsageAccounts.pipe( + Effect.flatMap((accounts) => + accounts.scan({ + keychainUsageEnabled: settings.cursorKeychainUsageEnabled, + windowStartMs, + retentionCutoffMs, + awaitRefresh, + }), + ), + ), + }, create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const host = yield* ProviderHost.ProviderHost; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; + const keychain = yield* CursorKeychain.CursorKeychain; const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -221,6 +246,7 @@ export const CursorDriver: ProviderDriver = { Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), + Effect.provideService(CursorKeychain.CursorKeychain, keychain), Effect.map(stampSnapshot), Effect.provide(CursorSdkCatalog.layer), ); diff --git a/packages/provider-cursor/src/server/keychainToken.test.ts b/packages/provider-cursor/src/server/keychainToken.test.ts deleted file mode 100644 index 7f279bb6089243954c98c8c10c42dd250e482686..0000000000000000000000000000000000000000 --- a/packages/provider-cursor/src/server/keychainToken.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { CursorKeychainTimeoutError, makeCachedCursorAccessTokenReader } from "./keychainToken.ts"; - -describe("Cursor Keychain reader", () => { - it("shares concurrent reads and rechecks after the cache expires", async () => { - let reads = 0; - let time = 0; - const read = makeCachedCursorAccessTokenReader( - async () => { - reads++; - return `token-${reads}`; - }, - () => time, - ); - assert.deepStrictEqual(await Promise.all([read(), read()]), ["token-1", "token-1"]); - assert.strictEqual(await read(), "token-1"); - assert.strictEqual(reads, 1); - time = 5 * 60_000; - assert.strictEqual(await read(), "token-2"); - }); - - it("gives up on an unanswered prompt and reuses it on the next read", async () => { - let reads = 0; - let allow: (token: string) => void = () => {}; - const read = makeCachedCursorAccessTokenReader( - () => { - reads++; - return new Promise((resolve) => { - allow = resolve; - }); - }, - () => 0, - 1, - ); - const error = await read().catch((cause: unknown) => cause); - assert.instanceOf(error, CursorKeychainTimeoutError); - const retry = read(); - allow("token"); - assert.strictEqual(await retry, "token"); - assert.strictEqual(reads, 1); - }); -}); diff --git a/packages/provider-cursor/src/server/keychainToken.ts b/packages/provider-cursor/src/server/keychainToken.ts deleted file mode 100644 index 2857683e4821c4c608632dbbb53591e1d1966e87..0000000000000000000000000000000000000000 --- a/packages/provider-cursor/src/server/keychainToken.ts +++ /dev/null @@ -1,55 +0,0 @@ -import * as NodeModule from "node:module"; - -const CACHE_MS = 5 * 60_000; - -const requireForKeyring = NodeModule.createRequire(import.meta.url); - -/** Rejected when nobody answers the macOS Keychain prompt in time. */ -export class CursorKeychainTimeoutError extends Error { - constructor() { - super("Timed out waiting for Keychain access."); - } -} - -/** - * Share one Keychain request across usage history and limits in this server process. - * - * macOS shows the access prompt on the server's own screen, which a remote - * client cannot answer, so callers give up after `timeoutMs`. The read stays in - * flight: the next call reuses it instead of stacking a second prompt, and picks - * up the token once someone allows access. - */ -export function makeCachedCursorAccessTokenReader( - read: () => Promise, - now: () => number = Date.now, - timeoutMs = 30_000, -): () => Promise { - let cached: { token: string; until: number } | null = null; - let pending: Promise | null = null; - return () => { - if (cached && cached.until > now()) return Promise.resolve(cached.token); - pending ??= read() - .then((token) => { - cached = token ? { token, until: now() + CACHE_MS } : null; - return token; - }) - .finally(() => { - pending = null; - }); - const deadline = AbortSignal.timeout(timeoutMs); - return Promise.race([ - pending, - new Promise((_, reject) => { - deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), { - once: true, - }); - }), - ]); - }; -} - -/** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */ -export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => { - const { AsyncEntry } = requireForKeyring("@napi-rs/keyring") as typeof import("@napi-rs/keyring"); - return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; -}); diff --git a/packages/provider-cursor/src/server/usageLimits.test.ts b/packages/provider-cursor/src/server/usageLimits.test.ts index ff765fcdec2b23274b3de0f437746f5b173d22d9..bb3e387030374f966af325ab3f45873d00072170 100644 --- a/packages/provider-cursor/src/server/usageLimits.test.ts +++ b/packages/provider-cursor/src/server/usageLimits.test.ts @@ -6,10 +6,19 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as CursorKeychain from "./CursorKeychain.ts"; import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts"; -const withNodeServices = (effect: Effect.Effect) => - effect.pipe(Effect.provide(NodeServices.layer)); +const withNodeServices = ( + effect: Effect.Effect, + accessToken: CursorKeychain.CursorKeychain["Service"]["accessToken"] = Effect.die( + "must not read Keychain", + ), +) => + effect.pipe( + Effect.provideService(CursorKeychain.CursorKeychain, { accessToken }), + Effect.provide(NodeServices.layer), + ); describe("Cursor usage limits", () => { const checkedAt = "2026-09-16T00:00:00.000Z"; @@ -121,9 +130,6 @@ describe("Cursor usage limits", () => { ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), }, false, - async () => { - throw new Error("must not read Keychain before opt-in"); - }, ).pipe( Effect.provideService(HostProcessPlatform, platform), Effect.provideService( @@ -195,7 +201,7 @@ describe("Cursor usage limits", () => { it.effect("reads the default macOS Cursor login from Keychain for limits", () => Effect.gen(function* () { const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe( Effect.provideService(HostProcessPlatform, "darwin"), Effect.provideService( FileSystem.FileSystem, @@ -216,6 +222,7 @@ describe("Cursor usage limits", () => { }), ), ), + Effect.succeed("keychain-token"), ); expect(limits.windows[0]?.usedPercent).toBe(42); }), @@ -224,15 +231,18 @@ describe("Cursor usage limits", () => { it.effect("reports a Keychain initialization failure without failing the provider refresh", () => Effect.gen(function* () { const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { - throw new Error("Keychain initialization failed"); - }).pipe( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe( Effect.provideService(HostProcessPlatform, "darwin"), Effect.provideService( HttpClient.HttpClient, HttpClient.make(() => Effect.die("must not request limits without a login")), ), ), + Effect.fail( + new CursorKeychain.CursorKeychainReadError({ + cause: new Error("Keychain initialization failed"), + }), + ), ); expect(limits.unavailable?.reason).toBe("probeFailed"); }), @@ -247,9 +257,7 @@ describe("Cursor usage limits", () => { ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], ] as const) { const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { - throw new Error("must not read Keychain for a custom endpoint"); - }).pipe( + readCursorUsageLimits({ apiEndpoint }, environment, true).pipe( Effect.provideService(HostProcessPlatform, "darwin"), Effect.provideService( HttpClient.HttpClient, diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts index 78f096f8d3356883195431c299d1e8598ef42039..57f74bc54488e99662b9b1c975b20a7855177f71 100644 --- a/packages/provider-cursor/src/server/usageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -17,7 +17,7 @@ import { makeUnavailableUsageLimits, makeUsageLimits, } from "@t3tools/provider-core/server/usageLimits"; -import { readMacCursorAccessToken } from "./keychainToken.ts"; +import * as CursorKeychain from "./CursorKeychain.ts"; const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; @@ -66,7 +66,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function settings: Pick, environment: NodeJS.ProcessEnv = process.env, allowKeychain = false, - keychainToken: () => Promise = readMacCursorAccessToken, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); return yield* Effect.gen(function* () { @@ -106,7 +105,8 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", }); } - token = (yield* Effect.tryPromise(keychainToken))?.trim(); + const keychain = yield* CursorKeychain.CursorKeychain; + token = (yield* keychain.accessToken)?.trim(); } else if (!token) { const home = (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); -- 2.54.0