Before submitting
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
- On macOS, sign in with the Cursor CLI:
cursor-agent login. Confirm that cursor-agent about reports your email.
- Don't set
CURSOR_API_KEY anywhere, and don't sign in through T3.
- Optionally, provision Cursor config through files only (e.g. MCP servers in
~/.cursor/mcp.json).
- 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:
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, |
- 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
- 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.
- 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.
- 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.
- 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.
Before submitting
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_KEYin the provider/server environment. Credentials that already exist outside T3 are ignored:cursor-agent login, stored in the macOS Keychain ascursor-access-token/cursor-user), and~/.cursor/sdk/auth.json, the default store ofCursor.auth.login()).As a result, T3 marks Cursor as
unauthenticatedand 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.jsonand depend on the user's existingcursor-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
cursor-agent login. Confirm thatcursor-agent aboutreports your email.CURSOR_API_KEYanywhere, and don't sign in through T3.~/.cursor/mcp.json).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 aboutand 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.jsonis already loaded throughsettingSources.)Actual behavior
error/unauthenticatedwith the messageSign in with Cursor or add CURSOR_API_KEY in provider settings.T3 Code - <name>API key into T3's own secret store, or manually addingCURSOR_API_KEY.ProviderSetupErroruntil one of those is done.Root cause
All Cursor auth goes through
CursorAuth, which only resolves two sources:CURSOR_API_KEYfrom the merged provider environment (process.envplus instance env):t3code/apps/server/src/provider/Drivers/CursorDriver.ts
Lines 118 to 123 in efecd3c
ServerSecretStore, keycursor:<instanceId>), filled only by T3's ownCursor.auth.login()flow:t3code/apps/server/src/provider/CursorAuth.ts
Lines 76 to 112 in efecd3c
Every entry point is gated on that result:
checkCursorProviderStatusreturnsunauthenticatedas soon as no key is resolved, without trying anything else:t3code/apps/server/src/provider/Layers/CursorProvider.ts
Lines 273 to 288 in efecd3c
sdkRunner.openis wrapped inauth.requireApiKey, which fails before the SDK is called:t3code/apps/server/src/provider/Drivers/CursorDriver.ts
Lines 158 to 183 in efecd3c
requireApiKeygate:t3code/apps/server/src/textGeneration/CursorTextGeneration.ts
Lines 85 to 104 in efecd3c
Because T3 always passes an explicit
apiKey, the SDK's own credential precedence (explicitapiKey, thenCURSOR_API_KEY, then the storedCursor.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.
readMacCursorAccessTokenreads the CLI's Keychain entry, but it's used only for the opt-in usage/limits feature (cursorKeychainUsageEnabled):t3code/apps/server/src/provider/cursorKeychainToken.ts
Lines 51 to 55 in efecd3c
For comparison, V1 (before #2829) ran
agent aboutwith the configuredbinaryPath, so any login the CLI itself recognized counted as authenticated:t3code/apps/server/src/provider/Layers/CursorProvider.ts
Lines 1023 to 1100 in e5a46d6
binaryPathandapiEndpointare still kept inCursorSettingsbut are documented as unused by V2:t3code/packages/contracts/src/settings.ts
Lines 703 to 726 in efecd3c
Background / constraints
@cursor/sdk(1.0.31) only accepts user API keys. ItsStoredSdkCredentialsdoc 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 toAgent.create({ apiKey })as-is.FileCredentialStoreandgetDefaultSdkAuthPath()are public SDK exports, so the standard~/.cursor/sdk/auth.jsonlogin can be read directly.mintUserApiKey,DashboardService/CreateUserApiKey), but that isn't a public export today.Possible fixes
CURSOR_API_KEYnor a T3-owned credential exists, readnew FileCredentialStore()(~/.cursor/sdk/auth.json), or pass noapiKeyand let the SDK resolve it. Report the result as authenticated with a distinct type/label such as "Cursor SDK login (shared)". KeepcanLogout: falsefor this source so T3 doesn't delete a credential it doesn't own.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.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.2648Environment
macOS 26.6.2, Cursor CLI
2026.10.01-e373342,@cursor/sdk1.0.31Related
CURSOR_API_KEYtreated as logged out), closed by feat(orchestrator): introduce new orchestrator #2829Workaround
Export
CURSOR_API_KEYin 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.