From d163e42457dce5a1614671c4fce20b76e7d2d6ed Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 16 Apr 2026 00:29:01 +0800 Subject: [PATCH 1/3] feat(settings): add ChatGPT OAuth device code login for Codex CLI Add OAuth device code flow for Codex CLI official subscription auth, allowing users to log in with their ChatGPT account directly from the agent settings page without using the terminal. - Backend: two new endpoints (codex_request_device_code, codex_poll_device_code) that handle the OpenAI OAuth device code flow and return tokens to frontend - Frontend: login UI with verification URL, copyable user code, polling status, 15-minute timeout, and auto-save via existing persistEnv/persistConfig path - Auth.json written in Codex CLI compatible format (nested tokens, account_id, last_refresh) so codex-acp can use OAuth tokens directly - Show logged-in status and re-login option when tokens are present - Remove auth.json textarea from Codex settings UI - i18n: all 10 languages updated with new login-related keys --- src-tauri/src/commands/acp.rs | 216 ++++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/web/handlers/acp.rs | 27 ++ src-tauri/src/web/router.rs | 8 + .../settings/acp-agent-settings.tsx | 311 +++++++++++++++--- src/i18n/messages/ar.json | 16 +- src/i18n/messages/de.json | 16 +- src/i18n/messages/en.json | 16 +- src/i18n/messages/es.json | 16 +- src/i18n/messages/fr.json | 16 +- src/i18n/messages/ja.json | 16 +- src/i18n/messages/ko.json | 16 +- src/i18n/messages/pt.json | 16 +- src/i18n/messages/zh-CN.json | 16 +- src/i18n/messages/zh-TW.json | 16 +- src/lib/api.ts | 26 ++ 16 files changed, 689 insertions(+), 61 deletions(-) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index cfcf26c..04cddf4 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -3077,3 +3077,219 @@ pub async fn opencode_uninstall_plugin( ) -> Result { opencode_uninstall_plugin_core(name).await } + +// ─── Codex Device Code OAuth ─── + +const CODEX_OAUTH_ISSUER: &str = "https://auth.openai.com"; +const CODEX_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexDeviceCodeResponse { + pub user_code: String, + pub verification_url: String, + pub device_auth_id: String, + pub interval: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexDeviceCodePollResult { + pub status: String, + pub message: Option, + pub id_token: Option, + pub access_token: Option, + pub refresh_token: Option, + pub account_id: Option, +} + +#[derive(Deserialize)] +struct DeviceCodeUserCodeResp { + device_auth_id: String, + #[serde(alias = "usercode")] + user_code: String, + #[serde(default = "default_interval", deserialize_with = "deserialize_interval")] + interval: u64, +} + +fn default_interval() -> u64 { + 5 +} + +fn extract_jwt_account_id(jwt: &str) -> Option { + let payload = jwt.split('.').nth(1)?; + let decoded = base64::Engine::decode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + payload, + ) + .ok()?; + let value: serde_json::Value = serde_json::from_slice(&decoded).ok()?; + value + .get("https://api.openai.com/auth") + .and_then(|auth| auth.get("chatgpt_account_id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +fn deserialize_interval<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de; + let value = serde_json::Value::deserialize(deserializer)?; + match &value { + serde_json::Value::Number(n) => n.as_u64().ok_or_else(|| { + de::Error::custom(format!("invalid interval number: {n}")) + }), + serde_json::Value::String(s) => s.trim().parse::().map_err(de::Error::custom), + _ => Err(de::Error::custom(format!("unexpected interval type: {value}"))), + } +} + +#[derive(Deserialize)] +struct DeviceCodeTokenResp { + authorization_code: String, + #[allow(dead_code)] + code_challenge: String, + code_verifier: String, +} + +#[derive(Deserialize)] +struct OAuthTokenResp { + id_token: String, + access_token: String, + refresh_token: String, +} + +pub(crate) async fn codex_request_device_code_core() + -> Result +{ + let client = reqwest::Client::new(); + let url = format!("{CODEX_OAUTH_ISSUER}/api/accounts/deviceauth/usercode"); + let body = serde_json::json!({ "client_id": CODEX_OAUTH_CLIENT_ID }); + + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| AcpError::protocol(format!("device code request failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(AcpError::protocol(format!( + "device code request returned {status}: {text}" + ))); + } + + let raw_body = resp + .text() + .await + .map_err(|e| AcpError::protocol(format!("read device code response failed: {e}")))?; + let uc: DeviceCodeUserCodeResp = serde_json::from_str(&raw_body) + .map_err(|e| AcpError::protocol(format!("parse device code response failed: {e} | body: {raw_body}")))?; + + Ok(CodexDeviceCodeResponse { + user_code: uc.user_code, + verification_url: format!("{CODEX_OAUTH_ISSUER}/codex/device"), + device_auth_id: uc.device_auth_id, + interval: uc.interval, + }) +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn codex_request_device_code() + -> Result +{ + codex_request_device_code_core().await +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn codex_poll_device_code( + device_auth_id: String, + user_code: String, +) -> Result { + codex_poll_device_code_core(device_auth_id, user_code).await +} + +pub(crate) async fn codex_poll_device_code_core( + device_auth_id: String, + user_code: String, +) -> Result { + let client = reqwest::Client::new(); + let poll_url = format!("{CODEX_OAUTH_ISSUER}/api/accounts/deviceauth/token"); + let poll_body = serde_json::json!({ + "device_auth_id": device_auth_id, + "user_code": user_code, + }); + + let resp = client + .post(&poll_url) + .json(&poll_body) + .send() + .await + .map_err(|e| AcpError::protocol(format!("device code poll failed: {e}")))?; + + if !resp.status().is_success() { + return Ok(CodexDeviceCodePollResult { + status: "pending".into(), + message: None, + id_token: None, + access_token: None, + refresh_token: None, + account_id: None, + }); + } + + let code_resp: DeviceCodeTokenResp = resp + .json() + .await + .map_err(|e| AcpError::protocol(format!("parse poll response failed: {e}")))?; + + let redirect_uri = format!("{CODEX_OAUTH_ISSUER}/deviceauth/callback"); + let token_url = format!("{CODEX_OAUTH_ISSUER}/oauth/token"); + + let token_resp = client + .post(&token_url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(format!( + "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}", + urlencoding::encode(&code_resp.authorization_code), + urlencoding::encode(&redirect_uri), + urlencoding::encode(CODEX_OAUTH_CLIENT_ID), + urlencoding::encode(&code_resp.code_verifier), + )) + .send() + .await + .map_err(|e| AcpError::protocol(format!("token exchange failed: {e}")))?; + + if !token_resp.status().is_success() { + let status = token_resp.status(); + let text = token_resp.text().await.unwrap_or_default(); + return Ok(CodexDeviceCodePollResult { + status: "error".into(), + message: Some(format!("token exchange returned {status}: {text}")), + id_token: None, + access_token: None, + refresh_token: None, + account_id: None, + }); + } + + let tokens: OAuthTokenResp = token_resp + .json() + .await + .map_err(|e| AcpError::protocol(format!("parse token response failed: {e}")))?; + + let account_id = extract_jwt_account_id(&tokens.id_token).unwrap_or_default(); + + Ok(CodexDeviceCodePollResult { + status: "success".into(), + message: None, + id_token: Some(tokens.id_token), + access_token: Some(tokens.access_token), + refresh_token: Some(tokens.refresh_token), + account_id: Some(account_id), + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 787d920..c4bf8a5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -393,6 +393,8 @@ mod tauri_app { acp_commands::opencode_list_plugins, acp_commands::opencode_install_plugins, acp_commands::opencode_uninstall_plugin, + acp_commands::codex_request_device_code, + acp_commands::codex_poll_device_code, experts_commands::experts_list, experts_commands::experts_list_for_agent, experts_commands::experts_get_install_status, diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index e74445e..70cd683 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -591,3 +591,30 @@ pub async fn opencode_uninstall_plugin( .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; Ok(Json(result)) } + +pub async fn codex_request_device_code( +) -> Result, AppCommandError> { + let result = acp_commands::codex_request_device_code_core() + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexPollDeviceCodeParams { + pub device_auth_id: String, + pub user_code: String, +} + +pub async fn codex_poll_device_code( + Json(params): Json, +) -> Result, AppCommandError> { + let result = acp_commands::codex_poll_device_code_core( + params.device_auth_id, + params.user_code, + ) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(result)) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index c05df43..fe47666 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -459,6 +459,14 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: "/opencode_uninstall_plugin", post(handlers::acp::opencode_uninstall_plugin), ) + .route( + "/codex_request_device_code", + post(handlers::acp::codex_request_device_code), + ) + .route( + "/codex_poll_device_code", + post(handlers::acp::codex_poll_device_code), + ) // ─── Experts ─── .route("/experts_list", post(handlers::experts::experts_list)) .route( diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 42a12ac..566dd2a 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -17,6 +17,7 @@ import { CheckCircle2, ChevronDown, ChevronRight, + Copy, Download, Eye, EyeOff, @@ -75,6 +76,8 @@ import { acpUninstallAgent, acpUpdateAgentConfig, acpUpdateAgentEnv, + codexPollDeviceCode, + codexRequestDeviceCode, listModelProviders, } from "@/lib/api" import type { @@ -1550,6 +1553,18 @@ function inferCodexAuthMode(authJsonText: string): CodexAuthMode { return "api_key" } +function hasCodexChatgptTokens(authJsonText: string): boolean { + const { authObject } = parseCodexAuthJsonObject(authJsonText) + if (!authObject) return false + const tokens = authObject.tokens as Record | undefined + if (tokens && typeof tokens === "object") { + return ( + typeof tokens.access_token === "string" && tokens.access_token.length > 0 + ) + } + return false +} + function extractCodexImportantValues( authJsonText: string, configTomlText: string @@ -2686,6 +2701,17 @@ export function AcpAgentSettings() { const installStream = useAgentInstallStream() const [streamAgentType, setStreamAgentType] = useState(null) const installLogEndRef = useRef(null) + const [codexDeviceCode, setCodexDeviceCode] = useState<{ + userCode: string + verificationUrl: string + deviceAuthId: string + interval: number + } | null>(null) + const [codexLoginStatus, setCodexLoginStatus] = useState< + "idle" | "requesting" | "polling" | "success" | "error" + >("idle") + const [codexLoginError, setCodexLoginError] = useState(null) + const codexPollCancelledRef = useRef(false) const sortedAgents = useMemo( () => @@ -3427,13 +3453,8 @@ export function AcpAgentSettings() { const selectedMissingModelProvider = selectedNeedsModelProvider && selectedDraft?.modelProviderId == null - const selectedCodexAuthJsonText = selectedDraft?.codexAuthJsonText ?? "" const selectedConfigText = selectedDraft?.configText ?? "" const selectedOpenCodeAuthJsonText = selectedDraft?.openCodeAuthJsonText ?? "" - const selectedCodexAuthError = useMemo(() => { - if (selectedAgentKind !== "codex" || !locale) return null - return parseCodexAuthJsonText(selectedCodexAuthJsonText) - }, [locale, selectedAgentKind, selectedCodexAuthJsonText]) const selectedCodexReasoningEffortOption = selectedAgent?.agent_type === "codex" && selectedDraft ? (CODEX_REASONING_EFFORT_OPTIONS.find( @@ -4594,31 +4615,6 @@ export function AcpAgentSettings() { [handleOpenCodeConfigPatch] ) - const handleCodexAuthJsonTextChange = useCallback( - (nextText: string) => { - if (!selectedAgent || selectedAgent.agent_type !== "codex") return - const important = extractCodexImportantValues( - nextText, - selectedDraft?.codexConfigTomlText ?? "" - ) - updateSelectedDraft((current) => ({ - ...current, - codexAuthMode: inferCodexAuthMode(nextText), - codexAuthJsonText: nextText, - apiBaseUrl: important.apiBaseUrl, - apiKey: important.apiKey ?? current.apiKey, - model: important.model, - codexModelProvider: important.modelProvider, - codexProviderOptions: important.providerOptions, - codexReasoningEffort: important.reasoningEffort, - codexSupportsWebsockets: important.supportsWebsockets, - codexSkills: important.skills, - codexServiceTierFast: important.serviceTierFast, - })) - }, - [selectedAgent, selectedDraft, updateSelectedDraft] - ) - const handleCodexConfigTomlTextChange = useCallback( (nextText: string) => { if (!selectedAgent || selectedAgent.agent_type !== "codex") return @@ -4901,6 +4897,138 @@ export function AcpAgentSettings() { [selectedAgent, selectedDraft, updateSelectedDraft] ) + const handleCodexDeviceLogin = useCallback(async () => { + setCodexLoginStatus("requesting") + setCodexLoginError(null) + setCodexDeviceCode(null) + codexPollCancelledRef.current = false + try { + const resp = await codexRequestDeviceCode() + setCodexDeviceCode(resp) + setCodexLoginStatus("polling") + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + setCodexLoginError(msg) + setCodexLoginStatus("error") + } + }, []) + + const cancelCodexDeviceLogin = useCallback(() => { + codexPollCancelledRef.current = true + setCodexLoginStatus("idle") + setCodexDeviceCode(null) + setCodexLoginError(null) + }, []) + + useEffect(() => { + if (codexLoginStatus !== "polling" || !codexDeviceCode) return + codexPollCancelledRef.current = false + const pollInterval = (codexDeviceCode.interval || 5) * 1000 + const deadline = Date.now() + 15 * 60 * 1000 + let timer: ReturnType | null = null + let active = true + + const poll = async () => { + if (!active || codexPollCancelledRef.current) return + if (Date.now() > deadline) { + setCodexLoginError(t("codex.loginTimeout")) + setCodexLoginStatus("error") + setCodexDeviceCode(null) + return + } + try { + const result = await codexPollDeviceCode({ + deviceAuthId: codexDeviceCode.deviceAuthId, + userCode: codexDeviceCode.userCode, + }) + if (!active || codexPollCancelledRef.current) return + if (result.status === "success") { + setCodexLoginStatus("success") + setCodexDeviceCode(null) + const authJson = JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + id_token: result.idToken, + access_token: result.accessToken, + refresh_token: result.refreshToken, + account_id: result.accountId ?? "", + }, + last_refresh: new Date().toISOString(), + }, + null, + 2 + ) + updateSelectedDraft((current) => ({ + ...current, + codexAuthJsonText: authJson, + })) + const draft = drafts.codex + if (draft) { + const codexEnvText = + draft.codexAuthMode === "chatgpt_subscription" + ? patchEnvText(draft.envText, { + OPENAI_API_KEY: "", + OPENAI_BASE_URL: "", + }) + : draft.envText + try { + await Promise.all([ + persistEnv( + "codex", + draft.enabled, + codexEnvText, + draft.modelProviderId + ), + persistConfig("codex", draft.configText, { + codexAuthJsonText: authJson, + codexConfigTomlText: draft.codexConfigTomlText, + }), + ]) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + toast.error(t("codex.loginSaveFailed"), { + description: msg, + }) + } + } + return + } + if (result.status === "error") { + setCodexLoginError(result.message ?? "Unknown error") + setCodexLoginStatus("error") + setCodexDeviceCode(null) + return + } + timer = setTimeout(poll, pollInterval) + } catch { + if (!active || codexPollCancelledRef.current) return + timer = setTimeout(poll, pollInterval) + } + } + + timer = setTimeout(poll, pollInterval) + return () => { + active = false + if (timer) clearTimeout(timer) + } + }, [ + codexLoginStatus, + codexDeviceCode, + drafts.codex, + persistConfig, + persistEnv, + updateSelectedDraft, + t, + ]) + + useEffect(() => { + if (selectedAgent?.agent_type !== "codex" && codexLoginStatus !== "idle") { + cancelCodexDeviceLogin() + } + }, [selectedAgent, codexLoginStatus, cancelCodexDeviceLogin]) + if (loadingAgents) { return (
@@ -5304,6 +5432,108 @@ export function AcpAgentSettings() {

+ {selectedDraft.codexAuthMode === "chatgpt_subscription" && ( +
+ {hasCodexChatgptTokens( + selectedDraft.codexAuthJsonText + ) && + codexLoginStatus !== "polling" && + codexLoginStatus !== "requesting" && ( +
+ + {t("codex.loggedIn")} +
+ )} + {codexLoginStatus === "idle" && ( + + )} + {codexLoginStatus === "requesting" && ( +
+ + {t("codex.loginRequesting")} +
+ )} + {codexLoginStatus === "polling" && codexDeviceCode && ( +
+

{t("codex.loginStep1")}

+ +

+ {t("codex.loginStep2")} +

+
+ + {codexDeviceCode.userCode} + + +
+
+ + {t("codex.loginPolling")} +
+ +
+ )} + {codexLoginStatus === "success" && ( +
+ + {t("codex.loginSuccess")} +
+ )} + {codexLoginStatus === "error" && ( +
+

+ {t("codex.loginFailed", { + message: codexLoginError ?? "Unknown error", + })} +

+ +
+ )} +
+ )} + {selectedDraft.codexAuthMode === "model_provider" && (
-
- -