diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 6aa478cccba..57d94033ae5 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 + openAiCodexEmail?: string debug?: boolean } diff --git a/src/api/index.ts b/src/api/index.ts index 30119b7dc7e..eb1a2507172 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -4,6 +4,7 @@ import OpenAI from "openai" import type { ProviderSettings, ModelInfo } from "@roo-code/types" import { ApiStream } from "./transform/stream" +import { ApiHandlerOptions } from "../shared/api" import { AnthropicHandler, @@ -122,82 +123,91 @@ export interface ApiHandler { export function buildApiHandler(configuration: ProviderSettings): ApiHandler { const { apiProvider, ...options } = configuration + // Inject currentApiConfigName into options if available in configuration + // This ensures handlers (like OpenAiCodexHandler) can access the profile name + // for profile-scoped operations. + const currentApiConfigName = (configuration as any).currentApiConfigName + const handlerOptions: ApiHandlerOptions = { + ...options, + currentApiConfigName, + } + switch (apiProvider) { case "anthropic": - return new AnthropicHandler(options) + return new AnthropicHandler(handlerOptions) case "openrouter": - return new OpenRouterHandler(options) + return new OpenRouterHandler(handlerOptions) case "bedrock": - return new AwsBedrockHandler(options) + return new AwsBedrockHandler(handlerOptions) case "vertex": return options.apiModelId?.startsWith("claude") - ? new AnthropicVertexHandler(options) - : new VertexHandler(options) + ? new AnthropicVertexHandler(handlerOptions) + : new VertexHandler(handlerOptions) case "openai": - return new OpenAiHandler(options) + return new OpenAiHandler(handlerOptions) case "ollama": - return new NativeOllamaHandler(options) + return new NativeOllamaHandler(handlerOptions) case "lmstudio": - return new LmStudioHandler(options) + return new LmStudioHandler(handlerOptions) case "gemini": - return new GeminiHandler(options) + return new GeminiHandler(handlerOptions) case "openai-codex": - return new OpenAiCodexHandler(options) + return new OpenAiCodexHandler(handlerOptions) case "openai-native": - return new OpenAiNativeHandler(options) + return new OpenAiNativeHandler(handlerOptions) case "deepseek": - return new DeepSeekHandler(options) + return new DeepSeekHandler(handlerOptions) case "doubao": - return new DoubaoHandler(options) + return new DoubaoHandler(handlerOptions) case "qwen-code": - return new QwenCodeHandler(options) + return new QwenCodeHandler(handlerOptions) case "moonshot": - return new MoonshotHandler(options) + return new MoonshotHandler(handlerOptions) case "vscode-lm": - return new VsCodeLmHandler(options) + return new VsCodeLmHandler(handlerOptions) case "mistral": - return new MistralHandler(options) + return new MistralHandler(handlerOptions) case "unbound": - return new UnboundHandler(options) + return new UnboundHandler(handlerOptions) case "requesty": - return new RequestyHandler(options) + return new RequestyHandler(handlerOptions) case "fake-ai": - return new FakeAIHandler(options) + return new FakeAIHandler(handlerOptions) case "xai": - return new XAIHandler(options) + return new XAIHandler(handlerOptions) case "groq": - return new GroqHandler(options) + return new GroqHandler(handlerOptions) case "deepinfra": - return new DeepInfraHandler(options) + return new DeepInfraHandler(handlerOptions) case "huggingface": - return new HuggingFaceHandler(options) + return new HuggingFaceHandler(handlerOptions) case "chutes": - return new ChutesHandler(options) + return new ChutesHandler(handlerOptions) case "litellm": - return new LiteLLMHandler(options) + return new LiteLLMHandler(handlerOptions) case "cerebras": - return new CerebrasHandler(options) + return new CerebrasHandler(handlerOptions) case "sambanova": - return new SambaNovaHandler(options) + return new SambaNovaHandler(handlerOptions) case "zai": - return new ZAiHandler(options) + return new ZAiHandler(handlerOptions) case "fireworks": - return new FireworksHandler(options) + return new FireworksHandler(handlerOptions) case "io-intelligence": - return new IOIntelligenceHandler(options) + return new IOIntelligenceHandler(handlerOptions) case "roo": // Never throw exceptions from provider constructors // The provider-proxy server will handle authentication and return appropriate error codes - return new RooHandler(options) + return new RooHandler(handlerOptions) case "featherless": - return new FeatherlessHandler(options) + return new FeatherlessHandler(handlerOptions) case "vercel-ai-gateway": - return new VercelAiGatewayHandler(options) + return new VercelAiGatewayHandler(handlerOptions) case "minimax": - return new MiniMaxHandler(options) + return new MiniMaxHandler(handlerOptions) case "baseten": - return new BasetenHandler(options) + return new BasetenHandler(handlerOptions) default: - return new AnthropicHandler(options) + return new AnthropicHandler(handlerOptions) } } diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index d64780c5557..0f2f3f2bce5 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -151,7 +151,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.options.currentApiConfigName + let accessToken = await openAiCodexOAuthManager.getAccessToken(profileId) if (!accessToken) { throw new Error( t("common:errors.openAiCodex.notAuthenticated", { @@ -183,7 +184,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 +342,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.options.currentApiConfigName) // Build Codex-specific headers. Authorization is provided by the SDK apiKey. const codexHeaders: Record = { @@ -481,7 +482,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.options.currentApiConfigName) // Build headers with required Codex-specific fields const headers: Record = { @@ -1008,7 +1009,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const model = this.getModel() // Get access token - const accessToken = await openAiCodexOAuthManager.getAccessToken() + const profileId = this.options.currentApiConfigName + const accessToken = await openAiCodexOAuthManager.getAccessToken(profileId) if (!accessToken) { throw new Error( t("common:errors.openAiCodex.notAuthenticated", { @@ -1043,7 +1045,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.options.currentApiConfigName) // 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 8de7cf35e84..48af03a67d3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2235,11 +2235,23 @@ export class ClineProvider openAiCodexIsAuthenticated: await (async () => { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - return await openAiCodexOAuthManager.isAuthenticated() + // Ensure we use the latest currentApiConfigName from stateValues + const configName = this.contextProxy.getValue("currentApiConfigName") + return await openAiCodexOAuthManager.isAuthenticated(configName) } catch { return false } })(), + openAiCodexEmail: await (async () => { + try { + const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") + const configName = this.contextProxy.getValue("currentApiConfigName") + const email = await openAiCodexOAuthManager.getEmail(configName) + return email || undefined + } catch { + return undefined + } + })(), 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..b8ca0981e22 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2385,7 +2385,8 @@ export const webviewMessageHandler = async ( case "openAiCodexSignIn": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - const authUrl = openAiCodexOAuthManager.startAuthorizationFlow() + const profileId = getGlobalState("currentApiConfigName") + const authUrl = openAiCodexOAuthManager.startAuthorizationFlow(profileId) // Open the authorization URL in the browser await vscode.env.openExternal(vscode.Uri.parse(authUrl)) @@ -2412,7 +2413,8 @@ export const webviewMessageHandler = async ( case "openAiCodexSignOut": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - await openAiCodexOAuthManager.clearCredentials() + const profileId = getGlobalState("currentApiConfigName") + await openAiCodexOAuthManager.clearCredentials(profileId) vscode.window.showInformationMessage("Signed out from OpenAI Codex") await provider.postStateToWebview() } catch (error) { @@ -3244,7 +3246,8 @@ export const webviewMessageHandler = async ( case "requestOpenAiCodexRateLimits": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - const accessToken = await openAiCodexOAuthManager.getAccessToken() + const profileId = getGlobalState("currentApiConfigName") + const accessToken = await openAiCodexOAuthManager.getAccessToken(profileId) if (!accessToken) { provider.postMessageToWebview({ @@ -3254,7 +3257,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/__tests__/oauth.spec.ts b/src/integrations/openai-codex/__tests__/oauth.spec.ts new file mode 100644 index 00000000000..556043da669 --- /dev/null +++ b/src/integrations/openai-codex/__tests__/oauth.spec.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { OpenAiCodexOAuthManager } from "../oauth" + +describe("OpenAiCodexOAuthManager profile scoping", () => { + let manager: OpenAiCodexOAuthManager + let mockSecrets: Map + let mockContext: any + + beforeEach(() => { + mockSecrets = new Map() + mockContext = { + secrets: { + get: vi.fn(async (key: string) => mockSecrets.get(key)), + store: vi.fn(async (key: string, value: string) => { + mockSecrets.set(key, value) + }), + delete: vi.fn(async (key: string) => { + mockSecrets.delete(key) + }), + }, + } + manager = new OpenAiCodexOAuthManager() + manager.initialize(mockContext) + }) + + it("should use different storage keys for different profiles", async () => { + const creds1 = { + type: "openai-codex" as const, + access_token: "token1", + refresh_token: "refresh1", + expires: Date.now() + 3600000, + email: "user1@example.com", + } + const creds2 = { + type: "openai-codex" as const, + access_token: "token2", + refresh_token: "refresh2", + expires: Date.now() + 3600000, + email: "user2@example.com", + } + + await manager.saveCredentials(creds1, "profile1") + await manager.saveCredentials(creds2, "profile2") + + const loaded1 = await manager.loadCredentials("profile1") + const loaded2 = await manager.loadCredentials("profile2") + + expect(loaded1?.email).toBe("user1@example.com") + expect(loaded2?.email).toBe("user2@example.com") + expect(mockSecrets.has("openai-codex-oauth-credentials-profile1")).toBe(true) + expect(mockSecrets.has("openai-codex-oauth-credentials-profile2")).toBe(true) + }) + + it("should fall back to default key when no profileId is provided", async () => { + const creds = { + type: "openai-codex" as const, + access_token: "default-token", + refresh_token: "default-refresh", + expires: Date.now() + 3600000, + email: "default@example.com", + } + + await manager.saveCredentials(creds) + expect(mockSecrets.has("openai-codex-oauth-credentials")).toBe(true) + + const loaded = await manager.loadCredentials() + expect(loaded?.email).toBe("default@example.com") + }) + + it("should clear credentials only for the specified profile", async () => { + const creds = { + type: "openai-codex" as const, + access_token: "token", + refresh_token: "refresh", + expires: Date.now() + 3600000, + } + + await manager.saveCredentials(creds, "profile1") + await manager.saveCredentials(creds, "profile2") + + await manager.clearCredentials("profile1") + + expect(await manager.getCredentials("profile1")).toBeNull() + expect(await manager.loadCredentials("profile2")).not.toBeNull() + }) +}) diff --git a/src/integrations/openai-codex/oauth.ts b/src/integrations/openai-codex/oauth.ts index 0cae6a41640..d01797966fa 100644 --- a/src/integrations/openai-codex/oauth.ts +++ b/src/integrations/openai-codex/oauth.ts @@ -340,12 +340,13 @@ export function isTokenExpired(credentials: OpenAiCodexCredentials): boolean { */ export class OpenAiCodexOAuthManager { private context: ExtensionContext | null = null - private credentials: OpenAiCodexCredentials | null = null + private credentialsMap: Map = new Map() private logFn: ((message: string) => void) | null = null - private refreshPromise: Promise | null = null + private refreshPromises: Map> = new Map() private pendingAuth: { codeVerifier: string state: string + profileId?: string server?: http.Server } | null = null @@ -376,42 +377,80 @@ 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() + private async getCredentialsKey(profileId?: string): Promise { + if (!profileId || !this.context) { + return OPENAI_CODEX_CREDENTIALS_KEY } - if (!this.credentials) { + try { + // Try to find the profile ID from ProviderSettingsManager to use a stable key + const content = await this.context.secrets.get("roo_cline_config_api_config") + if (content) { + const profiles = JSON.parse(content) + const id = profiles.apiConfigs?.[profileId]?.id + if (id) { + return `${OPENAI_CODEX_CREDENTIALS_KEY}-${id}` + } + } + } catch (error) { + // Fallback to name-based key if lookup fails + } + + return `${OPENAI_CODEX_CREDENTIALS_KEY}-${profileId}` + } + + /** + * 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(profileId?: string): Promise { + let credentials: OpenAiCodexCredentials | null | undefined = this.credentialsMap.get(profileId || "default") + if (!credentials) { + credentials = await this.loadCredentials(profileId) + } + + 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) => { + let refreshPromise = this.refreshPromises.get(profileId || "default") + if (!refreshPromise) { + const prevRefreshToken = credentials.refresh_token + this.log( + `[openai-codex-oauth] Forcing token refresh (profile=${profileId || "default"}, expires=${credentials.expires})...`, + ) + refreshPromise = refreshAccessToken(credentials).then((newCreds) => { const rotated = newCreds.refresh_token !== prevRefreshToken this.log( - `[openai-codex-oauth] Forced refresh response received (expires_in≈${Math.round( + `[openai-codex-oauth] Forced refresh response received (profile=${profileId || "default"}, expires_in≈${Math.round( (newCreds.expires - Date.now()) / 1000, )}s, refresh_token_rotated=${rotated})`, ) return newCreds }) + this.refreshPromises.set(profileId || "default", refreshPromise) } - const newCredentials = await this.refreshPromise - this.refreshPromise = null - await this.saveCredentials(newCredentials) - this.log(`[openai-codex-oauth] Forced token persisted (expires=${newCredentials.expires})`) + const newCredentials = await refreshPromise + this.refreshPromises.delete(profileId || "default") + await this.saveCredentials(newCredentials, profileId) + this.log( + `[openai-codex-oauth] Forced token persisted (profile=${profileId || "default"}, expires=${newCredentials.expires})`, + ) return newCredentials.access_token } catch (error) { - this.refreshPromise = null - this.logError("[openai-codex-oauth] Failed to force refresh token:", error) + this.refreshPromises.delete(profileId || "default") + this.logError( + `[openai-codex-oauth] Failed to force refresh token (profile=${profileId || "default"}):`, + error, + ) if (error instanceof OpenAiCodexOAuthTokenError && error.isLikelyInvalidGrant()) { - this.log("[openai-codex-oauth] Refresh token appears invalid; clearing stored credentials") - await this.clearCredentials() + this.log( + `[openai-codex-oauth] Refresh token appears invalid for profile ${profileId || "default"}; clearing stored credentials`, + ) + await this.clearCredentials(profileId) } return null } @@ -420,22 +459,23 @@ export class OpenAiCodexOAuthManager { /** * Load credentials from storage */ - async loadCredentials(): Promise { + async loadCredentials(profileId?: string): Promise { if (!this.context) { return null } try { - const credentialsJson = await this.context.secrets.get(OPENAI_CODEX_CREDENTIALS_KEY) + const credentialsJson = await this.context.secrets.get(await this.getCredentialsKey(profileId)) if (!credentialsJson) { return null } const parsed = JSON.parse(credentialsJson) - this.credentials = openAiCodexCredentialsSchema.parse(parsed) - return this.credentials + const credentials = openAiCodexCredentialsSchema.parse(parsed) + this.credentialsMap.set(profileId || "default", credentials) + return credentials } catch (error) { - this.logError("[openai-codex-oauth] Failed to load credentials:", error) + this.logError(`[openai-codex-oauth] Failed to load credentials (profile=${profileId || "default"}):`, error) return null } } @@ -443,106 +483,119 @@ 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 + await this.context.secrets.store(await this.getCredentialsKey(profileId), JSON.stringify(credentials)) + this.credentialsMap.set(profileId || "default", 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 + await this.context.secrets.delete(await this.getCredentialsKey(profileId)) + this.credentialsMap.delete(profileId || "default") } /** * Get a valid access token, refreshing if necessary */ - async getAccessToken(): Promise { + async getAccessToken(profileId?: string): Promise { // Try to load credentials if not already loaded - if (!this.credentials) { - await this.loadCredentials() + let credentials: OpenAiCodexCredentials | null | undefined = this.credentialsMap.get(profileId || "default") + if (!credentials) { + credentials = await this.loadCredentials(profileId) } - 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) { + let refreshPromise = this.refreshPromises.get(profileId || "default") + if (!refreshPromise) { this.log( - `[openai-codex-oauth] Access token expired (expires=${this.credentials.expires}). Refreshing...`, + `[openai-codex-oauth] Access token expired (profile=${profileId || "default"}, expires=${credentials.expires}). Refreshing...`, ) - const prevRefreshToken = this.credentials.refresh_token - this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { + const prevRefreshToken = credentials.refresh_token + refreshPromise = refreshAccessToken(credentials).then((newCreds) => { const rotated = newCreds.refresh_token !== prevRefreshToken this.log( - `[openai-codex-oauth] Refresh response received (expires_in≈${Math.round( + `[openai-codex-oauth] Refresh response received (profile=${profileId || "default"}, expires_in≈${Math.round( (newCreds.expires - Date.now()) / 1000, )}s, refresh_token_rotated=${rotated})`, ) return newCreds }) + this.refreshPromises.set(profileId || "default", refreshPromise) } - const newCredentials = await this.refreshPromise - this.refreshPromise = null - await this.saveCredentials(newCredentials) - this.log(`[openai-codex-oauth] Token persisted (expires=${newCredentials.expires})`) + const newCredentials = await refreshPromise + this.refreshPromises.delete(profileId || "default") + await this.saveCredentials(newCredentials, profileId) + this.log( + `[openai-codex-oauth] Token persisted (profile=${profileId || "default"}, expires=${newCredentials.expires})`, + ) + credentials = newCredentials } catch (error) { - this.refreshPromise = null - this.logError("[openai-codex-oauth] Failed to refresh token:", error) + this.refreshPromises.delete(profileId || "default") + this.logError( + `[openai-codex-oauth] Failed to refresh token (profile=${profileId || "default"}):`, + 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() + this.log( + `[openai-codex-oauth] Refresh token appears invalid for profile ${profileId || "default"}; clearing stored credentials`, + ) + await this.clearCredentials(profileId) } 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 { + let credentials: OpenAiCodexCredentials | null | undefined = this.credentialsMap.get(profileId || "default") + if (!credentials) { + credentials = await this.loadCredentials(profileId) } - 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 { + let credentials: OpenAiCodexCredentials | null | undefined = this.credentialsMap.get(profileId || "default") + if (!credentials) { + credentials = await this.loadCredentials(profileId) } - 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,7 +603,7 @@ 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() @@ -561,6 +614,7 @@ export class OpenAiCodexOAuthManager { this.pendingAuth = { codeVerifier, state, + profileId, } return buildAuthorizationUrl(codeChallenge, state) @@ -616,7 +670,8 @@ export class OpenAiCodexOAuthManager { return } - if (state !== this.pendingAuth?.state) { + const pendingAuth = this.pendingAuth + if (!pendingAuth || state !== pendingAuth.state) { res.writeHead(400) res.end("State mismatch - possible CSRF attack") reject(new Error("State mismatch")) @@ -627,9 +682,9 @@ export class OpenAiCodexOAuthManager { try { // Note: state is validated above but not passed to exchangeCodeForTokens // per the implementation guide (OpenAI rejects it) - const credentials = await exchangeCodeForTokens(code, this.pendingAuth.codeVerifier) + const credentials = await exchangeCodeForTokens(code, pendingAuth.codeVerifier) - await this.saveCredentials(credentials) + await this.saveCredentials(credentials, pendingAuth.profileId) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(` @@ -731,8 +786,8 @@ export class OpenAiCodexOAuthManager { /** * Get the current credentials (for display purposes) */ - getCredentials(): OpenAiCodexCredentials | null { - return this.credentials + getCredentials(profileId?: string): OpenAiCodexCredentials | null { + return this.credentialsMap.get(profileId || "default") || null } } diff --git a/src/shared/api.ts b/src/shared/api.ts index b2ba1e35420..7b7e63cc177 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -11,6 +11,11 @@ import { // ApiHandlerOptions // Extend ProviderSettings (minus apiProvider) with handler-specific toggles. export type ApiHandlerOptions = Omit & { + /** + * The name of the currently active provider profile. + * Used for profile-scoped operations like OAuth credential storage. + */ + currentApiConfigName?: string /** * When true and using OpenAI Responses API models that support reasoning summaries, * include reasoning.summary: "auto" so the API returns summaries (we already parse diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 939d2734d4b..f1e37d610d9 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -145,7 +145,8 @@ const ApiOptions = ({ setErrorMessage, }: ApiOptionsProps) => { const { t } = useAppTranslation() - const { organizationAllowList, cloudIsAuthenticated, openAiCodexIsAuthenticated } = useExtensionState() + const { organizationAllowList, cloudIsAuthenticated, openAiCodexIsAuthenticated, openAiCodexEmail } = + useExtensionState() const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} @@ -563,6 +564,7 @@ const ApiOptions = ({ setApiConfigurationField={setApiConfigurationField} simplifySettings={fromWelcomeView} openAiCodexIsAuthenticated={openAiCodexIsAuthenticated} + openAiCodexEmail={openAiCodexEmail} /> )} diff --git a/webview-ui/src/components/settings/providers/OpenAICodex.tsx b/webview-ui/src/components/settings/providers/OpenAICodex.tsx index 755b272702a..8479fcc5bcf 100644 --- a/webview-ui/src/components/settings/providers/OpenAICodex.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICodex.tsx @@ -14,6 +14,7 @@ interface OpenAICodexProps { setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void simplifySettings?: boolean openAiCodexIsAuthenticated?: boolean + openAiCodexEmail?: string } export const OpenAICodex: React.FC = ({ @@ -21,6 +22,7 @@ export const OpenAICodex: React.FC = ({ setApiConfigurationField, simplifySettings, openAiCodexIsAuthenticated = false, + openAiCodexEmail, }) => { const { t } = useAppTranslation() @@ -29,7 +31,20 @@ export const OpenAICodex: React.FC = ({ {/* Authentication Section */}
{openAiCodexIsAuthenticated ? ( -
+
+
+ + {t("settings:providers.openAiCodex.authenticatedAs", { + defaultValue: "Authenticated as", + })} + + + {openAiCodexEmail || + t("settings:providers.openAiCodex.activeAccount", { + defaultValue: "Active Account", + })} + +