Repository navigation
refactor(cursor): use shared provider account setup and storage #12966
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
juliusmarminge
merged 6 commits into
t3code/codex-turn-mapping
from
provider-auth/cursor
Sep 22, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
d03963c
refactor(cursor): use shared provider sign-in UI and secret storage
juliusmarminge c396afd
fix(cursor): construct credential persistence from driver infrastructure
juliusmarminge 1dfd110
test(cursor): verify shared credential persistence and damaged-store …
juliusmarminge 6d6c791
fix(cursor): migrate file-based sign-ins into the secret store
juliusmarminge daa32c1
fix(cursor): replace damaged stored sign-ins; explain CURSOR_API_KEY …
juliusmarminge ccf168b
refactor(cursor): namespace the credential store import
juliusmarminge File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| import { assert, it } from "@effect/vitest"; | ||
| import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as FileSystem from "effect/FileSystem"; | ||
| import * as Path from "effect/Path"; | ||
| import * as Option from "effect/Option"; | ||
|
|
||
| import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; | ||
| import { makeCursorCredentialStore } from "./CursorCredentialStore.ts"; | ||
|
|
||
| const makeSecrets = () => { | ||
| const data = new Map<string, Uint8Array>(); | ||
| return { | ||
| data, | ||
| secrets: ServerSecretStore.of({ | ||
| get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), | ||
| set: (key, bytes) => | ||
| Effect.sync(() => { | ||
| data.set(key, bytes); | ||
| }), | ||
| remove: (key) => | ||
| Effect.sync(() => { | ||
| data.delete(key); | ||
| }), | ||
| create: () => Effect.die("unused"), | ||
| getOrCreateRandom: () => Effect.die("unused"), | ||
| }), | ||
| }; | ||
| }; | ||
|
|
||
| const legacyCredentials = { | ||
| version: 1 as const, | ||
| backendUrl: "https://api.cursor.com", | ||
| apiKey: "test-only-legacy-key", | ||
| createdAtMs: 100, | ||
| }; | ||
| // The SDK's FileCredentialStore writes pretty-printed JSON. | ||
| const legacyFileText = `{ | ||
| "version": 1, | ||
| "backendUrl": "https://api.cursor.com", | ||
| "apiKey": "test-only-legacy-key", | ||
| "createdAtMs": 100 | ||
| }`; | ||
|
|
||
| it.effect.each([ | ||
| { | ||
| name: "imports a legacy sign-in", | ||
| legacy: legacyFileText, | ||
| stored: undefined, | ||
| expected: "test-only-legacy-key", | ||
| }, | ||
| { | ||
| name: "keeps a newer stored sign-in", | ||
| legacy: legacyFileText, | ||
| stored: legacyFileText.replace("test-only-legacy-key", "test-only-current-key"), | ||
| expected: "test-only-current-key", | ||
| }, | ||
| { | ||
| name: "replaces a damaged stored sign-in", | ||
| legacy: legacyFileText, | ||
| stored: "damaged", | ||
| expected: "test-only-legacy-key", | ||
| }, | ||
| { | ||
| name: "discards a damaged legacy file", | ||
| legacy: "damaged", | ||
| stored: undefined, | ||
| expected: undefined, | ||
| }, | ||
| ])("$name and deletes the legacy file", ({ legacy, stored, expected }) => | ||
| Effect.gen(function* () { | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| const path = yield* Path.Path; | ||
| const legacyFile = path.join(yield* fileSystem.makeTempDirectoryScoped(), "cursor.json"); | ||
| yield* fileSystem.writeFileString(legacyFile, legacy); | ||
| const { secrets } = makeSecrets(); | ||
| const instanceId = ProviderInstanceId.make("personal"); | ||
| if (stored !== undefined) { | ||
| const current = yield* makeCursorCredentialStore(instanceId).pipe( | ||
| Effect.provideService(ServerSecretStore, secrets), | ||
| ); | ||
| yield* secrets.set(current.binding.key, new TextEncoder().encode(stored)); | ||
| } | ||
| const migrated = yield* makeCursorCredentialStore(instanceId, legacyFile).pipe( | ||
| Effect.provideService(ServerSecretStore, secrets), | ||
| ); | ||
| assert.strictEqual((yield* Effect.tryPromise(() => migrated.store.load()))?.apiKey, expected); | ||
| assert.isFalse(yield* fileSystem.exists(legacyFile)); | ||
| }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| ); | ||
|
|
||
| it.effect( | ||
| "restores SDK credentials in a new controller and keeps another account when signing out", | ||
| () => | ||
| Effect.gen(function* () { | ||
| const data = new Map<string, Uint8Array>(); | ||
| const secrets = ServerSecretStore.of({ | ||
| get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), | ||
| set: (key, bytes) => | ||
| Effect.sync(() => { | ||
| data.set(key, bytes); | ||
| }), | ||
| remove: (key) => | ||
| Effect.sync(() => { | ||
| data.delete(key); | ||
| }), | ||
| create: () => Effect.die("unused"), | ||
| getOrCreateRandom: () => Effect.die("unused"), | ||
| }); | ||
| const makeStore = (id: string) => | ||
| makeCursorCredentialStore(ProviderInstanceId.make(id)).pipe( | ||
| Effect.provideService(ServerSecretStore, secrets), | ||
| ); | ||
| const personal = yield* makeStore("personal"); | ||
| const work = yield* makeStore("work"); | ||
| const credentials = { | ||
| version: 1 as const, | ||
| backendUrl: "https://api.cursor.com", | ||
| apiKey: "test-only-key", | ||
| createdAtMs: 100, | ||
| apiKeyExpiresAtMs: 1000, | ||
| email: "test@example.com", | ||
| }; | ||
| yield* Effect.tryPromise(() => personal.store.save(credentials)); | ||
| yield* Effect.tryPromise(() => | ||
| work.store.save({ ...credentials, apiKey: "test-only-work-key" }), | ||
| ); | ||
| const restored = yield* makeStore("personal"); | ||
| assert.deepEqual(yield* Effect.tryPromise(() => restored.store.load()), credentials); | ||
| data.set(personal.binding.key, new TextEncoder().encode("damaged credential")); | ||
| assert.isUndefined(yield* Effect.tryPromise(() => restored.store.load())); | ||
| yield* Effect.tryPromise(() => restored.store.save(credentials)); | ||
| yield* Effect.tryPromise(() => restored.store.clear()); | ||
| assert.isUndefined(yield* Effect.tryPromise(() => personal.store.load())); | ||
| assert.strictEqual( | ||
| (yield* Effect.tryPromise(() => work.store.load()))?.apiKey, | ||
| "test-only-work-key", | ||
| ); | ||
| }).pipe(Effect.provide(NodeServices.layer)), | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import type { SdkCredentialStore } from "@cursor/sdk"; | ||
| import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as FileSystem from "effect/FileSystem"; | ||
| import * as Option from "effect/Option"; | ||
| import * as Schema from "effect/Schema"; | ||
|
|
||
| import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; | ||
|
|
||
| const Credentials = Schema.fromJsonString( | ||
| Schema.Struct({ | ||
| version: Schema.Literal(1), | ||
| backendUrl: Schema.String, | ||
| apiKey: Schema.String, | ||
| createdAtMs: Schema.Finite, | ||
| apiKeyExpiresAtMs: Schema.optionalKey(Schema.Finite), | ||
| email: Schema.optionalKey(Schema.String), | ||
| }), | ||
| ); | ||
|
|
||
| const decodeCredentials = Schema.decodeUnknownEffect(Credentials); | ||
| const encodeCredentials = Schema.encodeEffect(Credentials); | ||
|
|
||
| /** | ||
| * The SDK owns the credential format; persistence uses the environment's secret store. | ||
| * `legacyFile` is the SDK file store earlier versions used; its sign-in moves into | ||
| * the secret store once and the file is deleted. | ||
| */ | ||
| export const makeCursorCredentialStore = Effect.fn("makeCursorCredentialStore")(function* ( | ||
| instanceId: ProviderInstanceId, | ||
| legacyFile?: string, | ||
| ) { | ||
| const credentials = yield* ProviderCredentialStore.makeProviderCredentialStore( | ||
| "cursor", | ||
| instanceId, | ||
| ); | ||
| if (legacyFile !== undefined) { | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| yield* Effect.gen(function* () { | ||
| const legacy = yield* fileSystem.readFileString(legacyFile).pipe(Effect.option); | ||
| if (Option.isNone(legacy)) return; | ||
| // A stored sign-in wins unless it is damaged; the SDK would ignore it anyway. | ||
| const stored = yield* credentials.get; | ||
| const storedIsValid = | ||
| Option.isSome(stored) && | ||
| Option.isSome( | ||
| yield* decodeCredentials(new TextDecoder().decode(stored.value)).pipe(Effect.option), | ||
| ); | ||
| if ( | ||
| !storedIsValid && | ||
| Option.isSome(yield* decodeCredentials(legacy.value).pipe(Effect.option)) | ||
| ) { | ||
| yield* credentials.set(new TextEncoder().encode(legacy.value)); | ||
| } | ||
| yield* fileSystem.remove(legacyFile); | ||
| }).pipe( | ||
| Effect.catch((error) => | ||
| Effect.logWarning("Could not migrate legacy Cursor credentials", error), | ||
| ), | ||
| ); | ||
| } | ||
| const store: SdkCredentialStore = { | ||
| load: () => | ||
| Effect.runPromise( | ||
| credentials.get.pipe( | ||
| Effect.flatMap( | ||
| Option.match({ | ||
| onNone: () => Effect.succeed(undefined), | ||
| onSome: (bytes) => | ||
| decodeCredentials(new TextDecoder().decode(bytes)).pipe( | ||
| Effect.orElseSucceed(() => undefined), | ||
| ), | ||
| }), | ||
| ), | ||
| ), | ||
| ), | ||
| save: (value) => | ||
| Effect.runPromise( | ||
| encodeCredentials(value).pipe( | ||
| Effect.mapError( | ||
| () => | ||
| new ProviderSetupError({ | ||
| instanceId, | ||
| operation: "credentials", | ||
| detail: "Cursor returned an unsupported credential format.", | ||
| }), | ||
| ), | ||
| Effect.flatMap((encoded) => credentials.set(new TextEncoder().encode(encoded))), | ||
| ), | ||
| ), | ||
| clear: () => Effect.runPromise(credentials.remove), | ||
| }; | ||
| return { store, binding: credentials.binding }; | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.