| 1 | /** |
| 2 | * CursorKeychain - the Cursor CLI's macOS Keychain login, shared by usage |
| 3 | * history and usage limits in one server process. |
| 4 | * |
| 5 | * macOS shows the access prompt on the server's own screen, which a remote |
| 6 | * client cannot answer, so a read gives up after a timeout. The Keychain read |
| 7 | * stays in flight: the next read joins it instead of stacking a second prompt, |
| 8 | * and picks up the token once someone allows access. |
| 9 | * |
| 10 | * @module provider-cursor/server/CursorKeychain |
| 11 | */ |
| 12 | import * as NodeModule from "node:module"; |
| 13 | |
| 14 | import * as Clock from "effect/Clock"; |
| 15 | import * as Context from "effect/Context"; |
| 16 | import * as Deferred from "effect/Deferred"; |
| 17 | import * as Duration from "effect/Duration"; |
| 18 | import * as Effect from "effect/Effect"; |
| 19 | import * as Layer from "effect/Layer"; |
| 20 | import * as Option from "effect/Option"; |
| 21 | import * as Schema from "effect/Schema"; |
| 22 | |
| 23 | const CACHE_MS = 5 * 60_000; |
| 24 | const DEFAULT_TIMEOUT = Duration.seconds(30); |
| 25 | |
| 26 | const requireForKeyring = NodeModule.createRequire(import.meta.url); |
| 27 | |
| 28 | /** Nobody answered the macOS Keychain prompt in time. */ |
| 29 | export class CursorKeychainTimeoutError extends Schema.TaggedError<CursorKeychainTimeoutError>()( |
| 30 | "CursorKeychainTimeoutError", |
| 31 | {}, |
| 32 | ) { |
| 33 | override get message(): string { |
| 34 | return "Timed out waiting for Keychain access."; |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | export const isCursorKeychainTimeoutError = Schema.is(CursorKeychainTimeoutError); |
| 39 | |
| 40 | /** The Keychain could not be read. */ |
| 41 | export class CursorKeychainReadError extends Schema.TaggedError<CursorKeychainReadError>()( |
| 42 | "CursorKeychainReadError", |
| 43 | { cause: Schema.Defect() }, |
| 44 | ) { |
| 45 | override get message(): string { |
| 46 | return "Cursor Keychain credentials could not be read."; |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | export class CursorKeychain extends Context.Service< |
| 51 | CursorKeychain, |
| 52 | { |
| 53 | /** The access token, or `null` when the Keychain holds no Cursor login. */ |
| 54 | readonly accessToken: Effect.Effect< |
| 55 | string | null, |
| 56 | CursorKeychainTimeoutError | CursorKeychainReadError |
| 57 | >; |
| 58 | } |
| 59 | >()("@t3tools/provider-cursor/server/CursorKeychain") {} |
| 60 | |
| 61 | /** Reads the Cursor CLI's default macOS credential without invoking the shared security binary. */ |
| 62 | const readKeychainEntry = Effect.tryPromise({ |
| 63 | try: async () => { |
| 64 | const { AsyncEntry } = requireForKeyring( |
| 65 | "@napi-rs/keyring", |
| 66 | ) as typeof import("@napi-rs/keyring"); |
| 67 | return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; |
| 68 | }, |
| 69 | catch: (cause) => new CursorKeychainReadError({ cause }), |
| 70 | }); |
| 71 | |
| 72 | /** A Keychain backed by `read`, which tests replace. */ |
| 73 | export const make = ( |
| 74 | read: Effect.Effect<string | null, CursorKeychainReadError> = readKeychainEntry, |
| 75 | timeout: Duration.Input = DEFAULT_TIMEOUT, |
| 76 | ): CursorKeychain["Service"] => { |
| 77 | let cached: { readonly token: string; readonly untilMs: number } | null = null; |
| 78 | let pending: Deferred.Deferred<string | null, CursorKeychainReadError> | null = null; |
| 79 | |
| 80 | /** Joins the read in flight, or starts one that outlives the caller. */ |
| 81 | const startRead = Effect.uninterruptible( |
| 82 | Effect.gen(function* () { |
| 83 | if (pending !== null) return pending; |
| 84 | const done = Deferred.makeUnsafe<string | null, CursorKeychainReadError>(); |
| 85 | pending = done; |
| 86 | yield* read.pipe( |
| 87 | Effect.tap((token) => |
| 88 | Clock.currentTimeMillis.pipe( |
| 89 | Effect.map((nowMs) => { |
| 90 | cached = token ? { token, untilMs: nowMs + CACHE_MS } : null; |
| 91 | }), |
| 92 | ), |
| 93 | ), |
| 94 | Effect.onExit((exit) => |
| 95 | Effect.suspend(() => { |
| 96 | pending = null; |
| 97 | return Deferred.done(done, exit); |
| 98 | }), |
| 99 | ), |
| 100 | Effect.forkDetach, |
| 101 | ); |
| 102 | return done; |
| 103 | }), |
| 104 | ); |
| 105 | |
| 106 | const accessToken = Effect.gen(function* () { |
| 107 | const nowMs = yield* Clock.currentTimeMillis; |
| 108 | if (cached !== null && cached.untilMs > nowMs) return cached.token; |
| 109 | const done = yield* startRead; |
| 110 | const token = yield* Deferred.await(done).pipe(Effect.timeoutOption(timeout)); |
| 111 | if (Option.isNone(token)) return yield* new CursorKeychainTimeoutError(); |
| 112 | return token.value; |
| 113 | }); |
| 114 | |
| 115 | return CursorKeychain.of({ accessToken }); |
| 116 | }; |
| 117 | |
| 118 | export const layer = Layer.sync(CursorKeychain, () => make()); |