Skip to content

[Bug]: Cursor provider (V2 SDK) ignores existing Cursor CLI/SDK login and forces T3 sign-in or CURSOR_API_KEY #15781

Description

@mats16

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Summary

Since the orchestration V2 switch (#2829), the Cursor provider only accepts two credential sources: a T3-owned browser sign-in, or CURSOR_API_KEY in the provider/server environment. Credentials that already exist outside T3 are ignored:

  • the Cursor CLI login (cursor-agent login, stored in the macOS Keychain as cursor-access-token / cursor-user), and
  • the Cursor SDK's standard login file (~/.cursor/sdk/auth.json, the default store of Cursor.auth.login()).

As a result, T3 marks Cursor as unauthenticated and blocks sending until the user signs in again through T3 or pastes an API key. This is a regression for setups where the Cursor environment is provisioned through config files and the existing CLI login. For example, some managed or corporate launchers configure Cursor only by writing ~/.cursor/mcp.json / ~/.cursor/cli-config.json and depend on the user's existing cursor-agent login; they don't put an API key in T3's environment. V1 worked in that setup because it asked the CLI itself.

Steps to reproduce

  1. On macOS, sign in with the Cursor CLI: cursor-agent login. Confirm that cursor-agent about reports your email.
  2. Don't set CURSOR_API_KEY anywhere, and don't sign in through T3.
  3. Optionally, provision Cursor config through files only (e.g. MCP servers in ~/.cursor/mcp.json).
  4. Enable the Cursor provider in T3 Code (nightly 0.0.46-nightly.20261004.2648, main @ efecd3c).

Expected behavior

T3 reuses the credential the user already has, or at least offers that option, the same way V1 did through agent about and the way the Claude and Codex providers reuse their CLI logins and config files. Cursor should show as authenticated and threads should start without a second, T3-specific sign-in. (Once auth passes, user config such as ~/.cursor/mcp.json is already loaded through settingSources.)

Actual behavior

  • The provider status is error / unauthenticated with the message Sign in with Cursor or add CURSOR_API_KEY in provider settings.
  • The only remedies offered are T3's browser sign-in, which mints a separate T3 Code - <name> API key into T3's own secret store, or manually adding CURSOR_API_KEY.
  • Sessions and text generation fail with the same ProviderSetupError until one of those is done.

Root cause

All Cursor auth goes through CursorAuth, which only resolves two sources:

  1. CURSOR_API_KEY from the merged provider environment (process.env plus instance env):
    const auth = yield* makeCursorAuth({
    instanceId,
    displayName: displayName ?? "Cursor",
    enabled,
    ...(processEnv.CURSOR_API_KEY ? { apiKey: processEnv.CURSOR_API_KEY } : {}),
    store: credentials.store,
  2. A T3-owned per-instance credential (ServerSecretStore, key cursor:<instanceId>), filled only by T3's own Cursor.auth.login() flow:
    const readApiKey = Effect.gen(function* () {
    if (configuredKey) return configuredKey;
    const credentials = yield* Effect.tryPromise({
    try: () => options.store.load(),
    catch: (cause) =>
    new ProviderSetupError({
    instanceId: options.instanceId,
    operation: "credentials",
    detail: "Could not read the Cursor sign-in. Try signing in again.",
    cause,
    }),
    });
    const now = yield* Clock.currentTimeMillis;
    return credentials &&
    (credentials.apiKeyExpiresAtMs === undefined || credentials.apiKeyExpiresAtMs > now)
    ? credentials.apiKey
    : undefined;
    });
    const requireApiKey = Effect.gen(function* () {
    if (operation !== "idle") {
    return yield* new ProviderSetupError({
    instanceId: options.instanceId,
    operation: "credentials",
    detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.",
    });
    }
    const apiKey = yield* readApiKey;
    if (!apiKey) {
    return yield* new ProviderSetupError({
    instanceId: options.instanceId,
    operation: "credentials",
    detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
    });
    }
    return apiKey;
    });

Every entry point is gated on that result:

  • Status probe: checkCursorProviderStatus returns unauthenticated as soon as no key is resolved, without trying anything else:
    const sdkApiKey = environment?.CURSOR_API_KEY?.trim();
    if (!sdkApiKey) {
    return buildServerProvider({
    presentation: CURSOR_PRESENTATION,
    enabled: cursorSettings.enabled,
    checkedAt,
    models: fallbackModels,
    probe: {
    installed: true,
    version: null,
    status: "error",
    auth: { status: "unauthenticated" },
    message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
    },
    });
    }
  • Sessions: sdkRunner.open is wrapped in auth.requireApiKey, which fails before the SDK is called:
    Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, {
    ...sdkRunner,
    open: (input) =>
    auth.requireApiKey.pipe(
    Effect.flatMap((apiKey) =>
    Effect.acquireRelease(
    sdkRunner
    .open({ ...input, options: { ...input.options, apiKey } })
    .pipe(
    Effect.flatMap((session) =>
    Effect.cached(session.close).pipe(
    Effect.map((close) => ({ ...session, close })),
    ),
    ),
    ),
    (session) => session.close.pipe(Effect.ignore),
    ),
    ),
    auth.withAccess,
    Effect.mapError((cause) =>
    isSdkRunnerError(cause)
    ? cause
    : new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "open", cause }),
    ),
    ),
    }),
  • Text generation: same requireApiKey gate:
    const resolveCursorApiKey = (operation: CursorTextGenerationOperation) =>
    Effect.gen(function* () {
    if (!cursorSettings.enabled) {
    return yield* new TextGenerationError({
    operation,
    detail: "Cursor is disabled in T3 Code settings.",
    });
    }
    const apiKey = resolveApiKey
    ? yield* resolveApiKey
    : resolvedEnvironment.CURSOR_API_KEY?.trim();
    if (!apiKey) {
    return yield* new TextGenerationError({
    operation,
    detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
    });
    }
    return apiKey;

Because T3 always passes an explicit apiKey, the SDK's own credential precedence (explicit apiKey, then CURSOR_API_KEY, then the stored Cursor.auth.login() key in ~/.cursor/sdk/auth.json) never applies, so a user-level SDK login is never seen.

The CLI login is also not considered for auth, even though T3 already knows how to read it. readMacCursorAccessToken reads the CLI's Keychain entry, but it's used only for the opt-in usage/limits feature (cursorKeychainUsageEnabled):

/** 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;
});

For comparison, V1 (before #2829) ran agent about with the configured binaryPath, so any login the CLI itself recognized counted as authenticated:

// Determine auth from the User Email field.
if (userEmail === undefined) {
// Field missing entirely — can't determine auth.
if (result.code === 0) {
return { version, status: "ready", auth: { status: "unknown" } };
}
return {
version,
status: "warning",
auth: { status: "unknown" },
message: "Could not verify Cursor Agent authentication status.",
};
}
const lowerEmail = userEmail.toLowerCase();
if (
lowerEmail === "not logged in" ||
lowerEmail.includes("login required") ||
lowerEmail.includes("authentication required")
) {
return {
version,
status: "error",
auth: { status: "unauthenticated" },
message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
};
}
// Any non-empty email value means authenticated.
return {
version,
status: "ready",
auth: { status: "authenticated", email: userEmail },
};
}
const runCursorCommand = (
cursorSettings: CursorSettings,
args: ReadonlyArray<string>,
environment?: NodeJS.ProcessEnv,
) =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const spawnCommand = yield* resolveSpawnCommand(
cursorSettings.binaryPath,
args,
environment ? { env: environment } : {},
);
const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
...(environment ? { env: environment } : { extendEnv: true }),
shell: spawnCommand.shell,
});
const child = yield* spawner.spawn(command);
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStreamAsString(child.stdout),
collectStreamAsString(child.stderr),
child.exitCode.pipe(Effect.map(Number)),
],
{ concurrency: "unbounded" },
);
return { stdout, stderr, code: exitCode } satisfies CommandResult;
}).pipe(Effect.scoped);
const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv) =>
Effect.gen(function* () {
const jsonResult = yield* runCursorCommand(
cursorSettings,
["about", "--format", "json"],
environment,
);
if (!isCursorAboutJsonFormatUnsupported(jsonResult)) {
return jsonResult;
}
return yield* runCursorCommand(cursorSettings, ["about"], environment);
});

binaryPath and apiEndpoint are still kept in CursorSettings but are documented as unused by V2:

export const CursorSettings = makeProviderSettingsSchema(
{
// Off by default like Grok and OpenCode. Users opt in from Settings.
enabled: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(false)),
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
),
// Keep V1's CLI configuration when V2 rewrites the shared settings file.
// V2's Cursor SDK does not use these fields.
binaryPath: Schema.optionalKey(TrimmedString).pipe(
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
),
apiEndpoint: Schema.optionalKey(TrimmedString).pipe(
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
),
customModels: Schema.Array(CustomModelSetting).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
),
},
{
order: [],
},
);

Background / constraints

  • @cursor/sdk (1.0.31) only accepts user API keys. Its StoredSdkCredentials doc says the minted API key "is the only credential the SDK's surfaces accept". So the CLI's Keychain session token can't be passed to Agent.create({ apiKey }) as-is.
  • FileCredentialStore and getDefaultSdkAuthPath() are public SDK exports, so the standard ~/.cursor/sdk/auth.json login can be read directly.
  • The SDK mints a key from a login session internally (mintUserApiKey, DashboardService/CreateUserApiKey), but that isn't a public export today.

Possible fixes

  1. Fall back to the SDK's default credential chain. When neither CURSOR_API_KEY nor a T3-owned credential exists, read new FileCredentialStore() (~/.cursor/sdk/auth.json), or pass no apiKey and let the SDK resolve it. Report the result as authenticated with a distinct type/label such as "Cursor SDK login (shared)". Keep canLogout: false for this source so T3 doesn't delete a credential it doesn't own.
  2. Optionally reuse the Cursor CLI login. With explicit consent (same pattern as cursorKeychainUsageEnabled), use the CLI's Keychain session to mint a named, expiring T3 API key without a browser round trip. This probably needs Cursor to expose a public mint API, or a CLI command that prints or mints an SDK key.
  3. Make the credential source explicit in provider settings, for example "T3 sign-in" / "Use existing Cursor login" / "API key from environment". Instances provisioned by config files then work without interactive auth.
  4. At minimum, when no T3-owned key is found but a CLI or SDK login exists, the status message should say so and explain why it isn't used. Today it suggests the user has never signed in.

Impact

Blocks work completely for setups that rely on an existing Cursor CLI/SDK login or file-based provisioning, unless the user creates a second credential just for T3.

Version or commit

main @ efecd3cf8bcec3d1891b5f5a27dc2f6d797c6448; T3 Code (Nightly) 0.0.46-nightly.20261004.2648

Environment

macOS 26.6.2, Cursor CLI 2026.10.01-e373342, @cursor/sdk 1.0.31

Related

Workaround

Export CURSOR_API_KEY in the environment T3's server inherits, or add it to the Cursor provider's environment. Alternatively, complete T3's own "Sign in with Cursor" flow, which creates an additional API key.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementRequested improvement or new capability.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions