diff --git a/packages/provider-cursor/src/server/auth.test.ts b/packages/provider-cursor/src/server/auth.test.ts index c2e368641b5af..d4ad401b69b51 100644 --- a/packages/provider-cursor/src/server/auth.test.ts +++ b/packages/provider-cursor/src/server/auth.test.ts @@ -4,18 +4,20 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Layer from "effect/Layer"; import { expect, it } from "@effect/vitest"; import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { makeCursorAuth, type CursorAuthOptions } from "./auth.ts"; import { loadCursorSdk } from "./sdk.ts"; -const { InMemoryCredentialStore } = loadCursorSdk(); +const { AuthenticationError, InMemoryCredentialStore } = loadCursorSdk(); const instanceId = ProviderInstanceId.make("cursor-personal"); const owner = "owner-client"; @@ -73,6 +75,13 @@ const makeHarness = Effect.fn("makeCursorAuthHarness")(function* ( }; }); +const captureWarnings = (warnings: string[]) => + Logger.layer([ + Logger.make(({ logLevel, cause }) => { + if (logLevel === "Warn") warnings.push(Cause.pretty(cause)); + }), + ]); + type Auth = Effect.Success>; const phase = (auth: Auth, phase: ProviderAuthState["phase"], session = owner) => auth.controller.subscribe(session).pipe( @@ -171,8 +180,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("CursorAuth", (it }).pipe(Effect.scoped), ); - it.effect("aborts cancellation and ignores a late successful SDK login", () => - Effect.gen(function* () { + it.effect("aborts cancellation and ignores a late successful SDK login", () => { + const warnings: string[] = []; + return Effect.gen(function* () { const harness = yield* makeHarness(); yield* harness.auth.controller.start(owner); const waiting = yield* phase(harness.auth, "waiting"); @@ -184,8 +194,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("CursorAuth", (it yield* Effect.promise(() => harness.returned.promise); expect(yield* harness.auth.readApiKey).toBeUndefined(); expect(harness.changes).toEqual([]); - }).pipe(Effect.scoped), - ); + expect(warnings).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(captureWarnings(warnings))); + }); it.effect("discards a credential rejected by verification and permits a fresh login", () => Effect.gen(function* () { @@ -219,6 +230,24 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("CursorAuth", (it }).pipe(Effect.scoped), ); + it.effect("shows a fixed reason when browser login fails and logs the SDK cause", () => { + const warnings: string[] = []; + return Effect.gen(function* () { + const sdkMessage = + "Login succeeded, but creating an SDK API key failed: [permission_denied] user API keys are disabled for this team (your team's settings may restrict user API keys — ask a team admin, or pass an existing API key directly)"; + const harness = yield* makeHarness({ + login: () => Promise.reject(new AuthenticationError(sdkMessage)), + }); + yield* harness.auth.controller.start(owner); + const failed = yield* phase(harness.auth, "failed"); + expect(failed.message).toContain("could not create an API key"); + expect(failed.message).not.toContain("permission_denied"); + expect(yield* harness.auth.readApiKey).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(sdkMessage); + }).pipe(Effect.scoped, Effect.provide(captureWarnings(warnings))); + }); + it.effect("clears only this instance's credential after its sessions stop", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/packages/provider-cursor/src/server/auth.ts b/packages/provider-cursor/src/server/auth.ts index c13db819ec280..c04c703dfefa6 100644 --- a/packages/provider-cursor/src/server/auth.ts +++ b/packages/provider-cursor/src/server/auth.ts @@ -18,11 +18,24 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { loadCursorSdk } from "./sdk.ts"; +import { isCursorSdkError, loadCursorSdk } from "./sdk.ts"; import type { ProviderAuthController } from "@t3tools/provider-core/server/auth"; const AUTH_TIMEOUT_MS = 300_000; +// The SDK has no code distinguishing these failures, so classify by its fixed message prefixes. +const loginFailureDetail = (cause: unknown) => { + if (isCursorSdkError(cause, "CursorSdkError")) { + if (cause.message.startsWith("Login succeeded, but creating an SDK API key failed")) { + return "Cursor signed you in but could not create an API key. Your team's settings may restrict API keys: ask a team admin, or add CURSOR_API_KEY in provider settings."; + } + if (cause.message.startsWith("Login failed or timed out")) { + return "Cursor sign-in failed or timed out. Check that this machine can reach Cursor, then start sign-in again."; + } + } + return "Cursor sign-in failed. Start sign-in again."; +}; + interface AuthFlow { readonly id: string; readonly owner: string; @@ -204,7 +217,7 @@ export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: Cu new ProviderSetupError({ instanceId: options.instanceId, operation: "start", - detail: "Cursor sign-in failed. Start sign-in again.", + detail: loginFailureDetail(cause), cause, }), }); @@ -270,6 +283,7 @@ export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: Cu }), ), }), + Effect.tapCause((cause) => Effect.logWarning("Cursor sign-in failed", cause)), Effect.exit, Effect.flatMap((result) => lock.withPermits(1)(