diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e176f45..a42a3dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -456,21 +456,15 @@ jobs: - name: Upload to release (Unix) if: runner.os != 'Windows' - uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref_name }} - files: dist/${{ matrix.artifact }}.tar.gz + run: gh release upload "${{ github.ref_name }}" dist/${{ matrix.artifact }}.tar.gz --clobber - name: Upload to release (Windows) if: runner.os == 'Windows' - uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref_name }} - files: dist/${{ matrix.artifact }}.zip + run: gh release upload "${{ github.ref_name }}" dist/${{ matrix.artifact }}.zip --clobber build-docker: needs: diff --git a/package.json b/package.json index db82f58..85c873e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codeg", "private": true, - "version": "0.8.7", + "version": "0.8.8", "scripts": { "dev": "next dev --turbopack", "build": "next build", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d4226a8..f984f1a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -853,7 +853,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "codeg" -version = "0.8.7" +version = "0.8.8" dependencies = [ "agent-client-protocol-schema", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3ed6b0e..1129f27 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeg" -version = "0.8.7" +version = "0.8.8" description = "Agent Code Generation App" authors = ["feitao"] edition = "2021" 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-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 248b5f2..b8fef98 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "codeg", - "version": "0.8.7", + "version": "0.8.8", "identifier": "app.codeg", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index f66aab5..82572ab 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 { @@ -1549,6 +1552,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 @@ -2685,6 +2700,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( () => @@ -3426,13 +3452,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( @@ -4593,31 +4614,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 @@ -4900,6 +4896,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 (
@@ -5303,6 +5431,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" && (
-
- -