From 11cddf8bd2573b525c6eda61c6f37523dae5b9ea Mon Sep 17 00:00:00 2001 From: loject Date: Sat, 31 Jan 2026 12:20:04 +0700 Subject: [PATCH 1/2] feat(openai-codex): profile-scoped oauth + email/limits --- packages/types/src/vscode-extension-host.ts | 1 + src/api/providers/openai-codex.ts | 28 +- src/core/webview/ClineProvider.ts | 25 +- src/core/webview/webviewMessageHandler.ts | 16 +- src/integrations/openai-codex/oauth.ts | 285 +++++++++++++----- .../src/components/settings/ApiOptions.tsx | 10 +- .../settings/providers/OpenAICodex.tsx | 51 +++- .../OpenAICodexRateLimitDashboard.tsx | 11 +- 8 files changed, 321 insertions(+), 106 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 6aa478cccba..5d86d31ba33 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -403,6 +403,7 @@ export type ExtensionState = Pick< taskSyncEnabled: boolean featureRoomoteControlEnabled: boolean openAiCodexIsAuthenticated?: boolean + openAiCodexAccountEmail?: string | null debug?: boolean } diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index d64780c5557..e5e3f82957f 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -26,6 +26,7 @@ import { isMcpTool } from "../../utils/mcp-name" import { sanitizeOpenAiCallId } from "../../utils/tool-id" import { openAiCodexOAuthManager } from "../../integrations/openai-codex/oauth" import { t } from "../../i18n" +import { ContextProxy } from "../../core/config/ContextProxy" export type OpenAiCodexModel = ReturnType @@ -64,6 +65,20 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion */ private pendingToolCallId: string | undefined private pendingToolCallName: string | undefined + private resolveProfileId(): string | undefined { + try { + const contextProxy = ContextProxy.instance + const currentApiConfigName = contextProxy.getValue("currentApiConfigName") + const listApiConfigMeta = contextProxy.getValue("listApiConfigMeta") + if (!Array.isArray(listApiConfigMeta)) { + return undefined + } + const match = listApiConfigMeta.find((profile) => profile?.name === currentApiConfigName) + return typeof match?.id === "string" ? match.id : undefined + } catch { + return undefined + } + } // Event types handled by the shared event processor private readonly coreHandledEventTypes = new Set([ @@ -151,7 +166,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion this.pendingToolCallName = undefined // Get access token from OAuth manager - let accessToken = await openAiCodexOAuthManager.getAccessToken() + const profileId = this.resolveProfileId() + let accessToken = await openAiCodexOAuthManager.getAccessToken(profileId) if (!accessToken) { throw new Error( t("common:errors.openAiCodex.notAuthenticated", { @@ -183,7 +199,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion if (attempt === 0 && isAuthFailure) { // Force refresh the token for retry - const refreshed = await openAiCodexOAuthManager.forceRefreshAccessToken() + const refreshed = await openAiCodexOAuthManager.forceRefreshAccessToken(profileId) if (!refreshed) { throw new Error( t("common:errors.openAiCodex.notAuthenticated", { @@ -341,7 +357,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // is consistent across providers. try { // Get ChatGPT account ID for organization subscriptions - const accountId = await openAiCodexOAuthManager.getAccountId() + const accountId = await openAiCodexOAuthManager.getAccountId(this.resolveProfileId()) // Build Codex-specific headers. Authorization is provided by the SDK apiKey. const codexHeaders: Record = { @@ -481,7 +497,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const url = `${CODEX_API_BASE_URL}/responses` // Get ChatGPT account ID for organization subscriptions - const accountId = await openAiCodexOAuthManager.getAccountId() + const accountId = await openAiCodexOAuthManager.getAccountId(this.resolveProfileId()) // Build headers with required Codex-specific fields const headers: Record = { @@ -1008,7 +1024,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const model = this.getModel() // Get access token - const accessToken = await openAiCodexOAuthManager.getAccessToken() + const accessToken = await openAiCodexOAuthManager.getAccessToken(this.resolveProfileId()) if (!accessToken) { throw new Error( t("common:errors.openAiCodex.notAuthenticated", { @@ -1043,7 +1059,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const url = `${CODEX_API_BASE_URL}/responses` // Get ChatGPT account ID for organization subscriptions - const accountId = await openAiCodexOAuthManager.getAccountId() + const accountId = await openAiCodexOAuthManager.getAccountId(this.resolveProfileId()) // Build headers with required Codex-specific fields const headers: Record = { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index bc3f6bd6ef1..67ed1522f5f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2105,6 +2105,21 @@ export class ClineProvider const currentMode = mode ?? defaultModeSlug const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode) + const openAiCodexProfileId = Array.isArray(listApiConfigMeta) + ? listApiConfigMeta.find((profile) => profile.name === currentApiConfigName)?.id + : undefined + let openAiCodexIsAuthenticated = false + let openAiCodexAccountEmail: string | null = null + + try { + const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") + openAiCodexIsAuthenticated = await openAiCodexOAuthManager.isAuthenticated(openAiCodexProfileId) + openAiCodexAccountEmail = await openAiCodexOAuthManager.getEmail(openAiCodexProfileId) + } catch { + openAiCodexIsAuthenticated = false + openAiCodexAccountEmail = null + } + return { version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration, @@ -2232,14 +2247,8 @@ export class ClineProvider openRouterImageApiKey, openRouterImageGenerationSelectedModel, featureRoomoteControlEnabled, - openAiCodexIsAuthenticated: await (async () => { - try { - const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - return await openAiCodexOAuthManager.isAuthenticated() - } catch { - return false - } - })(), + openAiCodexIsAuthenticated, + openAiCodexAccountEmail, debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), } } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 051586b119d..af15b32aa48 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2385,14 +2385,16 @@ export const webviewMessageHandler = async ( case "openAiCodexSignIn": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - const authUrl = openAiCodexOAuthManager.startAuthorizationFlow() + const { currentApiConfigName, listApiConfigMeta } = await provider.getState() + const profileId = listApiConfigMeta?.find((profile) => profile.name === currentApiConfigName)?.id + const authUrl = openAiCodexOAuthManager.startAuthorizationFlow(profileId) // Open the authorization URL in the browser await vscode.env.openExternal(vscode.Uri.parse(authUrl)) // Wait for the callback in a separate promise (non-blocking) openAiCodexOAuthManager - .waitForCallback() + .waitForCallback(profileId) .then(async () => { vscode.window.showInformationMessage("Successfully signed in to OpenAI Codex") await provider.postStateToWebview() @@ -2412,7 +2414,9 @@ export const webviewMessageHandler = async ( case "openAiCodexSignOut": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - await openAiCodexOAuthManager.clearCredentials() + const { currentApiConfigName, listApiConfigMeta } = await provider.getState() + const profileId = listApiConfigMeta?.find((profile) => profile.name === currentApiConfigName)?.id + await openAiCodexOAuthManager.clearCredentials(profileId) vscode.window.showInformationMessage("Signed out from OpenAI Codex") await provider.postStateToWebview() } catch (error) { @@ -3244,7 +3248,9 @@ export const webviewMessageHandler = async ( case "requestOpenAiCodexRateLimits": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - const accessToken = await openAiCodexOAuthManager.getAccessToken() + const { currentApiConfigName, listApiConfigMeta } = await provider.getState() + const profileId = listApiConfigMeta?.find((profile) => profile.name === currentApiConfigName)?.id + const accessToken = await openAiCodexOAuthManager.getAccessToken(profileId) if (!accessToken) { provider.postMessageToWebview({ @@ -3254,7 +3260,7 @@ export const webviewMessageHandler = async ( break } - const accountId = await openAiCodexOAuthManager.getAccountId() + const accountId = await openAiCodexOAuthManager.getAccountId(profileId) const { fetchOpenAiCodexRateLimitInfo } = await import("../../integrations/openai-codex/rate-limits") const rateLimits = await fetchOpenAiCodexRateLimitInfo(accessToken, { accountId }) diff --git a/src/integrations/openai-codex/oauth.ts b/src/integrations/openai-codex/oauth.ts index 0cae6a41640..cf93444a8a5 100644 --- a/src/integrations/openai-codex/oauth.ts +++ b/src/integrations/openai-codex/oauth.ts @@ -3,6 +3,7 @@ import * as http from "http" import { URL } from "url" import type { ExtensionContext } from "vscode" import { z } from "zod" +import { ContextProxy } from "../../core/config/ContextProxy" /** * OpenAI Codex OAuth Configuration @@ -24,7 +25,9 @@ export const OPENAI_CODEX_OAUTH_CONFIG = { } as const // Token storage key -const OPENAI_CODEX_CREDENTIALS_KEY = "openai-codex-oauth-credentials" +const OPENAI_CODEX_CREDENTIALS_KEY_PREFIX = "openai-codex-oauth-credentials" +const OPENAI_CODEX_LEGACY_CREDENTIALS_KEY = OPENAI_CODEX_CREDENTIALS_KEY_PREFIX +const DEFAULT_PROFILE_ID = "default" // Credentials schema const openAiCodexCredentialsSchema = z.object({ @@ -93,6 +96,14 @@ function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined { ) } +function extractEmailFromIdToken(idToken?: string): string | undefined { + if (!idToken) return undefined + const claims = parseJwtClaims(idToken) + if (!claims || typeof claims.email !== "string") return undefined + const email = claims.email.trim() + return email.length > 0 ? email : undefined +} + /** * Extract ChatGPT account ID from token response * Tries id_token first, then access_token @@ -262,13 +273,14 @@ export async function exchangeCodeForTokens(code: string, codeVerifier: string): id_token: tokenResponse.id_token, access_token: tokenResponse.access_token, }) + const email = extractEmailFromIdToken(tokenResponse.id_token) ?? tokenResponse.email return { type: "openai-codex", access_token: tokenResponse.access_token, refresh_token: tokenResponse.refresh_token, expires: expiresAt, - email: tokenResponse.email, + email, accountId, } } @@ -314,13 +326,14 @@ export async function refreshAccessToken(credentials: OpenAiCodexCredentials): P id_token: tokenResponse.id_token, access_token: tokenResponse.access_token, }) + const refreshedEmail = extractEmailFromIdToken(tokenResponse.id_token) ?? tokenResponse.email return { type: "openai-codex", access_token: tokenResponse.access_token, refresh_token: tokenResponse.refresh_token ?? credentials.refresh_token, expires: expiresAt, - email: tokenResponse.email ?? credentials.email, + email: refreshedEmail ?? credentials.email, // Prefer newly extracted accountId, fall back to existing accountId: newAccountId ?? credentials.accountId, } @@ -340,12 +353,14 @@ export function isTokenExpired(credentials: OpenAiCodexCredentials): boolean { */ export class OpenAiCodexOAuthManager { private context: ExtensionContext | null = null - private credentials: OpenAiCodexCredentials | null = null + private credentialsByProfile = new Map() private logFn: ((message: string) => void) | null = null - private refreshPromise: Promise | null = null + private refreshPromises = new Map>() + private lastResolvedProfileId: string | null = null private pendingAuth: { codeVerifier: string state: string + profileId: string server?: http.Server } | null = null @@ -364,6 +379,52 @@ export class OpenAiCodexOAuthManager { console.error(full) } + private resolveProfileId(profileId?: string): string { + const normalized = profileId?.trim() + if (normalized) { + this.lastResolvedProfileId = normalized + return normalized + } + + const fromContext = this.getProfileIdFromContext() + if (fromContext) { + this.lastResolvedProfileId = fromContext + return fromContext + } + + if (this.lastResolvedProfileId) { + return this.lastResolvedProfileId + } + + this.lastResolvedProfileId = DEFAULT_PROFILE_ID + return DEFAULT_PROFILE_ID + } + + private getProfileIdFromContext(): string | undefined { + try { + const contextProxy = ContextProxy.instance + const currentApiConfigName = contextProxy.getValue("currentApiConfigName") + const listApiConfigMeta = contextProxy.getValue("listApiConfigMeta") + + if (Array.isArray(listApiConfigMeta)) { + const match = listApiConfigMeta.find((profile) => profile?.name === currentApiConfigName) + if (match?.id && typeof match.id === "string") { + return match.id + } + } + } catch { + return undefined + } + + return undefined + } + + private getCredentialsKey(profileId: string): string { + const normalized = profileId.trim() + const safeProfileId = normalized.length > 0 ? normalized : DEFAULT_PROFILE_ID + return `${OPENAI_CODEX_CREDENTIALS_KEY_PREFIX}-${safeProfileId}` + } + /** * Initialize the OAuth manager with VS Code extension context */ @@ -376,42 +437,53 @@ export class OpenAiCodexOAuthManager { * Force a refresh using the stored refresh token even if the access token is not expired. * Useful when the server invalidates an access token early. */ - async forceRefreshAccessToken(): Promise { - if (!this.credentials) { - await this.loadCredentials() + async forceRefreshAccessToken(profileId?: string): Promise { + const resolvedProfileId = this.resolveProfileId(profileId) + let credentials: OpenAiCodexCredentials | null = this.credentialsByProfile.get(resolvedProfileId) ?? null + + if (!credentials) { + credentials = await this.loadCredentials(resolvedProfileId) } - if (!this.credentials) { + if (!credentials) { return null } try { // De-dupe concurrent refreshes - if (!this.refreshPromise) { - const prevRefreshToken = this.credentials.refresh_token - this.log(`[openai-codex-oauth] Forcing token refresh (expires=${this.credentials.expires})...`) - this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { - const rotated = newCreds.refresh_token !== prevRefreshToken - this.log( - `[openai-codex-oauth] Forced refresh response received (expires_in≈${Math.round( - (newCreds.expires - Date.now()) / 1000, - )}s, refresh_token_rotated=${rotated})`, - ) - return newCreds - }) + if (!this.refreshPromises.has(resolvedProfileId)) { + const prevRefreshToken = credentials.refresh_token + this.log(`[openai-codex-oauth] Forcing token refresh (expires=${credentials.expires})...`) + this.refreshPromises.set( + resolvedProfileId, + refreshAccessToken(credentials).then((newCreds) => { + const rotated = newCreds.refresh_token !== prevRefreshToken + this.log( + `[openai-codex-oauth] Forced refresh response received (expires_in≈${Math.round( + (newCreds.expires - Date.now()) / 1000, + )}s, refresh_token_rotated=${rotated})`, + ) + return newCreds + }), + ) + } + + const refreshPromise = this.refreshPromises.get(resolvedProfileId) + if (!refreshPromise) { + return null } - const newCredentials = await this.refreshPromise - this.refreshPromise = null - await this.saveCredentials(newCredentials) + const newCredentials = await refreshPromise + this.refreshPromises.delete(resolvedProfileId) + await this.saveCredentials(newCredentials, resolvedProfileId) this.log(`[openai-codex-oauth] Forced token persisted (expires=${newCredentials.expires})`) return newCredentials.access_token } catch (error) { - this.refreshPromise = null + this.refreshPromises.delete(resolvedProfileId) this.logError("[openai-codex-oauth] Failed to force refresh token:", error) if (error instanceof OpenAiCodexOAuthTokenError && error.isLikelyInvalidGrant()) { this.log("[openai-codex-oauth] Refresh token appears invalid; clearing stored credentials") - await this.clearCredentials() + await this.clearCredentials(resolvedProfileId) } return null } @@ -420,20 +492,49 @@ export class OpenAiCodexOAuthManager { /** * Load credentials from storage */ - async loadCredentials(): Promise { + async loadCredentials(profileId?: string): Promise { if (!this.context) { return null } + const resolvedProfileId = this.resolveProfileId(profileId) + const cached = this.credentialsByProfile.get(resolvedProfileId) + if (cached) { + return cached + } + try { - const credentialsJson = await this.context.secrets.get(OPENAI_CODEX_CREDENTIALS_KEY) + const credentialsKey = this.getCredentialsKey(resolvedProfileId) + let credentialsJson = await this.context.secrets.get(credentialsKey) + + if (!credentialsJson && resolvedProfileId === DEFAULT_PROFILE_ID) { + const legacyJson = await this.context.secrets.get(OPENAI_CODEX_LEGACY_CREDENTIALS_KEY) + if (legacyJson) { + credentialsJson = legacyJson + } + } + if (!credentialsJson) { return null } const parsed = JSON.parse(credentialsJson) - this.credentials = openAiCodexCredentialsSchema.parse(parsed) - return this.credentials + const credentials = openAiCodexCredentialsSchema.parse(parsed) + this.credentialsByProfile.set(resolvedProfileId, credentials) + + if (resolvedProfileId === DEFAULT_PROFILE_ID && credentialsJson) { + try { + await this.context.secrets.store( + this.getCredentialsKey(resolvedProfileId), + JSON.stringify(credentials), + ) + await this.context.secrets.delete(OPENAI_CODEX_LEGACY_CREDENTIALS_KEY) + } catch (migrationError) { + this.logError("[openai-codex-oauth] Failed to migrate legacy credentials:", migrationError) + } + } + + return credentials } catch (error) { this.logError("[openai-codex-oauth] Failed to load credentials:", error) return null @@ -443,106 +544,129 @@ export class OpenAiCodexOAuthManager { /** * Save credentials to storage */ - async saveCredentials(credentials: OpenAiCodexCredentials): Promise { + async saveCredentials(credentials: OpenAiCodexCredentials, profileId?: string): Promise { if (!this.context) { throw new Error("OAuth manager not initialized") } - await this.context.secrets.store(OPENAI_CODEX_CREDENTIALS_KEY, JSON.stringify(credentials)) - this.credentials = credentials + const resolvedProfileId = this.resolveProfileId(profileId) + + await this.context.secrets.store(this.getCredentialsKey(resolvedProfileId), JSON.stringify(credentials)) + this.credentialsByProfile.set(resolvedProfileId, credentials) } /** * Clear credentials from storage */ - async clearCredentials(): Promise { + async clearCredentials(profileId?: string): Promise { if (!this.context) { return } - await this.context.secrets.delete(OPENAI_CODEX_CREDENTIALS_KEY) - this.credentials = null + const resolvedProfileId = this.resolveProfileId(profileId) + + await this.context.secrets.delete(this.getCredentialsKey(resolvedProfileId)) + this.credentialsByProfile.delete(resolvedProfileId) + if (resolvedProfileId === DEFAULT_PROFILE_ID) { + await this.context.secrets.delete(OPENAI_CODEX_LEGACY_CREDENTIALS_KEY) + } } /** * Get a valid access token, refreshing if necessary */ - async getAccessToken(): Promise { + async getAccessToken(profileId?: string): Promise { + const resolvedProfileId = this.resolveProfileId(profileId) + let credentials: OpenAiCodexCredentials | null = this.credentialsByProfile.get(resolvedProfileId) ?? null + // Try to load credentials if not already loaded - if (!this.credentials) { - await this.loadCredentials() + if (!credentials) { + credentials = await this.loadCredentials(resolvedProfileId) } - if (!this.credentials) { + if (!credentials) { return null } // Check if token is expired and refresh if needed - if (isTokenExpired(this.credentials)) { + if (isTokenExpired(credentials)) { try { // De-dupe concurrent refreshes - if (!this.refreshPromise) { + if (!this.refreshPromises.has(resolvedProfileId)) { this.log( - `[openai-codex-oauth] Access token expired (expires=${this.credentials.expires}). Refreshing...`, + `[openai-codex-oauth] Access token expired (expires=${credentials.expires}). Refreshing...`, ) - const prevRefreshToken = this.credentials.refresh_token - this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { - const rotated = newCreds.refresh_token !== prevRefreshToken - this.log( - `[openai-codex-oauth] Refresh response received (expires_in≈${Math.round( - (newCreds.expires - Date.now()) / 1000, - )}s, refresh_token_rotated=${rotated})`, - ) - return newCreds - }) + const prevRefreshToken = credentials.refresh_token + this.refreshPromises.set( + resolvedProfileId, + refreshAccessToken(credentials).then((newCreds) => { + const rotated = newCreds.refresh_token !== prevRefreshToken + this.log( + `[openai-codex-oauth] Refresh response received (expires_in≈${Math.round( + (newCreds.expires - Date.now()) / 1000, + )}s, refresh_token_rotated=${rotated})`, + ) + return newCreds + }), + ) + } + + const refreshPromise = this.refreshPromises.get(resolvedProfileId) + if (!refreshPromise) { + return null } - const newCredentials = await this.refreshPromise - this.refreshPromise = null - await this.saveCredentials(newCredentials) + const newCredentials = await refreshPromise + this.refreshPromises.delete(resolvedProfileId) + await this.saveCredentials(newCredentials, resolvedProfileId) + credentials = newCredentials this.log(`[openai-codex-oauth] Token persisted (expires=${newCredentials.expires})`) } catch (error) { - this.refreshPromise = null + this.refreshPromises.delete(resolvedProfileId) this.logError("[openai-codex-oauth] Failed to refresh token:", error) // Only clear secrets when the refresh token is clearly invalid/revoked. if (error instanceof OpenAiCodexOAuthTokenError && error.isLikelyInvalidGrant()) { this.log("[openai-codex-oauth] Refresh token appears invalid; clearing stored credentials") - await this.clearCredentials() + await this.clearCredentials(resolvedProfileId) } return null } } - return this.credentials.access_token + return credentials.access_token } /** * Get the user's email from credentials */ - async getEmail(): Promise { - if (!this.credentials) { - await this.loadCredentials() + async getEmail(profileId?: string): Promise { + const resolvedProfileId = this.resolveProfileId(profileId) + let credentials: OpenAiCodexCredentials | null = this.credentialsByProfile.get(resolvedProfileId) ?? null + if (!credentials) { + credentials = await this.loadCredentials(resolvedProfileId) } - return this.credentials?.email || null + return credentials?.email || null } /** * Get the ChatGPT account ID from credentials * Used for the ChatGPT-Account-Id header required by the Codex API */ - async getAccountId(): Promise { - if (!this.credentials) { - await this.loadCredentials() + async getAccountId(profileId?: string): Promise { + const resolvedProfileId = this.resolveProfileId(profileId) + let credentials: OpenAiCodexCredentials | null = this.credentialsByProfile.get(resolvedProfileId) ?? null + if (!credentials) { + credentials = await this.loadCredentials(resolvedProfileId) } - return this.credentials?.accountId || null + return credentials?.accountId || null } /** * Check if the user is authenticated */ - async isAuthenticated(): Promise { - const token = await this.getAccessToken() + async isAuthenticated(profileId?: string): Promise { + const token = await this.getAccessToken(profileId) return token !== null } @@ -550,17 +674,19 @@ export class OpenAiCodexOAuthManager { * Start the OAuth authorization flow * Returns the authorization URL to open in browser */ - startAuthorizationFlow(): string { + startAuthorizationFlow(profileId?: string): string { // Cancel any existing authorization flow before starting a new one this.cancelAuthorizationFlow() const codeVerifier = generateCodeVerifier() const codeChallenge = generateCodeChallenge(codeVerifier) const state = generateState() + const resolvedProfileId = this.resolveProfileId(profileId) this.pendingAuth = { codeVerifier, state, + profileId: resolvedProfileId, } return buildAuthorizationUrl(codeChallenge, state) @@ -570,11 +696,20 @@ export class OpenAiCodexOAuthManager { * Start a local server to receive the OAuth callback * Returns a promise that resolves when authentication is complete */ - async waitForCallback(): Promise { + async waitForCallback(profileId?: string): Promise { if (!this.pendingAuth) { throw new Error("No pending authorization flow") } + if (profileId) { + const resolvedProfileId = this.resolveProfileId(profileId) + if (resolvedProfileId !== this.pendingAuth.profileId) { + this.log( + `[openai-codex-oauth] Profile mismatch during callback (pending=${this.pendingAuth.profileId}, requested=${resolvedProfileId})`, + ) + } + } + // Close any existing server before starting a new one if (this.pendingAuth.server) { try { @@ -585,6 +720,7 @@ export class OpenAiCodexOAuthManager { this.pendingAuth.server = undefined } + const pendingProfileId = this.pendingAuth.profileId return new Promise((resolve, reject) => { const server = http.createServer(async (req, res) => { try { @@ -629,7 +765,7 @@ export class OpenAiCodexOAuthManager { // per the implementation guide (OpenAI rejects it) const credentials = await exchangeCodeForTokens(code, this.pendingAuth.codeVerifier) - await this.saveCredentials(credentials) + await this.saveCredentials(credentials, pendingProfileId) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(` @@ -731,8 +867,9 @@ export class OpenAiCodexOAuthManager { /** * Get the current credentials (for display purposes) */ - getCredentials(): OpenAiCodexCredentials | null { - return this.credentials + getCredentials(profileId?: string): OpenAiCodexCredentials | null { + const resolvedProfileId = this.resolveProfileId(profileId) + return this.credentialsByProfile.get(resolvedProfileId) ?? null } } diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 939d2734d4b..a07d3441a22 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -145,7 +145,13 @@ const ApiOptions = ({ setErrorMessage, }: ApiOptionsProps) => { const { t } = useAppTranslation() - const { organizationAllowList, cloudIsAuthenticated, openAiCodexIsAuthenticated } = useExtensionState() + const { + organizationAllowList, + cloudIsAuthenticated, + openAiCodexIsAuthenticated, + openAiCodexAccountEmail, + currentApiConfigName, + } = useExtensionState() const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} @@ -563,6 +569,8 @@ const ApiOptions = ({ setApiConfigurationField={setApiConfigurationField} simplifySettings={fromWelcomeView} openAiCodexIsAuthenticated={openAiCodexIsAuthenticated} + openAiCodexAccountEmail={openAiCodexAccountEmail} + currentApiConfigName={currentApiConfigName} /> )} diff --git a/webview-ui/src/components/settings/providers/OpenAICodex.tsx b/webview-ui/src/components/settings/providers/OpenAICodex.tsx index 755b272702a..954527fa9f8 100644 --- a/webview-ui/src/components/settings/providers/OpenAICodex.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICodex.tsx @@ -14,6 +14,8 @@ interface OpenAICodexProps { setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void simplifySettings?: boolean openAiCodexIsAuthenticated?: boolean + openAiCodexAccountEmail?: string | null + currentApiConfigName?: string } export const OpenAICodex: React.FC = ({ @@ -21,6 +23,8 @@ export const OpenAICodex: React.FC = ({ setApiConfigurationField, simplifySettings, openAiCodexIsAuthenticated = false, + openAiCodexAccountEmail, + currentApiConfigName, }) => { const { t } = useAppTranslation() @@ -29,15 +33,39 @@ export const OpenAICodex: React.FC = ({ {/* Authentication Section */}
{openAiCodexIsAuthenticated ? ( -
- +
+
+
+
+ {t("settings:providers.openAiCodex.connectedLabel", { + defaultValue: "Connected", + })} +
+ {openAiCodexAccountEmail ? ( +
+ {openAiCodexAccountEmail} +
+ ) : null} +
+
+ + +
+
) : (
{/* Rate Limit Dashboard - only shown when authenticated */} - + {/* Model Picker */} ) => string @@ -84,7 +85,10 @@ const UsageProgressBar: React.FC<{ usedPercent: number; label?: string }> = ({ u ) } -export const OpenAICodexRateLimitDashboard: React.FC = ({ isAuthenticated }) => { +export const OpenAICodexRateLimitDashboard: React.FC = ({ + isAuthenticated, + currentApiConfigName, +}) => { const { t } = useAppTranslation() const [rateLimits, setRateLimits] = useState(null) const [isLoading, setIsLoading] = useState(false) @@ -120,10 +124,13 @@ export const OpenAICodexRateLimitDashboard: React.FC { + setRateLimits(null) + setError(null) + setIsLoading(false) if (isAuthenticated) { fetchRateLimits() } - }, [isAuthenticated, fetchRateLimits]) + }, [isAuthenticated, currentApiConfigName, fetchRateLimits]) if (!isAuthenticated) return null From 65a15d54e4357567a83bfeb69244a85a0710e54c Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sat, 31 Jan 2026 05:51:04 +0000 Subject: [PATCH 2/2] fix(tests): add getState() mock to requestOpenAiCodexRateLimits tests The tests were failing when run in isolation due to missing getState() mock with currentApiConfigName and listApiConfigMeta properties. Added the mock to the beforeEach block similar to other describe blocks in the file. --- src/core/webview/__tests__/webviewMessageHandler.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index faa8e926825..d0ae3f69f00 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -599,6 +599,10 @@ describe("webviewMessageHandler - requestRouterModels", () => { describe("webviewMessageHandler - requestOpenAiCodexRateLimits", () => { beforeEach(() => { vi.clearAllMocks() + mockClineProvider.getState = vi.fn().mockResolvedValue({ + currentApiConfigName: "test-profile", + listApiConfigMeta: [{ id: "test-profile-id", name: "test-profile" }], + }) mockGetAccessToken.mockResolvedValue(null) mockGetAccountId.mockResolvedValue(null) })