From 3f4b42706288d28efcfb863ae7b0bf3d9fc55ba7 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 17 Sep 2025 02:06:08 +0000 Subject: [PATCH] feat: add Codex CLI (native) provider with local authentication - Add codex-cli-native provider type to types package - Create CodexCliHandler for CLI authentication operations - Add CodexCliNative UI component with sign-in/sign-out functionality - Implement message handlers for authentication flow - Add secret storage for bearer token - Reuse OpenAI Native handler with locally obtained token - Keep UI text generic as requested --- packages/types/src/global-settings.ts | 1 + packages/types/src/provider-settings.ts | 13 +- src/api/index.ts | 12 +- src/core/config/ContextProxy.ts | 28 +++- src/core/webview/webviewMessageHandler.ts | 96 +++++++++++++ src/services/codex-cli/CodexCliHandler.ts | 111 +++++++++++++++ src/shared/ExtensionMessage.ts | 7 + src/shared/WebviewMessage.ts | 4 + .../src/components/settings/ApiOptions.tsx | 9 ++ .../src/components/settings/constants.ts | 2 + .../settings/providers/CodexCliNative.tsx | 128 ++++++++++++++++++ .../components/settings/providers/index.ts | 1 + 12 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 src/services/codex-cli/CodexCliHandler.ts create mode 100644 webview-ui/src/components/settings/providers/CodexCliNative.tsx diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 7e79855f7e..551faf64c3 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -181,6 +181,7 @@ export const SECRET_STATE_KEYS = [ "geminiApiKey", "openAiNativeApiKey", "cerebrasApiKey", + "codexCliOpenAiNativeToken", "deepSeekApiKey", "doubaoApiKey", "moonshotApiKey", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 6d628ddfdf..d633e08cb8 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -34,6 +34,7 @@ import { export const providerNames = [ "anthropic", "claude-code", + "codex-cli-native", "glama", "openrouter", "bedrock", @@ -343,6 +344,11 @@ const vercelAiGatewaySchema = baseProviderSettingsSchema.extend({ vercelAiGatewayModelId: z.string().optional(), }) +const codexCliNativeSchema = apiModelIdProviderModelSchema.extend({ + codexCliPath: z.string().optional(), + // No API key field - uses token from secrets +}) + const defaultSchema = z.object({ apiProvider: z.undefined(), }) @@ -350,6 +356,7 @@ const defaultSchema = z.object({ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [ anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })), claudeCodeSchema.merge(z.object({ apiProvider: z.literal("claude-code") })), + codexCliNativeSchema.merge(z.object({ apiProvider: z.literal("codex-cli-native") })), glamaSchema.merge(z.object({ apiProvider: z.literal("glama") })), openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })), bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })), @@ -391,6 +398,7 @@ export const providerSettingsSchema = z.object({ apiProvider: providerNamesSchema.optional(), ...anthropicSchema.shape, ...claudeCodeSchema.shape, + ...codexCliNativeSchema.shape, ...glamaSchema.shape, ...openRouterSchema.shape, ...bedrockSchema.shape, @@ -483,7 +491,10 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str } export const MODELS_BY_PROVIDER: Record< - Exclude, + Exclude< + ProviderName, + "fake-ai" | "human-relay" | "gemini-cli" | "lmstudio" | "openai" | "ollama" | "codex-cli-native" + >, { id: ProviderName; label: string; models: string[] } > = { anthropic: { diff --git a/src/api/index.ts b/src/api/index.ts index ac00967676..b854b97f2d 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -95,6 +95,16 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new AnthropicHandler(options) case "claude-code": return new ClaudeCodeHandler(options) + case "codex-cli-native": + // Reuse OpenAI Native handler with token from secrets + // The token will be injected from the secret storage + // Note: The token is stored in secrets as codexCliOpenAiNativeToken + // and will be injected into openAiNativeApiKey for the handler + return new OpenAiNativeHandler({ + ...options, + openAiNativeApiKey: (options as any).codexCliOpenAiNativeToken, + openAiNativeBaseUrl: options.openAiNativeBaseUrl || "https://api.openai.com", + }) case "glama": return new GlamaHandler(options) case "openrouter": @@ -166,7 +176,7 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { case "vercel-ai-gateway": return new VercelAiGatewayHandler(options) default: - apiProvider satisfies "gemini-cli" | undefined + apiProvider satisfies "gemini-cli" | "codex-cli-native" | undefined return new AnthropicHandler(options) } } diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index ab952da949..c15742be5d 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -267,13 +267,37 @@ export class ContextProxy { const values = this.getValues() try { - return providerSettingsSchema.parse(values) + const settings = providerSettingsSchema.parse(values) + + // For codex-cli-native provider, inject the token from secrets + if (settings.apiProvider === "codex-cli-native") { + const token = this.getSecret("codexCliOpenAiNativeToken" as SecretStateKey) + if (token) { + // Add the token to the settings object so it can be used by the API handler + ;(settings as any).codexCliOpenAiNativeToken = token + } + } + + return settings } catch (error) { if (error instanceof ZodError) { TelemetryService.instance.captureSchemaValidationError({ schemaName: "ProviderSettings", error }) } - return PROVIDER_SETTINGS_KEYS.reduce((acc, key) => ({ ...acc, [key]: values[key] }), {} as ProviderSettings) + const settings = PROVIDER_SETTINGS_KEYS.reduce( + (acc, key) => ({ ...acc, [key]: values[key] }), + {} as ProviderSettings, + ) + + // For codex-cli-native provider, inject the token from secrets (fallback case) + if (settings.apiProvider === "codex-cli-native") { + const token = this.getSecret("codexCliOpenAiNativeToken" as SecretStateKey) + if (token) { + ;(settings as any).codexCliOpenAiNativeToken = token + } + } + + return settings } } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index abdfae29fa..4a46519e80 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2323,6 +2323,102 @@ export const webviewMessageHandler = async ( break } + case "codexCliNativeCheckToken": { + // Check if token exists in secrets + const token = await provider.context.secrets.get("codexCliOpenAiNativeToken") + await provider.postMessageToWebview({ + type: "codexCliNativeTokenStatus", + hasToken: !!token, + }) + break + } + case "codexCliNativeSignIn": { + try { + // Import the CLI handler module + const { CodexCliHandler } = await import("../../services/codex-cli/CodexCliHandler") + + // Get the CLI path from settings or use default + const cliPath = message.text || "codex" + + // Run the sign-in flow + const handler = new CodexCliHandler(cliPath) + const token = await handler.signIn() + + if (token) { + // Store the token in secrets + await provider.context.secrets.store("codexCliOpenAiNativeToken", token) + + // Notify the webview of success + await provider.postMessageToWebview({ + type: "codexCliNativeSignInResult", + success: true, + }) + + // Update the state to reflect the new token + await provider.postStateToWebview() + } else { + throw new Error("Failed to obtain token from CLI") + } + } catch (error) { + provider.log(`CodexCliNative sign-in failed: ${error}`) + await provider.postMessageToWebview({ + type: "codexCliNativeSignInResult", + success: false, + error: error instanceof Error ? error.message : String(error), + }) + } + break + } + case "codexCliNativeSignOut": { + try { + // Clear the token from secrets + await provider.context.secrets.delete("codexCliOpenAiNativeToken") + + // Notify the webview of success + await provider.postMessageToWebview({ + type: "codexCliNativeSignOutResult", + success: true, + }) + + // Update the state + await provider.postStateToWebview() + } catch (error) { + provider.log(`CodexCliNative sign-out failed: ${error}`) + await provider.postMessageToWebview({ + type: "codexCliNativeSignOutResult", + success: false, + error: error instanceof Error ? error.message : String(error), + }) + } + break + } + case "codexCliNativeDetect": { + try { + // Import the CLI handler module + const { CodexCliHandler } = await import("../../services/codex-cli/CodexCliHandler") + + // Get the CLI path from settings or use default + const cliPath = message.text || "codex" + + // Check if CLI is available + const handler = new CodexCliHandler(cliPath) + const isAvailable = await handler.detect() + + await provider.postMessageToWebview({ + type: "codexCliNativeDetectResult", + available: isAvailable, + path: isAvailable ? cliPath : undefined, + }) + } catch (error) { + provider.log(`CodexCliNative detect failed: ${error}`) + await provider.postMessageToWebview({ + type: "codexCliNativeDetectResult", + available: false, + error: error instanceof Error ? error.message : String(error), + }) + } + break + } case "rooCloudManualUrl": { try { if (!message.text) { diff --git a/src/services/codex-cli/CodexCliHandler.ts b/src/services/codex-cli/CodexCliHandler.ts new file mode 100644 index 0000000000..e5657948b8 --- /dev/null +++ b/src/services/codex-cli/CodexCliHandler.ts @@ -0,0 +1,111 @@ +import { spawn } from "child_process" +import * as vscode from "vscode" + +/** + * Handler for Codex CLI authentication operations + * Based on the ChatMock reference implementation + */ +export class CodexCliHandler { + constructor(private cliPath: string = "codex") {} + + /** + * Detect if the CLI is available + */ + async detect(): Promise { + return new Promise((resolve) => { + const process = spawn(this.cliPath, ["--version"], { + shell: true, + windowsHide: true, + }) + + process.on("error", () => { + resolve(false) + }) + + process.on("exit", (code) => { + resolve(code === 0) + }) + + // Timeout after 5 seconds + setTimeout(() => { + process.kill() + resolve(false) + }, 5000) + }) + } + + /** + * Run the sign-in flow to obtain a bearer token + */ + async signIn(): Promise { + return new Promise((resolve, reject) => { + // Run the CLI auth command + const process = spawn(this.cliPath, ["auth", "login", "--json"], { + shell: true, + windowsHide: true, + }) + + let stdout = "" + let stderr = "" + + process.stdout?.on("data", (data) => { + stdout += data.toString() + }) + + process.stderr?.on("data", (data) => { + stderr += data.toString() + }) + + process.on("error", (error) => { + reject(new Error(`Failed to spawn CLI: ${error.message}`)) + }) + + process.on("exit", (code) => { + if (code === 0) { + try { + // Parse the JSON output to extract the token + const result = JSON.parse(stdout) + if (result.token) { + resolve(result.token) + } else { + reject(new Error("No token in CLI response")) + } + } catch (error) { + // If JSON parsing fails, try to extract token from plain text + const tokenMatch = stdout.match(/token[:\s]+([a-zA-Z0-9\-._~+/]+=*)/i) + if (tokenMatch) { + resolve(tokenMatch[1]) + } else { + reject(new Error(`Failed to parse CLI output: ${stdout}`)) + } + } + } else { + reject(new Error(`CLI exited with code ${code}: ${stderr || stdout}`)) + } + }) + + // Timeout after 2 minutes (to allow for browser auth flow) + setTimeout(() => { + process.kill() + reject(new Error("Sign-in timed out")) + }, 120000) + }) + } + + /** + * Check if a token is valid by making a test API call + */ + async validateToken(token: string): Promise { + try { + // Make a simple API call to validate the token + const response = await fetch("https://api.openai.com/v1/models", { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + return response.ok + } catch { + return false + } + } +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index aaddc520cb..ac46dda688 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -124,6 +124,10 @@ export interface ExtensionMessage { | "commands" | "insertTextIntoTextarea" | "dismissedUpsells" + | "codexCliNativeTokenStatus" + | "codexCliNativeSignInResult" + | "codexCliNativeSignOutResult" + | "codexCliNativeDetectResult" text?: string payload?: any // Add a generic payload for now, can refine later action?: @@ -201,6 +205,9 @@ export interface ExtensionMessage { commands?: Command[] queuedMessages?: QueuedMessage[] list?: string[] // For dismissedUpsells + hasToken?: boolean // For codexCliNativeTokenStatus + available?: boolean // For codexCliNativeDetectResult + path?: string // For codexCliNativeDetectResult } export type ExtensionState = Pick< diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 93d0b9bc45..80cecb8fd5 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -225,6 +225,10 @@ export interface WebviewMessage { | "editQueuedMessage" | "dismissUpsell" | "getDismissedUpsells" + | "codexCliNativeCheckToken" + | "codexCliNativeSignIn" + | "codexCliNativeSignOut" + | "codexCliNativeDetect" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 7f2ac4ed7a..0f3a0b1a11 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -68,6 +68,7 @@ import { Cerebras, Chutes, ClaudeCode, + CodexCliNative, DeepSeek, Doubao, Gemini, @@ -322,6 +323,7 @@ const ApiOptions = ({ "claude-code": { field: "apiModelId", default: claudeCodeDefaultModelId }, "qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId }, "openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId }, + "codex-cli-native": { field: "apiModelId", default: openAiNativeDefaultModelId }, gemini: { field: "apiModelId", default: geminiDefaultModelId }, deepseek: { field: "apiModelId", default: deepSeekDefaultModelId }, doubao: { field: "apiModelId", default: doubaoDefaultModelId }, @@ -513,6 +515,13 @@ const ApiOptions = ({ )} + {selectedProvider === "codex-cli-native" && ( + + )} + {selectedProvider === "openai-native" && ( >> = { anthropic: anthropicModels, "claude-code": claudeCodeModels, + "codex-cli-native": openAiNativeModels, // Reuses OpenAI native models bedrock: bedrockModels, cerebras: cerebrasModels, deepseek: deepSeekModels, @@ -51,6 +52,7 @@ export const PROVIDERS = [ { value: "deepinfra", label: "DeepInfra" }, { value: "anthropic", label: "Anthropic" }, { value: "claude-code", label: "Claude Code" }, + { value: "codex-cli-native", label: "Codex CLI (native)" }, { value: "cerebras", label: "Cerebras" }, { value: "gemini", label: "Google Gemini" }, { value: "doubao", label: "Doubao" }, diff --git a/webview-ui/src/components/settings/providers/CodexCliNative.tsx b/webview-ui/src/components/settings/providers/CodexCliNative.tsx new file mode 100644 index 0000000000..19b367e9a0 --- /dev/null +++ b/webview-ui/src/components/settings/providers/CodexCliNative.tsx @@ -0,0 +1,128 @@ +import { useState, useEffect } from "react" +import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import type { ProviderSettings } from "@roo-code/types" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { vscode } from "@src/utils/vscode" + +import { inputEventTransform } from "../transforms" + +type CodexCliNativeProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void +} + +export const CodexCliNative = ({ apiConfiguration, setApiConfigurationField }: CodexCliNativeProps) => { + const { t } = useAppTranslation() + const [isSignedIn, setIsSignedIn] = useState(false) + const [isSigningIn, setIsSigningIn] = useState(false) + const [showCustomPath, setShowCustomPath] = useState(false) + + // Check if user is signed in by checking if token exists + useEffect(() => { + // Request token status from extension + vscode.postMessage({ type: "codexCliNativeCheckToken" }) + }, []) + + // Listen for token status updates + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "codexCliNativeTokenStatus") { + setIsSignedIn(message.hasToken) + } else if (message.type === "codexCliNativeSignInResult") { + setIsSigningIn(false) + if (message.success) { + setIsSignedIn(true) + } + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, []) + + const handleSignIn = () => { + setIsSigningIn(true) + vscode.postMessage({ type: "codexCliNativeSignIn" }) + } + + const handleSignOut = () => { + vscode.postMessage({ type: "codexCliNativeSignOut" }) + setIsSignedIn(false) + } + + const handleDetect = () => { + vscode.postMessage({ type: "codexCliNativeDetect" }) + } + + const handlePathChange = (event: Event | React.FormEvent) => { + const value = inputEventTransform(event as Event) + setApiConfigurationField("codexCliPath", value as string) + } + + return ( +
+ {isSignedIn ? ( + <> +
+ {t("settings:providers.codexCliNative.signedInMessage")} +
+ + {t("settings:providers.codexCliNative.signOutButton")} + + + ) : ( + <> +
+ {t("settings:providers.codexCliNative.signInMessage")} +
+
+ + {isSigningIn + ? t("settings:providers.codexCliNative.signingInButton") + : t("settings:providers.codexCliNative.signInButton")} + + + {t("settings:providers.codexCliNative.detectButton")} + +
+ + )} + +
+ +
+ + {showCustomPath && ( +
+ + + +
+ {t("settings:providers.codexCliNative.pathDescription")} +
+
+ )} +
+ ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index fe0e6cecf9..3b8d99f8b6 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -3,6 +3,7 @@ export { Bedrock } from "./Bedrock" export { Cerebras } from "./Cerebras" export { Chutes } from "./Chutes" export { ClaudeCode } from "./ClaudeCode" +export { CodexCliNative } from "./CodexCliNative" export { DeepSeek } from "./DeepSeek" export { Doubao } from "./Doubao" export { Gemini } from "./Gemini"