diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9d7d9ddc084c..55b2142f345a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -178,7 +178,7 @@ checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "app_test_support" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "assert_cmd", @@ -822,7 +822,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" [[package]] name = "codex-ansi-escape" -version = "0.0.0" +version = "0.0.1" dependencies = [ "ansi-to-tui", "ratatui", @@ -831,7 +831,7 @@ dependencies = [ [[package]] name = "codex-app-server" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "app_test_support", @@ -866,7 +866,7 @@ dependencies = [ [[package]] name = "codex-app-server-protocol" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "clap", @@ -883,7 +883,7 @@ dependencies = [ [[package]] name = "codex-apply-patch" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "assert_cmd", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "codex-arg0" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "codex-apply-patch", @@ -911,7 +911,7 @@ dependencies = [ [[package]] name = "codex-async-utils" -version = "0.0.0" +version = "0.0.1" dependencies = [ "async-trait", "pretty_assertions", @@ -935,7 +935,7 @@ dependencies = [ [[package]] name = "codex-backend-openapi-models" -version = "0.0.0" +version = "0.0.1" dependencies = [ "serde", "serde_json", @@ -944,7 +944,7 @@ dependencies = [ [[package]] name = "codex-chatgpt" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "clap", @@ -959,7 +959,7 @@ dependencies = [ [[package]] name = "codex-cli" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "assert_cmd", @@ -995,7 +995,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "async-trait", @@ -1021,7 +1021,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-client" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "async-trait", @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "codex-common" -version = "0.0.0" +version = "0.0.1" dependencies = [ "clap", "codex-app-server-protocol", @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "codex-core" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "askama", @@ -1117,6 +1117,7 @@ dependencies = [ "tracing-test", "tree-sitter", "tree-sitter-bash", + "urlencoding", "uuid", "walkdir", "which", @@ -1126,7 +1127,7 @@ dependencies = [ [[package]] name = "codex-exec" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "assert_cmd", @@ -1159,7 +1160,7 @@ dependencies = [ [[package]] name = "codex-execpolicy" -version = "0.0.0" +version = "0.0.1" dependencies = [ "allocative", "anyhow", @@ -1179,7 +1180,7 @@ dependencies = [ [[package]] name = "codex-feedback" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "codex-protocol", @@ -1190,7 +1191,7 @@ dependencies = [ [[package]] name = "codex-file-search" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "clap", @@ -1203,7 +1204,7 @@ dependencies = [ [[package]] name = "codex-git" -version = "0.0.0" +version = "0.0.1" dependencies = [ "assert_matches", "once_cell", @@ -1219,7 +1220,7 @@ dependencies = [ [[package]] name = "codex-keyring-store" -version = "0.0.0" +version = "0.0.1" dependencies = [ "keyring", "tracing", @@ -1227,7 +1228,7 @@ dependencies = [ [[package]] name = "codex-linux-sandbox" -version = "0.0.0" +version = "0.0.1" dependencies = [ "clap", "codex-core", @@ -1240,7 +1241,7 @@ dependencies = [ [[package]] name = "codex-login" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "base64", @@ -1264,7 +1265,7 @@ dependencies = [ [[package]] name = "codex-mcp-server" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "assert_cmd", @@ -1291,7 +1292,7 @@ dependencies = [ [[package]] name = "codex-ollama" -version = "0.0.0" +version = "0.0.1" dependencies = [ "assert_matches", "async-stream", @@ -1307,7 +1308,7 @@ dependencies = [ [[package]] name = "codex-otel" -version = "0.0.0" +version = "0.0.1" dependencies = [ "chrono", "codex-app-server-protocol", @@ -1328,14 +1329,14 @@ dependencies = [ [[package]] name = "codex-process-hardening" -version = "0.0.0" +version = "0.0.1" dependencies = [ "libc", ] [[package]] name = "codex-protocol" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "base64", @@ -1361,7 +1362,7 @@ dependencies = [ [[package]] name = "codex-protocol-ts" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "clap", @@ -1371,7 +1372,7 @@ dependencies = [ [[package]] name = "codex-responses-api-proxy" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "clap", @@ -1387,7 +1388,7 @@ dependencies = [ [[package]] name = "codex-rmcp-client" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "axum", @@ -1416,7 +1417,7 @@ dependencies = [ [[package]] name = "codex-stdio-to-uds" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "assert_cmd", @@ -1427,7 +1428,7 @@ dependencies = [ [[package]] name = "codex-tui" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "arboard", @@ -1489,7 +1490,7 @@ dependencies = [ [[package]] name = "codex-utils-cache" -version = "0.0.0" +version = "0.0.1" dependencies = [ "lru", "sha1", @@ -1498,7 +1499,7 @@ dependencies = [ [[package]] name = "codex-utils-image" -version = "0.0.0" +version = "0.0.1" dependencies = [ "base64", "codex-utils-cache", @@ -1510,7 +1511,7 @@ dependencies = [ [[package]] name = "codex-utils-json-to-toml" -version = "0.0.0" +version = "0.0.1" dependencies = [ "pretty_assertions", "serde_json", @@ -1519,7 +1520,7 @@ dependencies = [ [[package]] name = "codex-utils-pty" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "portable-pty", @@ -1528,7 +1529,7 @@ dependencies = [ [[package]] name = "codex-utils-readiness" -version = "0.0.0" +version = "0.0.1" dependencies = [ "assert_matches", "async-trait", @@ -1539,11 +1540,11 @@ dependencies = [ [[package]] name = "codex-utils-string" -version = "0.0.0" +version = "0.0.1" [[package]] name = "codex-utils-tokenizer" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "pretty_assertions", @@ -1666,7 +1667,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core_test_support" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "assert_cmd", @@ -3639,7 +3640,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "mcp-types" -version = "0.0.0" +version = "0.0.1" dependencies = [ "schemars 0.8.22", "serde", @@ -3649,7 +3650,7 @@ dependencies = [ [[package]] name = "mcp_test_support" -version = "0.0.0" +version = "0.0.1" dependencies = [ "anyhow", "assert_cmd", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index a2c52f44ad26..d8d9ca02b6b6 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -43,7 +43,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.0.1" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 @@ -123,8 +123,8 @@ eventsource-stream = "0.2.3" futures = { version = "0.3", default-features = false } http = "1.3.1" icu_decimal = "2.1" -icu_provider = { version = "2.1", features = ["sync"] } icu_locale_core = "2.1" +icu_provider = { version = "2.1", features = ["sync"] } ignore = "0.4.23" image = { version = "^0.25.8", default-features = false } indexmap = "2.6.0" @@ -254,7 +254,12 @@ unwrap_used = "deny" # cargo-shear cannot see the platform-specific openssl-sys usage, so we # silence the false positive here instead of deleting a real dependency. [workspace.metadata.cargo-shear] -ignored = ["icu_provider", "openssl-sys", "codex-utils-readiness", "codex-utils-tokenizer"] +ignored = [ + "icu_provider", + "openssl-sys", + "codex-utils-readiness", + "codex-utils-tokenizer", +] [profile.release] lto = "fat" diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 74ad15f61d9e..27ea67587ed9 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -44,6 +44,7 @@ impl GitSha { pub enum AuthMode { ApiKey, ChatGPT, + OCA, } /// Generates an `enum ClientRequest` where each variant is a request that the @@ -435,7 +436,7 @@ pub struct Model { pub display_name: String, pub description: String, pub supported_reasoning_efforts: Vec, - pub default_reasoning_effort: ReasoningEffort, + pub default_reasoning_effort: Option, // Only one model should be marked as default. pub is_default: bool, } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index eb89582050b8..d88aab11e193 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -68,6 +68,7 @@ use codex_core::InitialHistory; use codex_core::NewConversation; use codex_core::RolloutRecorder; use codex_core::SessionMeta; +use codex_core::auth::CHATGPT_AUTH_MODE; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::config::Config; @@ -373,6 +374,7 @@ impl CodexMessageProcessor { CLIENT_ID.to_string(), config.forced_chatgpt_workspace_id.clone(), config.cli_auth_credentials_store_mode, + CHATGPT_AUTH_MODE.to_string(), ) }; diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index be47cbc2cbf6..53b962f7dfac 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -2,10 +2,10 @@ use codex_app_server_protocol::Model; use codex_app_server_protocol::ReasoningEffortOption; use codex_common::model_presets::ModelPreset; use codex_common::model_presets::ReasoningEffortPreset; -use codex_common::model_presets::builtin_model_presets; +use codex_common::model_presets::builtin_model_presets_sync; pub fn supported_models() -> Vec { - builtin_model_presets(None) + builtin_model_presets_sync(None) .into_iter() .map(model_from_preset) .collect() diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 071a920b8946..5963c0121a5a 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -8,6 +8,7 @@ use chrono::DateTime; use chrono::Utc; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::AuthDotJson; +use codex_core::auth::CHATGPT_AUTH_MODE; use codex_core::auth::save_auth; use codex_core::token_data::TokenData; use codex_core::token_data::parse_id_token; @@ -129,6 +130,7 @@ pub fn write_chatgpt_auth( openai_api_key: None, tokens: Some(tokens), last_refresh, + last_auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), }; save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") diff --git a/codex-rs/app-server/tests/suite/model_list.rs b/codex-rs/app-server/tests/suite/model_list.rs index 3472e0d3cb6f..241fe1e982d7 100644 --- a/codex-rs/app-server/tests/suite/model_list.rs +++ b/codex-rs/app-server/tests/suite/model_list.rs @@ -62,7 +62,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { .to_string(), }, ], - default_reasoning_effort: ReasoningEffort::Medium, + default_reasoning_effort: Some(ReasoningEffort::Medium), is_default: true, }, Model { @@ -93,7 +93,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { .to_string(), }, ], - default_reasoning_effort: ReasoningEffort::Medium, + default_reasoning_effort: Some(ReasoningEffort::Medium), is_default: false, }, ]; diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 6681ab20c8ff..afbfcdb6150e 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -2,6 +2,7 @@ use codex_app_server_protocol::AuthMode; use codex_common::CliConfigOverrides; use codex_core::CodexAuth; use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::CHATGPT_AUTH_MODE; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::auth::logout; @@ -25,6 +26,7 @@ pub async fn login_with_chatgpt( CLIENT_ID.to_string(), forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, + CHATGPT_AUTH_MODE.to_string(), ); let server = run_login_server(opts)?; @@ -135,6 +137,7 @@ pub async fn run_login_with_device_code( client_id.unwrap_or(CLIENT_ID.to_string()), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, + CHATGPT_AUTH_MODE.to_string(), ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -170,6 +173,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { eprintln!("Logged in using ChatGPT"); std::process::exit(0); } + AuthMode::OCA => { + eprintln!("Logged in using OCA"); + std::process::exit(0); + } }, Ok(None) => { eprintln!("Not logged in"); diff --git a/codex-rs/common/Cargo.toml b/codex-rs/common/Cargo.toml index d8f30cc09ddd..cd4a547dfcd1 100644 --- a/codex-rs/common/Cargo.toml +++ b/codex-rs/common/Cargo.toml @@ -8,9 +8,9 @@ workspace = true [dependencies] clap = { workspace = true, features = ["derive", "wrap_help"], optional = true } +codex-app-server-protocol = { workspace = true } codex-core = { workspace = true } codex-protocol = { workspace = true } -codex-app-server-protocol = { workspace = true } serde = { workspace = true, optional = true } toml = { workspace = true, optional = true } diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index b6d064380971..397dcaf84b0e 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -1,5 +1,46 @@ use codex_app_server_protocol::AuthMode; +use codex_core::default_client::create_client; use codex_core::protocol_config_types::ReasoningEffort; +#[cfg(feature = "serde")] +use serde::Deserialize; + +#[cfg_attr(feature = "serde", derive(Deserialize))] +struct ModelInfoResponse { + #[allow(unused)] + object: Option, + data: Vec, +} + +#[cfg_attr(feature = "serde", derive(Deserialize))] +struct ModelInfoLiteLLMParams { + #[allow(unused)] + max_tokens: i64, + model: String, +} + +#[cfg_attr(feature = "serde", derive(Deserialize))] +struct ModelInfoParams { + #[allow(unused)] + banner: Option, + #[allow(unused)] + context_window: i64, + description: Option, + #[allow(unused)] + labels: Option, + #[allow(unused)] + survey_content: Option, + #[allow(unused)] + survey_id: Option, + #[allow(unused)] + version: Option, +} + +#[cfg_attr(feature = "serde", derive(Deserialize))] +struct ModelInfo { + model_info: ModelInfoParams, + model_name: String, + litellm_params: ModelInfoLiteLLMParams, +} /// A reasoning effort option that can be surfaced for a model. #[derive(Debug, Clone, Copy)] @@ -22,7 +63,7 @@ pub struct ModelPreset { /// Short human description shown in UIs. pub description: &'static str, /// Reasoning effort applied when none is explicitly chosen. - pub default_reasoning_effort: ReasoningEffort, + pub default_reasoning_effort: Option, /// Supported reasoning effort options. pub supported_reasoning_efforts: &'static [ReasoningEffortPreset], /// Whether this is the default model for new users. @@ -35,7 +76,7 @@ const PRESETS: &[ModelPreset] = &[ model: "gpt-5-codex", display_name: "gpt-5-codex", description: "Optimized for coding tasks with many tools.", - default_reasoning_effort: ReasoningEffort::Medium, + default_reasoning_effort: Some(ReasoningEffort::Medium), supported_reasoning_efforts: &[ ReasoningEffortPreset { effort: ReasoningEffort::Low, @@ -57,7 +98,7 @@ const PRESETS: &[ModelPreset] = &[ model: "gpt-5", display_name: "gpt-5", description: "Broad world knowledge with strong general reasoning.", - default_reasoning_effort: ReasoningEffort::Medium, + default_reasoning_effort: Some(ReasoningEffort::Medium), supported_reasoning_efforts: &[ ReasoningEffortPreset { effort: ReasoningEffort::Minimal, @@ -80,10 +121,79 @@ const PRESETS: &[ModelPreset] = &[ }, ]; -pub fn builtin_model_presets(_auth_mode: Option) -> Vec { +/// Synchronous version that returns static presets for non-OCA auth modes. +/// For OCA auth mode, this will panic - use the async version instead. +pub fn builtin_model_presets_sync(_auth_mode: Option) -> Vec { + if _auth_mode == Some(AuthMode::OCA) { + panic!("OCA auth mode requires async builtin_model_presets function"); + } PRESETS.to_vec() } +pub async fn builtin_model_presets( + _auth_mode: Option, + base_url: Option<&str>, + access_token: Option<&str>, +) -> Result, Box> { + if _auth_mode == Some(AuthMode::OCA) { + // For now, return static presets. The async version would be called from async contexts. + return fetch_oracle_code_assist_models( + base_url.unwrap_or_default(), + access_token.unwrap_or_default(), + ) + .await; + } + Ok(PRESETS.to_vec()) +} + +pub async fn fetch_oracle_code_assist_models( + base_url: &str, + access_token: &str, +) -> Result, Box> { + let client = create_client(); + let url = format!("{}/v1/model/info", base_url.trim_end_matches('/')); + + let response = client.get(&url).bearer_auth(access_token).send().await?; + + if !response.status().is_success() { + return Err(format!("API request failed with status: {}", response.status()).into()); + } + + let response_data = response.json::().await?; + let mut presets = Vec::new(); + + let mut is_default = true; + + for model_info in response_data.data { + // Create static versions for the struct + let id = Box::leak(model_info.litellm_params.model.clone().into_boxed_str()); + let model = Box::leak(model_info.litellm_params.model.clone().into_boxed_str()); + let display_name = Box::leak(model_info.model_name.into_boxed_str()); + let description = Box::leak( + model_info + .model_info + .description + .unwrap_or_default() + .into_boxed_str(), + ); + + let preset = ModelPreset { + id, + model, + display_name, + description, + default_reasoning_effort: None, + supported_reasoning_efforts: Box::leak(Vec::new().into_boxed_slice()), + is_default, + }; + is_default = false; + + presets.push(preset); + } + + Ok(presets) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 921ca2843ae4..f0ee1e2f3523 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -80,6 +80,7 @@ toml_edit = { workspace = true } tracing = { workspace = true, features = ["log"] } tree-sitter = { workspace = true } tree-sitter-bash = { workspace = true } +urlencoding = { workspace = true } uuid = { workspace = true, features = ["serde", "v4"] } which = { workspace = true } wildmatch = { workspace = true } diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index b18cae5fa6db..60bf0fe8e233 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -19,6 +19,8 @@ use codex_protocol::config_types::ForcedLoginMethod; pub use crate::auth::storage::AuthCredentialsStoreMode; pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; +pub use crate::auth::storage::CHATGPT_AUTH_MODE; +pub use crate::auth::storage::ORACLE_CODE_ASSIST_AUTH_MODE; use crate::auth::storage::create_auth_storage; use crate::config::Config; use crate::default_client::CodexHttpClient; @@ -44,7 +46,8 @@ impl PartialEq for CodexAuth { } // TODO(pakrym): use token exp field to check for expiration instead -const TOKEN_REFRESH_INTERVAL: i64 = 8; +const CHATGPT_TOKEN_REFRESH_INTERVAL_HOURS: i64 = 8 * 24; +const OCA_TOKEN_REFRESH_INTERVAL_HOURS: i64 = 1; impl CodexAuth { pub async fn refresh_token(&self) -> Result { @@ -55,7 +58,7 @@ impl CodexAuth { .ok_or(std::io::Error::other("Token data is not available."))?; let token = token_data.refresh_token; - let refresh_response = try_refresh_token(token, &self.client) + let refresh_response = try_refresh_token(self.mode, token, &self.client) .await .map_err(std::io::Error::other)?; @@ -92,16 +95,21 @@ impl CodexAuth { pub async fn get_token_data(&self) -> Result { let auth_dot_json: Option = self.get_current_auth_json(); + let refresh_time = match self.mode { + AuthMode::ChatGPT => CHATGPT_TOKEN_REFRESH_INTERVAL_HOURS, + AuthMode::OCA => OCA_TOKEN_REFRESH_INTERVAL_HOURS, + AuthMode::ApiKey => i64::MAX, + }; match auth_dot_json { Some(AuthDotJson { tokens: Some(mut tokens), last_refresh: Some(last_refresh), .. }) => { - if last_refresh < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) { + if last_refresh < Utc::now() - chrono::Duration::hours(refresh_time) { let refresh_response = tokio::time::timeout( Duration::from_secs(60), - try_refresh_token(tokens.refresh_token.clone(), &self.client), + try_refresh_token(self.mode, tokens.refresh_token.clone(), &self.client), ) .await .map_err(|_| { @@ -138,7 +146,7 @@ impl CodexAuth { pub async fn get_token(&self) -> Result { match self.mode { AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()), - AuthMode::ChatGPT => { + AuthMode::ChatGPT | AuthMode::OCA => { let id_token = self.get_token_data().await?.access_token; Ok(id_token) } @@ -178,6 +186,7 @@ impl CodexAuth { account_id: Some("account_id".to_string()), }), last_refresh: Some(Utc::now()), + last_auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), }; let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json))); @@ -242,6 +251,7 @@ pub fn login_with_api_key( openai_api_key: Some(api_key.to_string()), tokens: None, last_refresh: None, + last_auth_mode: None, }; save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } @@ -280,14 +290,31 @@ pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> let method_violation = match (required_method, auth.mode) { (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, (ForcedLoginMethod::Chatgpt, AuthMode::ChatGPT) => None, + (ForcedLoginMethod::OCA, AuthMode::OCA) => None, (ForcedLoginMethod::Api, AuthMode::ChatGPT) => Some( "API key login is required, but ChatGPT is currently being used. Logging out." .to_string(), ), + (ForcedLoginMethod::Api, AuthMode::OCA) => Some( + "API key login is required, but OCA is currently being used. Logging out." + .to_string(), + ), (ForcedLoginMethod::Chatgpt, AuthMode::ApiKey) => Some( "ChatGPT login is required, but an API key is currently being used. Logging out." .to_string(), ), + (ForcedLoginMethod::OCA, AuthMode::ApiKey) => Some( + "OCA login is required, but an API key is currently being used. Logging out." + .to_string(), + ), + (ForcedLoginMethod::OCA, AuthMode::ChatGPT) => Some( + "OCA login is required, but Chatgpt login is currently being used. Logging out." + .to_string(), + ), + (ForcedLoginMethod::Chatgpt, AuthMode::OCA) => Some( + "Chatgpt login is required, but OCA login is currently being used. Logging out." + .to_string(), + ), }; if let Some(message) = method_violation { @@ -377,6 +404,7 @@ fn load_auth( openai_api_key: auth_json_api_key, tokens, last_refresh, + last_auth_mode, } = auth_dot_json; // Prefer AuthMode.ApiKey if it's set in the auth.json. @@ -384,14 +412,25 @@ fn load_auth( return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); } + let mode = if tokens.is_some() { + match last_auth_mode.as_deref() { + Some(s) if s == ORACLE_CODE_ASSIST_AUTH_MODE => AuthMode::OCA, + Some(s) if s == CHATGPT_AUTH_MODE => AuthMode::ChatGPT, + _ => AuthMode::ApiKey, + } + } else { + AuthMode::ApiKey + }; + Ok(Some(CodexAuth { api_key: None, - mode: AuthMode::ChatGPT, + mode, storage: storage.clone(), auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson { openai_api_key: None, tokens, last_refresh, + last_auth_mode, }))), client, })) @@ -422,7 +461,61 @@ async fn update_tokens( Ok(auth_dot_json) } +async fn try_oca_refresh_token( + refresh_token: String, + client: &CodexHttpClient, +) -> std::io::Result { + let refresh_url = format!("{OCA_IDCS_URL}/oauth2/v1/token"); + + let form_data = OcaRefreshForm { + grant_type: "refresh_token".to_string(), + refresh_token, + client_id: OCA_CLIENT_ID.to_string(), + }; + + let response = client + .post(refresh_url) + .form(&form_data) + .send() + .await + .map_err(std::io::Error::other)?; + + if response.status().is_success() { + let refresh_response = response + .json::() + .await + .map_err(std::io::Error::other)?; + Ok(refresh_response) + } else if response.status() == reqwest::StatusCode::UNAUTHORIZED + || response.status() == reqwest::StatusCode::BAD_REQUEST + { + // Return specific authentication expired error for 401 responses + // This allows the TUI layer to detect and trigger reauthentication + Err(std::io::Error::other( + crate::error::CodexErr::AuthenticationExpired, + )) + } else { + Err(std::io::Error::other(format!( + "Failed to refresh token: {}: {}", + response.status(), + try_parse_error_message(&response.text().await.unwrap_or_default()), + ))) + } +} + async fn try_refresh_token( + mode: AuthMode, + refresh_token: String, + client: &CodexHttpClient, +) -> std::io::Result { + match mode { + AuthMode::ChatGPT => try_chatgpt_refresh_token(refresh_token, client).await, + AuthMode::OCA => try_oca_refresh_token(refresh_token, client).await, + AuthMode::ApiKey => Err(std::io::Error::other("Trying to refresh API Key")), + } +} + +async fn try_chatgpt_refresh_token( refresh_token: String, client: &CodexHttpClient, ) -> std::io::Result { @@ -472,8 +565,19 @@ struct RefreshResponse { refresh_token: Option, } +#[derive(Serialize)] +struct OcaRefreshForm { + grant_type: String, + refresh_token: String, + client_id: String, +} + // Shared constant for token refresh (client id used for oauth token refresh flow) pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +pub const OCA_CLIENT_ID: &str = "a8331954c0cf48ba99b5dd223a14c6ea"; + +pub const OCA_IDCS_URL: &str = + "https://idcs-9dc693e80d9b469480d7afe00e743931.identity.oraclecloud.com"; use std::sync::RwLock; @@ -619,6 +723,7 @@ mod tests { account_id: None, }), last_refresh: Some(last_refresh), + last_auth_mode: Some(CHATGPT_AUTH_MODE.to_string()) }, auth_dot_json ); @@ -651,6 +756,7 @@ mod tests { openai_api_key: Some("sk-test-key".to_string()), tokens: None, last_refresh: None, + last_auth_mode: None, }; super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?; let auth_file = get_auth_file(dir.path()); diff --git a/codex-rs/core/src/auth/storage.rs b/codex-rs/core/src/auth/storage.rs index a238eb9c38ed..c56f3e9706cb 100644 --- a/codex-rs/core/src/auth/storage.rs +++ b/codex-rs/core/src/auth/storage.rs @@ -20,6 +20,9 @@ use crate::token_data::TokenData; use codex_keyring_store::DefaultKeyringStore; use codex_keyring_store::KeyringStore; +pub const CHATGPT_AUTH_MODE: &str = "ChatGPT"; +pub const ORACLE_CODE_ASSIST_AUTH_MODE: &str = "Oracle Code Assist"; + /// Determine where Codex should store CLI auth credentials. #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -44,6 +47,9 @@ pub struct AuthDotJson { #[serde(default, skip_serializing_if = "Option::is_none")] pub last_refresh: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_auth_mode: Option, } pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf { @@ -298,6 +304,7 @@ mod tests { openai_api_key: Some("test-key".to_string()), tokens: None, last_refresh: Some(Utc::now()), + last_auth_mode: None, }; storage @@ -317,6 +324,7 @@ mod tests { openai_api_key: Some("test-key".to_string()), tokens: None, last_refresh: Some(Utc::now()), + last_auth_mode: None, }; let file = get_auth_file(codex_home.path()); @@ -338,6 +346,7 @@ mod tests { openai_api_key: Some("sk-test-key".to_string()), tokens: None, last_refresh: None, + last_auth_mode: None, }; let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); storage.save(&auth_dot_json)?; @@ -432,6 +441,7 @@ mod tests { account_id: Some(format!("{prefix}-account-id")), }), last_refresh: None, + last_auth_mode: None, } } @@ -447,6 +457,7 @@ mod tests { openai_api_key: Some("sk-test".to_string()), tokens: None, last_refresh: None, + last_auth_mode: None, }; seed_keyring_with_auth( &mock_keyring, @@ -488,6 +499,7 @@ mod tests { account_id: Some("account".to_string()), }), last_refresh: Some(Utc::now()), + last_auth_mode: None, }; storage.save(&auth)?; diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 1fb2230d7939..0ee1a22a8c04 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -40,6 +40,7 @@ pub(crate) async fn stream_chat_completions( model_family: &ModelFamily, client: &CodexHttpClient, provider: &ModelProviderInfo, + auth: &Option, otel_event_manager: &OtelEventManager, ) -> Result { if prompt.output_schema.is_some() { @@ -334,7 +335,7 @@ pub(crate) async fn stream_chat_completions( debug!( "POST to {}: {}", - provider.get_full_url(&None), + provider.get_full_url(auth), serde_json::to_string_pretty(&payload).unwrap_or_default() ); @@ -343,7 +344,7 @@ pub(crate) async fn stream_chat_completions( loop { attempt += 1; - let req_builder = provider.create_request_builder(client, &None).await?; + let req_builder = provider.create_request_builder(client, auth).await?; let res = otel_event_manager .log_request(attempt, || { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index b38d6de8a48c..5b6d785a5063 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -151,11 +151,13 @@ impl ModelClient { WireApi::Responses => self.stream_responses(prompt, task_kind).await, WireApi::Chat => { // Create the raw streaming connection first. + let auth = self.auth_manager.as_ref().and_then(|m| m.auth()); let response_stream = stream_chat_completions( prompt, &self.config.model_family, &self.client, &self.provider, + &auth, &self.otel_event_manager, ) .await?; @@ -1106,6 +1108,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let otel_event_manager = otel_event_manager(); @@ -1170,6 +1173,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let otel_event_manager = otel_event_manager(); @@ -1207,6 +1211,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let otel_event_manager = otel_event_manager(); @@ -1246,6 +1251,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let otel_event_manager = otel_event_manager(); @@ -1281,6 +1287,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let otel_event_manager = otel_event_manager(); @@ -1385,6 +1392,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let otel_event_manager = otel_event_manager(); diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index c2510dc7170c..ce162a3f701c 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -2718,6 +2718,7 @@ model_verbosity = "high" stream_max_retries: Some(10), stream_idle_timeout_ms: Some(300_000), requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let model_provider_map = { let mut model_provider_map = built_in_model_providers(); diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 8e4635460c35..725f0fda4bb0 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -8,6 +8,8 @@ use reqwest::header::HeaderValue; use serde::Serialize; use std::collections::HashMap; use std::fmt::Display; +use std::fs::OpenOptions; +use std::io::Write; use std::sync::LazyLock; use std::sync::Mutex; use std::sync::OnceLock; @@ -113,6 +115,13 @@ impl CodexRequestBuilder { self.map(|builder| builder.json(value)) } + pub fn form(self, form: &T) -> Self + where + T: Serialize, + { + self.map(|builder| builder.form(form)) + } + pub async fn send(self) -> Result { match self.builder.send().await { Ok(response) => { @@ -126,6 +135,9 @@ impl CodexRequestBuilder { "Request completed" ); + // Write request IDs to user-accessible file for troubleshooting + Self::write_request_ids_to_file(&request_ids, &self.method, &self.url).ok(); + Ok(response) } Err(error) => { @@ -143,15 +155,54 @@ impl CodexRequestBuilder { } fn extract_request_ids(response: &Response) -> HashMap { - ["cf-ray", "x-request-id", "x-oai-request-id"] - .iter() - .filter_map(|&name| { - let header_name = HeaderName::from_static(name); - let value = response.headers().get(header_name)?; - let value = value.to_str().ok()?.to_owned(); - Some((name.to_owned(), value)) - }) - .collect() + [ + "cf-ray", + "x-request-id", + "x-oai-request-id", + "opc-request-id", + ] + .iter() + .filter_map(|&name| { + let header_name = HeaderName::from_static(name); + let value = response.headers().get(header_name)?; + let value = value.to_str().ok()?.to_owned(); + Some((name.to_owned(), value)) + }) + .collect() + } + + fn write_request_ids_to_file( + request_ids: &HashMap, + method: &Method, + url: &str, + ) -> std::io::Result<()> { + // Skip if no request IDs were found + if request_ids.is_empty() { + return Ok(()); + } + + // Get codex home directory + let codex_home = crate::config::find_codex_home()?; + let mut request_ids_file = codex_home; + request_ids_file.push("request_ids.txt"); + + // Create or append to the file + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&request_ids_file)?; + + // Write a timestamped entry + let timestamp = chrono::Utc::now().to_rfc3339(); + writeln!(file, "[{timestamp}] {method} {url}")?; + + // Write each request ID + for (header_name, request_id) in request_ids { + writeln!(file, " {header_name}: {request_id}")?; + } + + writeln!(file)?; // Empty line for readability + Ok(()) } } #[derive(Debug, Clone)] diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 0901e426ad1e..8577a6802866 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -133,6 +133,9 @@ pub enum CodexErr { #[error("unsupported operation: {0}")] UnsupportedOperation(String), + #[error("Authentication expired. Please logout with /logout and then reopen codex-cli.")] + AuthenticationExpired, + #[error("Fatal error: {0}")] Fatal(String), diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 8dc252aa7c10..d39dcfdeb0c7 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -93,6 +93,11 @@ pub struct ModelProviderInfo { /// and API key (if needed) comes from the "env_key" environment variable. #[serde(default)] pub requires_openai_auth: bool, + + /// Does this provider require Oracle Code Assist auth? If true, the forced login method is + /// set to OCA and the user is taken through the login process on load + #[serde(default)] + pub requires_oracle_code_assist_auth: bool, } impl ModelProviderInfo { @@ -261,6 +266,45 @@ impl ModelProviderInfo { const DEFAULT_OLLAMA_PORT: u32 = 11434; pub const BUILT_IN_OSS_MODEL_PROVIDER_ID: &str = "oss"; +pub const BUILT_IN_OCA_MODEL_PROVIDER_ID: &str = "oca"; +pub const BUILT_IN_OCA_RESPONSES_MODEL_PROVIDER_ID: &str = "oca-responses"; + +pub const OCA_DEFAULT_BASE_URL: &str = + "https://code-internal.aiservice.us-chicago-1.oci.oraclecloud.com/20250206/app/litellm"; + +pub fn create_oca_provider(wire_api: WireApi) -> ModelProviderInfo { + ModelProviderInfo { + name: "Oracle Code Assist (oca)".into(), + // Allow users to override the default OCA endpoint by + // exporting `CODEX_CLI_OCA_BASE_URL`. This is useful when pointing + // Codex at a proxy, mock server, or different deployment + // without requiring a full TOML override for the built-in + // OCA provider. + base_url: std::env::var("CODEX_CLI_OCA_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()) + .or_else(|| Some(OCA_DEFAULT_BASE_URL.to_string())), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api, + query_params: None, + http_headers: Some( + [ + ("client".to_string(), "codex-cli".to_string()), + ("client-version".to_string(), "1".to_string()), + ] + .into_iter() + .collect(), + ), + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: false, + requires_oracle_code_assist_auth: true, + } +} /// Built-in default provider list. pub fn built_in_model_providers() -> HashMap { @@ -309,8 +353,17 @@ pub fn built_in_model_providers() -> HashMap { stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: true, + requires_oracle_code_assist_auth: false, }, ), + ( + BUILT_IN_OCA_MODEL_PROVIDER_ID, + create_oca_provider(WireApi::Chat), + ), + ( + BUILT_IN_OCA_RESPONSES_MODEL_PROVIDER_ID, + create_oca_provider(WireApi::Responses), + ), (BUILT_IN_OSS_MODEL_PROVIDER_ID, create_oss_provider()), ] .into_iter() @@ -354,6 +407,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str) -> ModelProviderInfo { stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_oracle_code_assist_auth: false, } } @@ -394,6 +448,7 @@ base_url = "http://localhost:11434/v1" stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -424,6 +479,7 @@ query_params = { api-version = "2025-04-01-preview" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -457,6 +513,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -480,6 +537,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_oracle_code_assist_auth: false, } } @@ -513,6 +571,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; assert!(named_provider.is_azure_responses_endpoint()); diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index a1e14bcdca1d..4cbbcdf2f8e0 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -59,6 +59,7 @@ async fn run_request(input: Vec) -> Value { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let codex_home = match TempDir::new() { diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index 33af9bcc7483..2672c9f011c9 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -59,6 +59,7 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let codex_home = match TempDir::new() { diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index f5887ebc5aef..19b248447965 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -47,6 +47,7 @@ async fn responses_stream_includes_task_type_header() { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let codex_home = TempDir::new().expect("failed to create TempDir"); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 02e9cdc36138..bd70f8cd49cb 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -644,6 +644,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let codex_home = TempDir::new().unwrap(); @@ -1135,6 +1136,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; // Init session @@ -1213,6 +1215,7 @@ async fn env_var_overrides_loaded_auth() { stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; // Init session diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index ba86f8c155c8..e49a6d092bfa 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -75,6 +75,7 @@ async fn continue_after_stream_error() { stream_max_retries: Some(1), stream_idle_timeout_ms: Some(2_000), requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let TestCodex { codex, .. } = test_codex() diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 550bb3f9c2f6..90b69c6ee1c1 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -83,6 +83,7 @@ async fn retries_on_early_close() { stream_max_retries: Some(1), stream_idle_timeout_ms: Some(2000), requires_openai_auth: false, + requires_oracle_code_assist_auth: false, }; let TestCodex { codex, .. } = test_codex() diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index acaf30ba0ca9..6f15781bead6 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -178,6 +178,7 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { let tokens = crate::server::exchange_code_for_tokens( base_url, + &opts.issuer_path_prefix, &opts.client_id, &redirect_uri, &pkce, @@ -200,6 +201,7 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { tokens.access_token, tokens.refresh_token, opts.cli_auth_credentials_store_mode, + opts.provider_name, ) .await } diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 999c19072e50..8da49e833df5 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -29,6 +29,8 @@ use tiny_http::Server; use tiny_http::StatusCode; const DEFAULT_ISSUER: &str = "https://auth.openai.com"; +const DEFAULT_REDIRECT_CALLBACK_PATH: &str = "/auth/callback"; +const DEFAULT_ISSUER_PATH_PREFIX: &str = "/oauth"; const DEFAULT_PORT: u16 = 1455; #[derive(Debug, Clone)] @@ -36,11 +38,14 @@ pub struct ServerOptions { pub codex_home: PathBuf, pub client_id: String, pub issuer: String, + pub issuer_path_prefix: String, + pub redirect_callback_path: String, pub port: u16, pub open_browser: bool, pub force_state: Option, pub forced_chatgpt_workspace_id: Option, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + pub provider_name: String, } impl ServerOptions { @@ -49,16 +54,20 @@ impl ServerOptions { client_id: String, forced_chatgpt_workspace_id: Option, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + provider_name: String, ) -> Self { Self { codex_home, client_id, issuer: DEFAULT_ISSUER.to_string(), + issuer_path_prefix: DEFAULT_ISSUER_PATH_PREFIX.to_string(), + redirect_callback_path: DEFAULT_REDIRECT_CALLBACK_PATH.to_string(), port: DEFAULT_PORT, open_browser: true, force_state: None, forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, + provider_name, } } } @@ -113,9 +122,13 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { }; let server = Arc::new(server); - let redirect_uri = format!("http://localhost:{actual_port}/auth/callback"); + let redirect_uri = format!( + "http://localhost:{actual_port}{0}", + opts.redirect_callback_path + ); let auth_url = build_authorize_url( &opts.issuer, + &opts.issuer_path_prefix, &opts.client_id, &redirect_uri, &pkce, @@ -182,7 +195,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await; None } - }; + }; if let Some(result) = exit_result { break result; @@ -236,7 +249,7 @@ async fn process_request( let path = parsed_url.path().to_string(); match path.as_str() { - "/auth/callback" => { + s if s == opts.redirect_callback_path.as_str() => { let params: std::collections::HashMap = parsed_url.query_pairs().into_owned().collect(); if params.get("state").map(String::as_str) != Some(state) { @@ -253,8 +266,15 @@ async fn process_request( } }; - match exchange_code_for_tokens(&opts.issuer, &opts.client_id, redirect_uri, pkce, &code) - .await + match exchange_code_for_tokens( + &opts.issuer, + &opts.issuer_path_prefix, + &opts.client_id, + redirect_uri, + pkce, + &code, + ) + .await { Ok(tokens) => { if let Err(message) = ensure_workspace_allowed( @@ -265,9 +285,14 @@ async fn process_request( return login_error_response(&message); } // Obtain API key via token-exchange and persist - let api_key = obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token) - .await - .ok(); + let api_key = obtain_api_key( + &opts.issuer, + &opts.issuer_path_prefix, + &opts.client_id, + &tokens.id_token, + ) + .await + .ok(); if let Err(err) = persist_tokens_async( &opts.codex_home, api_key.clone(), @@ -275,6 +300,7 @@ async fn process_request( tokens.access_token.clone(), tokens.refresh_token.clone(), opts.cli_auth_credentials_store_mode, + opts.provider_name.clone(), ) .await { @@ -379,6 +405,7 @@ fn send_response_with_disconnect( fn build_authorize_url( issuer: &str, + issuer_path_prefix: &str, client_id: &str, redirect_uri: &str, pkce: &PkceCodes, @@ -414,7 +441,7 @@ fn build_authorize_url( .map(|(k, v)| format!("{k}={}", urlencoding::encode(&v))) .collect::>() .join("&"); - format!("{issuer}/oauth/authorize?{qs}") + format!("{issuer}{issuer_path_prefix}/authorize?{qs}") } fn generate_state() -> String { @@ -493,6 +520,7 @@ pub(crate) struct ExchangedTokens { pub(crate) async fn exchange_code_for_tokens( issuer: &str, + issuer_path_prefix: &str, client_id: &str, redirect_uri: &str, pkce: &PkceCodes, @@ -507,7 +535,7 @@ pub(crate) async fn exchange_code_for_tokens( let client = reqwest::Client::new(); let resp = client - .post(format!("{issuer}/oauth/token")) + .post(format!("{issuer}{issuer_path_prefix}/token")) .header("Content-Type", "application/x-www-form-urlencoded") .body(format!( "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}", @@ -542,6 +570,7 @@ pub(crate) async fn persist_tokens_async( access_token: String, refresh_token: String, auth_credentials_store_mode: AuthCredentialsStoreMode, + provider_name: String, ) -> io::Result<()> { // Reuse existing synchronous logic but run it off the async runtime. let codex_home = codex_home.to_path_buf(); @@ -562,6 +591,7 @@ pub(crate) async fn persist_tokens_async( openai_api_key: api_key, tokens: Some(tokens), last_refresh: Some(Utc::now()), + last_auth_mode: Some(provider_name), }; save_auth(&codex_home, &auth, auth_credentials_store_mode) }) @@ -687,6 +717,7 @@ fn login_error_response(message: &str) -> HandledRequest { pub(crate) async fn obtain_api_key( issuer: &str, + issuer_path_prefix: &str, client_id: &str, id_token: &str, ) -> io::Result { @@ -697,7 +728,7 @@ pub(crate) async fn obtain_api_key( } let client = reqwest::Client::new(); let resp = client - .post(format!("{issuer}/oauth/token")) + .post(format!("{issuer}{issuer_path_prefix}/token")) .header("Content-Type", "application/x-www-form-urlencoded") .body(format!( "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}", diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index 266930e41902..afefee26169b 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -4,6 +4,7 @@ use anyhow::Context; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::CHATGPT_AUTH_MODE; use codex_core::auth::load_auth_dot_json; use codex_login::ServerOptions; use codex_login::run_device_code_login; @@ -107,6 +108,7 @@ fn server_opts( "client-id".to_string(), None, cli_auth_credentials_store_mode, + CHATGPT_AUTH_MODE.to_string(), ); opts.issuer = issuer; opts.open_browser = false; @@ -244,6 +246,7 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail "client-id".to_string(), None, AuthCredentialsStoreMode::File, + CHATGPT_AUTH_MODE.to_string(), ); opts.issuer = issuer; opts.open_browser = false; @@ -294,6 +297,7 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result "client-id".to_string(), None, AuthCredentialsStoreMode::File, + CHATGPT_AUTH_MODE.to_string(), ); opts.issuer = issuer; opts.open_browser = false; diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 73cb8bd420fa..7ffa2a9d9eb1 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -8,6 +8,7 @@ use std::time::Duration; use anyhow::Result; use base64::Engine; use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::CHATGPT_AUTH_MODE; use codex_login::ServerOptions; use codex_login::run_login_server; use core_test_support::skip_if_no_network; @@ -85,7 +86,8 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { let chatgpt_account_id = "12345678-0000-0000-0000-000000000000"; let (issuer_addr, issuer_handle) = start_mock_issuer(chatgpt_account_id); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); - + let issuer_path_prefix: String = "/oauth".to_string(); + let redirect_callback_path: String = "/auth/callback".to_string(); let tmp = tempdir()?; let codex_home = tmp.path().to_path_buf(); @@ -114,10 +116,13 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer, + issuer_path_prefix, + redirect_callback_path, port: 0, open_browser: false, force_state: Some(state), forced_chatgpt_workspace_id: Some(chatgpt_account_id.to_string()), + provider_name: CHATGPT_AUTH_MODE.to_string(), }; let server = run_login_server(opts)?; assert!( @@ -162,6 +167,8 @@ async fn creates_missing_codex_home_dir() -> Result<()> { let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); + let issuer_path_prefix: String = "/oauth".to_string(); + let redirect_callback_path: String = "/auth/callback".to_string(); let tmp = tempdir()?; let codex_home = tmp.path().join("missing-subdir"); // does not exist @@ -175,10 +182,13 @@ async fn creates_missing_codex_home_dir() -> Result<()> { cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer, + issuer_path_prefix, + redirect_callback_path, port: 0, open_browser: false, force_state: Some(state), forced_chatgpt_workspace_id: None, + provider_name: CHATGPT_AUTH_MODE.to_string(), }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -204,6 +214,8 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { let (issuer_addr, _issuer_handle) = start_mock_issuer("org-actual"); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); + let issuer_path_prefix: String = "/oauth".to_string(); + let redirect_callback_path: String = "/auth/callback".to_string(); let tmp = tempdir()?; let codex_home = tmp.path().to_path_buf(); @@ -214,10 +226,13 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer, + issuer_path_prefix, + redirect_callback_path, port: 0, open_browser: false, force_state: Some(state.clone()), forced_chatgpt_workspace_id: Some("org-required".to_string()), + provider_name: CHATGPT_AUTH_MODE.to_string(), }; let server = run_login_server(opts)?; assert!( @@ -261,6 +276,8 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); + let issuer_path_prefix: String = "/oauth".to_string(); + let redirect_callback_path: String = "/auth/callback".to_string(); let first_tmp = tempdir()?; let first_codex_home = first_tmp.path().to_path_buf(); @@ -270,10 +287,13 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer: issuer.clone(), + issuer_path_prefix: issuer_path_prefix.clone(), + redirect_callback_path: redirect_callback_path.clone(), port: 0, open_browser: false, force_state: Some("cancel_state".to_string()), forced_chatgpt_workspace_id: None, + provider_name: CHATGPT_AUTH_MODE.to_string(), }; let first_server = run_login_server(first_opts)?; @@ -290,10 +310,13 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer, + issuer_path_prefix, + redirect_callback_path, port: login_port, open_browser: false, force_state: Some("cancel_state_2".to_string()), forced_chatgpt_workspace_id: None, + provider_name: CHATGPT_AUTH_MODE.to_string(), }; let second_server = run_login_server(second_opts)?; diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 32950527bc57..b2e19a5d858d 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -83,5 +83,6 @@ pub enum SandboxMode { #[strum(serialize_all = "lowercase")] pub enum ForcedLoginMethod { Chatgpt, + OCA, Api, } diff --git a/codex-rs/scripts/create_github_release b/codex-rs/scripts/create_github_release index e121fc5c5ea3..daa7fcae1bf3 100755 --- a/codex-rs/scripts/create_github_release +++ b/codex-rs/scripts/create_github_release @@ -8,8 +8,8 @@ import subprocess import sys -REPO = "openai/codex" -BRANCH_REF = "heads/main" +REPO = "ChaiEranki/codex" +BRANCH_REF = "heads/oca-updates-cline" CARGO_TOML_PATH = "codex-rs/Cargo.toml" @@ -271,12 +271,18 @@ def determine_version(args: argparse.Namespace) -> str: def get_latest_release_version() -> str: - response = run_gh_api(f"/repos/{REPO}/releases/latest") - tag = response.get("tag_name") - version = strip_tag_prefix(tag) - if not version: - raise ReleaseError("Latest release tag has unexpected format.") - return version + try: + response = run_gh_api(f"/repos/{REPO}/releases/latest") + tag = response.get("tag_name") + version = strip_tag_prefix(tag) + if not version: + raise ReleaseError("Latest release tag has unexpected format.") + return version + except ReleaseError as e: + if "Not Found" in str(e) or "404" in str(e): + # No releases exist yet, start with 0.1.0 + return "0.1.0" + raise def list_releases() -> list[dict]: diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 9af54af7b056..e93cc64135d9 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -508,7 +508,7 @@ impl App { { self.handle_backtrack_esc_key(tui); } else { - self.chat_widget.handle_key_event(key_event); + self.chat_widget.handle_key_event(key_event).await; } } // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. @@ -533,7 +533,7 @@ impl App { if key_event.code != KeyCode::Esc && self.backtrack.primed { self.reset_backtrack_state(); } - self.chat_widget.handle_key_event(key_event); + self.chat_widget.handle_key_event(key_event).await; } _ => { // Ignore Release key events. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 229b51592465..4284abb53fc0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1102,7 +1102,7 @@ impl ChatWidget { .map_or(0, |c| c.desired_height(width) + 1) } - pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { + pub(crate) async fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { code: KeyCode::Char(c), @@ -1160,7 +1160,7 @@ impl ChatWidget { } } InputResult::Command(cmd) => { - self.dispatch_command(cmd); + self.dispatch_command(cmd).await; } InputResult::None => {} } @@ -1183,7 +1183,7 @@ impl ChatWidget { self.request_redraw(); } - fn dispatch_command(&mut self, cmd: SlashCommand) { + async fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( "'/{}' is disabled while a task is in progress.", @@ -1224,7 +1224,7 @@ impl ChatWidget { self.open_review_popup(); } SlashCommand::Model => { - self.open_model_popup(); + self.open_model_popup().await; } SlashCommand::Approvals => { self.open_approvals_popup(); @@ -1661,10 +1661,19 @@ impl ChatWidget { /// Open a popup to choose the model (stage 1). After selecting a model, /// a second popup is shown to choose the reasoning effort. - pub(crate) fn open_model_popup(&mut self) { + pub(crate) async fn open_model_popup(&mut self) { let current_model = self.config.model.clone(); let auth_mode = self.auth_manager.auth().map(|auth| auth.mode); - let presets: Vec = builtin_model_presets(auth_mode); + let base_url = self.config.model_provider.base_url.as_deref(); + let access_token = if let Some(auth) = self.auth_manager.auth() { + (auth.get_token().await).ok() + } else { + None + }; + let presets: Vec = + builtin_model_presets(auth_mode, base_url, access_token.as_deref()) + .await + .unwrap_or_default(); let mut items: Vec = Vec::new(); for preset in presets.into_iter() { @@ -1675,17 +1684,43 @@ impl ChatWidget { }; let is_current = preset.model == current_model; let preset_for_action = preset; - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::OpenReasoningPopup { - model: preset_for_action, - }); - })]; + let model_for_action = preset.model.to_string(); + + let actions: Vec = match preset.supported_reasoning_efforts.len() { + 0 => { + vec![Box::new(move |tx| { + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(model_for_action.clone()), + effort: None, + summary: None, + })); + tx.send(AppEvent::UpdateModel(model_for_action.clone())); + tx.send(AppEvent::UpdateReasoningEffort(None)); + tx.send(AppEvent::PersistModelSelection { + model: model_for_action.clone(), + effort: None, + }); + tracing::info!("Selected model: {}", model_for_action,); + })] + } + _ => { + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenReasoningPopup { + model: preset_for_action, + }); + })] + } + }; + items.push(SelectionItem { name: preset.display_name.to_string(), description, is_current, actions, - dismiss_on_select: false, + dismiss_on_select: preset.supported_reasoning_efforts.is_empty(), ..Default::default() }); } @@ -1701,7 +1736,8 @@ impl ChatWidget { /// Open a popup to choose the reasoning effort (stage 2) for the given model. pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { - let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; + let default_effort: ReasoningEffortConfig = + preset.default_reasoning_effort.unwrap_or_default(); let supported = preset.supported_reasoning_efforts; struct EffortChoice { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index eb53f90f77f5..2174ff4f5a96 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -5,6 +5,7 @@ use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; use codex_common::approval_presets::builtin_approval_presets; +use codex_common::model_presets::builtin_model_presets_sync; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::config::Config; @@ -392,8 +393,8 @@ fn test_rate_limit_warnings_monthly() { // (removed experimental resize snapshot test) -#[test] -fn exec_approval_emits_proposed_command_and_decision_history() { +#[tokio::test] +async fn exec_approval_emits_proposed_command_and_decision_history() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Trigger an exec approval request with a short, single-line command @@ -425,7 +426,8 @@ fn exec_approval_emits_proposed_command_and_decision_history() { assert_snapshot!("exec_approval_modal_exec", format!("{buf:?}")); // Approve via keyboard and verify a concise decision history line is added - chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)) + .await; let decision = drain_insert_history(&mut rx) .pop() .expect("expected decision cell in history"); @@ -435,8 +437,8 @@ fn exec_approval_emits_proposed_command_and_decision_history() { ); } -#[test] -fn exec_approval_decision_truncates_multiline_and_long_commands() { +#[tokio::test] +async fn exec_approval_decision_truncates_multiline_and_long_commands() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Multiline command: modal should show full command, history records decision only @@ -480,7 +482,8 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { ); // Deny via keyboard; decision snippet should be single-line and elided with " ..." - chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)) + .await; let aborted_multi = drain_insert_history(&mut rx) .pop() .expect("expected aborted decision cell (multiline)"); @@ -508,7 +511,8 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { proposed_long.is_empty(), "expected long approval request to avoid emitting history cells before decision" ); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)) + .await; let aborted_long = drain_insert_history(&mut rx) .pop() .expect("expected aborted decision cell (long)"); @@ -589,22 +593,23 @@ fn open_fixture(name: &str) -> File { File::open(name).expect("open fixture file") } -#[test] -fn empty_enter_during_task_does_not_queue() { +#[tokio::test] +async fn empty_enter_during_task_does_not_queue() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); // Simulate running task so submissions would normally be queued. chat.bottom_pane.set_task_running(true); // Press Enter with an empty composer. - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .await; // Ensure nothing was queued. assert!(chat.queued_user_messages.is_empty()); } -#[test] -fn alt_up_edits_most_recent_queued_message() { +#[tokio::test] +async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); // Simulate a running task so messages would normally be queued. @@ -618,7 +623,8 @@ fn alt_up_edits_most_recent_queued_message() { chat.refresh_queued_user_messages(); // Press Alt+Up to edit the most recent (last) queued message. - chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)) + .await; // Composer should now contain the last queued message. assert_eq!( @@ -636,24 +642,27 @@ fn alt_up_edits_most_recent_queued_message() { /// Pressing Up to recall the most recent history entry and immediately queuing /// it while a task is running should always enqueue the same text, even when it /// is queued repeatedly. -#[test] -fn enqueueing_history_prompt_multiple_times_is_stable() { +#[tokio::test] +async fn enqueueing_history_prompt_multiple_times_is_stable() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); // Submit an initial prompt to seed history. chat.bottom_pane.set_composer_text("repeat me".to_string()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .await; // Simulate an active task so further submissions are queued. chat.bottom_pane.set_task_running(true); for _ in 0..3 { // Recall the prompt from history and ensure it is what we expect. - chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) + .await; assert_eq!(chat.bottom_pane.composer_text(), "repeat me"); // Queue the prompt while the task is running. - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .await; } assert_eq!(chat.queued_user_messages.len(), 3); @@ -662,8 +671,8 @@ fn enqueueing_history_prompt_multiple_times_is_stable() { } } -#[test] -fn streaming_final_answer_keeps_task_running_state() { +#[tokio::test] +async fn streaming_final_answer_keeps_task_running_state() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); chat.on_task_started(); @@ -675,7 +684,8 @@ fn streaming_final_answer_keeps_task_running_state() { chat.bottom_pane .set_composer_text("queued submission".to_string()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .await; assert_eq!(chat.queued_user_messages.len(), 1); assert_eq!( @@ -684,7 +694,8 @@ fn streaming_final_answer_keeps_task_running_state() { ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)) + .await; match op_rx.try_recv() { Ok(Op::Interrupt) => {} other => panic!("expected Op::Interrupt, got {other:?}"), @@ -692,11 +703,12 @@ fn streaming_final_answer_keeps_task_running_state() { assert!(chat.bottom_pane.ctrl_c_quit_hint_visible()); } -#[test] -fn ctrl_c_shutdown_ignores_caps_lock() { +#[tokio::test] +async fn ctrl_c_shutdown_ignores_caps_lock() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)) + .await; match op_rx.try_recv() { Ok(Op::Shutdown) => {} @@ -704,8 +716,8 @@ fn ctrl_c_shutdown_ignores_caps_lock() { } } -#[test] -fn ctrl_c_cleared_prompt_is_recoverable_via_history() { +#[tokio::test] +async fn ctrl_c_cleared_prompt_is_recoverable_via_history() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); chat.bottom_pane.insert_str("draft message "); @@ -717,12 +729,14 @@ fn ctrl_c_cleared_prompt_is_recoverable_via_history() { "expected placeholder {placeholder:?} in composer text" ); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)) + .await; assert!(chat.bottom_pane.composer_text().is_empty()); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); assert!(chat.bottom_pane.ctrl_c_quit_hint_visible()); - chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) + .await; let restored_text = chat.bottom_pane.composer_text(); assert!( restored_text.ends_with(placeholder), @@ -794,19 +808,23 @@ fn exec_history_cell_shows_working_then_failed() { /// Selecting the custom prompt option from the review popup sends /// OpenReviewCustomPrompt to the app event channel. -#[test] -fn review_popup_custom_prompt_action_sends_event() { +#[tokio::test] +async fn review_popup_custom_prompt_action_sends_event() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Open the preset selection popup chat.open_review_popup(); // Move selection down to the fourth item: "Custom review instructions" - chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await; + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await; + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await; // Activate - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .await; // Drain events and ensure we saw the OpenReviewCustomPrompt request let mut found = false; @@ -819,15 +837,15 @@ fn review_popup_custom_prompt_action_sends_event() { assert!(found, "expected OpenReviewCustomPrompt event to be sent"); } -#[test] -fn slash_init_skips_when_project_doc_exists() { +#[tokio::test] +async fn slash_init_skips_when_project_doc_exists() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); let tempdir = tempdir().unwrap(); let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); std::fs::write(&existing_path, "existing instructions").unwrap(); chat.config.cwd = tempdir.path().to_path_buf(); - chat.dispatch_command(SlashCommand::Init); + chat.dispatch_command(SlashCommand::Init).await; match op_rx.try_recv() { Err(TryRecvError::Empty) => {} @@ -851,11 +869,11 @@ fn slash_init_skips_when_project_doc_exists() { ); } -#[test] -fn slash_undo_sends_op() { +#[tokio::test] +async fn slash_undo_sends_op() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); - chat.dispatch_command(SlashCommand::Undo); + chat.dispatch_command(SlashCommand::Undo).await; match rx.try_recv() { Ok(AppEvent::CodexOp(Op::Undo)) => {} @@ -1017,14 +1035,15 @@ fn review_commit_picker_shows_subjects_without_timestamps() { /// Submitting the custom prompt view sends Op::Review with the typed prompt /// and uses the same text for the user-facing hint. -#[test] -fn custom_prompt_submit_sends_review_op() { +#[tokio::test] +async fn custom_prompt_submit_sends_review_op() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); chat.show_review_custom_prompt(); // Paste prompt text via ChatWidget handler, then submit chat.handle_paste(" please audit dependencies ".to_string()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .await; // Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt let evt = rx.try_recv().expect("expected one app event"); @@ -1044,13 +1063,14 @@ fn custom_prompt_submit_sends_review_op() { } /// Hitting Enter on an empty custom prompt view does not submit. -#[test] -fn custom_prompt_enter_empty_does_not_send() { +#[tokio::test] +async fn custom_prompt_enter_empty_does_not_send() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); chat.show_review_custom_prompt(); // Enter without any text - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .await; // No AppEvent::CodexOp should be sent assert!(rx.try_recv().is_err(), "no app event should be sent"); @@ -1137,8 +1157,8 @@ fn interrupted_turn_error_message_snapshot() { /// Opening custom prompt from the review popup, pressing Esc returns to the /// parent popup, pressing Esc again dismisses all panels (back to normal mode). -#[test] -fn review_custom_prompt_escape_navigates_back_then_dismisses() { +#[tokio::test] +async fn review_custom_prompt_escape_navigates_back_then_dismisses() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); // Open the Review presets parent popup. @@ -1155,7 +1175,8 @@ fn review_custom_prompt_escape_navigates_back_then_dismisses() { ); // Esc once: child view closes, parent (review presets) remains. - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .await; let header = render_bottom_first_row(&chat, 60); assert!( header.contains("Select a review preset"), @@ -1163,7 +1184,8 @@ fn review_custom_prompt_escape_navigates_back_then_dismisses() { ); // Esc again: parent closes; back to normal composer state. - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .await; assert!( chat.is_normal_backtrack_mode(), "expected to be back in normal composer mode" @@ -1191,7 +1213,8 @@ async fn review_branch_picker_escape_navigates_back_then_dismisses() { ); // Esc once: child view closes, parent remains. - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .await; let header = render_bottom_first_row(&chat, 60); assert!( header.contains("Select a review preset"), @@ -1199,7 +1222,8 @@ async fn review_branch_picker_escape_navigates_back_then_dismisses() { ); // Esc again: parent closes; back to normal composer state. - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .await; assert!( chat.is_normal_backtrack_mode(), "expected to be back in normal composer mode" @@ -1259,12 +1283,12 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } -#[test] -fn model_selection_popup_snapshot() { +#[tokio::test] +async fn model_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); chat.config.model = "gpt-5-codex".to_string(); - chat.open_model_popup(); + chat.open_model_popup().await; let popup = render_bottom_popup(&chat, 80); assert_snapshot!("model_selection_popup", popup); @@ -1343,7 +1367,7 @@ fn model_reasoning_selection_popup_snapshot() { chat.config.model = "gpt-5-codex".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); - let preset = builtin_model_presets(None) + let preset = builtin_model_presets_sync(None) .into_iter() .find(|preset| preset.model == "gpt-5-codex") .expect("gpt-5-codex preset"); @@ -1353,12 +1377,12 @@ fn model_reasoning_selection_popup_snapshot() { assert_snapshot!("model_reasoning_selection_popup", popup); } -#[test] -fn feedback_selection_popup_snapshot() { +#[tokio::test] +async fn feedback_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); // Open the feedback category selection popup via slash command. - chat.dispatch_command(SlashCommand::Feedback); + chat.dispatch_command(SlashCommand::Feedback).await; let popup = render_bottom_popup(&chat, 80); assert_snapshot!("feedback_selection_popup", popup); @@ -1375,14 +1399,14 @@ fn feedback_upload_consent_popup_snapshot() { assert_snapshot!("feedback_upload_consent_popup", popup); } -#[test] -fn reasoning_popup_escape_returns_to_model_popup() { +#[tokio::test] +async fn reasoning_popup_escape_returns_to_model_popup() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); chat.config.model = "gpt-5".to_string(); - chat.open_model_popup(); + chat.open_model_popup().await; - let presets = builtin_model_presets(None) + let presets = builtin_model_presets_sync(None) .into_iter() .find(|preset| preset.model == "gpt-5-codex") .expect("gpt-5-codex preset"); @@ -1391,7 +1415,8 @@ fn reasoning_popup_escape_returns_to_model_popup() { let before_escape = render_bottom_popup(&chat, 80); assert!(before_escape.contains("Select Reasoning Level")); - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .await; let after_escape = render_bottom_popup(&chat, 80); assert!(after_escape.contains("Select Model and Effort")); @@ -1429,14 +1454,14 @@ fn exec_history_extends_previous_when_consecutive() { assert_snapshot!("exploring_step6_finish_cat_bar", active_blob(&chat)); } -#[test] -fn disabled_slash_command_while_task_running_snapshot() { +#[tokio::test] +async fn disabled_slash_command_while_task_running_snapshot() { // Build a chat widget and simulate an active task let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); chat.bottom_pane.set_task_running(true); // Dispatch a command that is unavailable while a task runs (e.g., /model) - chat.dispatch_command(SlashCommand::Model); + chat.dispatch_command(SlashCommand::Model).await; // Drain history and snapshot the rendered error line(s) let cells = drain_insert_history(&mut rx); @@ -2113,8 +2138,8 @@ fn apply_patch_manual_flow_snapshot() { ); } -#[test] -fn apply_patch_approval_sends_op_with_submission_id() { +#[tokio::test] +async fn apply_patch_approval_sends_op_with_submission_id() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Simulate receiving an approval request with a distinct submission id and call id let mut changes = HashMap::new(); @@ -2136,7 +2161,8 @@ fn apply_patch_approval_sends_op_with_submission_id() { }); // Approve via key press 'y' - chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)) + .await; // Expect a CodexOp with PatchApproval carrying the submission id, not call id let mut found = false; @@ -2151,8 +2177,8 @@ fn apply_patch_approval_sends_op_with_submission_id() { assert!(found, "expected PatchApproval op to be sent"); } -#[test] -fn apply_patch_full_flow_integration_like() { +#[tokio::test] +async fn apply_patch_full_flow_integration_like() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); // 1) Backend requests approval @@ -2172,7 +2198,8 @@ fn apply_patch_full_flow_integration_like() { }); // 2) User approves via 'y' and App receives a CodexOp - chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)) + .await; let mut maybe_op: Option = None; while let Ok(app_ev) = rx.try_recv() { if let AppEvent::CodexOp(op) = app_ev { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 028bf68e87f6..cf7d6cedfd66 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -15,7 +15,10 @@ use codex_core::RolloutRecorder; use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use codex_core::config_edit::ConfigEditsBuilder; use codex_core::find_conversation_path_by_id_str; +use codex_core::model_family::derive_default_model_family; +use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; @@ -81,6 +84,7 @@ pub mod test_backend; use crate::onboarding::TrustDirectorySelection; use crate::onboarding::WSL_INSTRUCTIONS; +use crate::onboarding::auth::perform_oca_login; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::tui::Tui; @@ -268,6 +272,54 @@ pub async fn run_main( .map_err(|err| std::io::Error::other(err.to_string())) } +async fn confirm_oca_model(config: &mut Config) { + if !config.model_provider.requires_oracle_code_assist_auth { + return; + } + + if let Ok(Some(auth)) = + CodexAuth::from_auth_storage(&config.codex_home, config.cli_auth_credentials_store_mode) + { + match codex_common::model_presets::fetch_oracle_code_assist_models( + config + .model_provider + .base_url + .clone() + .unwrap_or_default() + .as_ref(), + &auth.get_token().await.unwrap_or_default(), + ) + .await + { + Ok(models) => { + if !models.iter().any(|m| m.model == config.model) && !models.is_empty() { + let new_model = models[0].model.to_string(); + tracing::info!( + "Model {} not supported by OCA, switching to {}", + config.model, + new_model + ); + if let Err(e) = ConfigEditsBuilder::new(&config.codex_home) + .set_model(Some(&new_model), None) + .apply() + .await + { + tracing::error!("Failed to update config with new model: {e}"); + } else { + let model_family = find_family_for_model(&new_model) + .unwrap_or_else(|| derive_default_model_family(&new_model)); + config.model_family = model_family; + config.model = new_model; + } + } + } + Err(e) => { + tracing::error!("Failed to fetch OCA models: {e}"); + } + } + } +} + async fn run_ratatui_app( cli: Cli, initial_config: Config, @@ -330,8 +382,24 @@ async fn run_ratatui_app( should_show_trust_screen, should_show_windows_wsl_screen, ); + let should_show_oracle_code_assist_login = + should_show_oracle_code_assist_login(login_status, &initial_config); - let config = if should_show_onboarding { + if initial_config.model_provider.requires_openai_auth + && initial_config + .model_provider + .requires_oracle_code_assist_auth + { + restore(); + session_log::log_session_end(); + let _ = tui.terminal.clear(); + error!( + "Can't have both requires_openai_auth and requires_oracle_code_assist_auth selected" + ); + std::process::exit(1); + } + + let mut config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { show_login_screen: should_show_login_screen(login_status, &initial_config), @@ -372,6 +440,25 @@ async fn run_ratatui_app( initial_config }; + if should_show_oracle_code_assist_login + && let Err(e) = perform_oca_login( + auth_manager.clone(), + config.codex_home.clone(), + config.cli_auth_credentials_store_mode, + None, + ) + .await + { + restore(); + session_log::log_session_end(); + let _ = tui.terminal.clear(); + error!("OCA login failed: {e}"); + std::process::exit(1); + } + + // uncomment this if you want to check if the user actually has access to models they are requesting + confirm_oca_model(&mut config).await; + // Determine resume behavior: explicit id, then resume last, then picker. let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() { match find_conversation_path_by_id_str(&config.codex_home, id_str).await? { @@ -476,7 +563,9 @@ pub enum LoginStatus { } fn get_login_status(config: &Config) -> LoginStatus { - if config.model_provider.requires_openai_auth { + if config.model_provider.requires_openai_auth + || config.model_provider.requires_oracle_code_assist_auth + { // Reading the OpenAI API key is an async operation because it may need // to refresh the token. Block on it. let codex_home = config.codex_home.clone(); @@ -523,6 +612,13 @@ fn should_show_trust_screen(config: &Config) -> bool { !config.active_project.is_trusted() } +fn should_show_oracle_code_assist_login(login_status: LoginStatus, config: &Config) -> bool { + if !config.model_provider.requires_oracle_code_assist_auth { + return false; + } + !matches!(login_status, LoginStatus::AuthMode(AuthMode::OCA)) +} + fn should_show_onboarding( login_status: LoginStatus, config: &Config, diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 56527ac8ef81..2b5e9a8d9976 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -2,7 +2,11 @@ use codex_core::AuthManager; use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::CHATGPT_AUTH_MODE; use codex_core::auth::CLIENT_ID; +use codex_core::auth::OCA_CLIENT_ID; +use codex_core::auth::OCA_IDCS_URL; +use codex_core::auth::ORACLE_CODE_ASSIST_AUTH_MODE; use codex_core::auth::login_with_api_key; use codex_core::auth::read_openai_api_key_from_env; use codex_login::ServerOptions; @@ -75,6 +79,60 @@ impl Drop for ContinueInBrowserState { } } +pub async fn perform_oca_login( + auth_manager: Arc, + codex_home: PathBuf, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + forced_chatgpt_workspace_id: Option, +) -> Result<(), anyhow::Error> { + let opts = ServerOptions { + codex_home, + client_id: OCA_CLIENT_ID.to_string(), + issuer: OCA_IDCS_URL.to_string(), + issuer_path_prefix: "/oauth2/v1".to_string(), + redirect_callback_path: "/callback".to_string(), + port: 8669, + open_browser: true, + force_state: None, + forced_chatgpt_workspace_id, + cli_auth_credentials_store_mode, + provider_name: ORACLE_CODE_ASSIST_AUTH_MODE.to_string(), + }; + + let child = run_login_server(opts)?; + let r = child.block_until_done().await; + match r { + Ok(()) => { + auth_manager.reload(); + Ok(()) + } + Err(e) => Err(anyhow::anyhow!("OCA login failed: {}", e)), + } +} + +/// Automatically re-authenticate with Oracle Code Assist when authentication expires. +/// This function can be called when AuthenticationExpired errors are detected. +pub async fn perform_oca_reauthentication( + auth_manager: Arc, + codex_home: PathBuf, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + forced_chatgpt_workspace_id: Option, +) -> Result<(), anyhow::Error> { + tracing::info!("Performing automatic OCA reauthentication due to expired credentials"); + + // Force logout first to clear any stale tokens + let _ = auth_manager.logout(); + + // Then perform fresh login + perform_oca_login( + auth_manager, + codex_home, + cli_auth_credentials_store_mode, + forced_chatgpt_workspace_id, + ) + .await +} + impl KeyboardHandler for AuthModeWidget { fn handle_key_event(&mut self, key_event: KeyEvent) { if self.handle_api_key_entry_key_event(&key_event) { @@ -104,6 +162,7 @@ impl KeyboardHandler for AuthModeWidget { self.disallow_api_login(); } } + KeyCode::Enter => { let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; match sign_in_state { @@ -111,10 +170,13 @@ impl KeyboardHandler for AuthModeWidget { AuthMode::ChatGPT if self.is_chatgpt_login_allowed() => { self.start_chatgpt_login(); } + AuthMode::OCA if self.is_oca_login_allowed() => { + self.start_oca_login(); + } AuthMode::ApiKey if self.is_api_login_allowed() => { self.start_api_key_entry(); } - AuthMode::ChatGPT => {} + AuthMode::ChatGPT | AuthMode::OCA => {} AuthMode::ApiKey => { self.disallow_api_login(); } @@ -159,12 +221,17 @@ pub(crate) struct AuthModeWidget { impl AuthModeWidget { fn is_api_login_allowed(&self) -> bool { !matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) + || !matches!(self.forced_login_method, Some(ForcedLoginMethod::OCA)) } fn is_chatgpt_login_allowed(&self) -> bool { !matches!(self.forced_login_method, Some(ForcedLoginMethod::Api)) } + fn is_oca_login_allowed(&self) -> bool { + !matches!(self.forced_login_method, Some(ForcedLoginMethod::Api)) + } + fn disallow_api_login(&mut self) { self.highlighted_mode = AuthMode::ChatGPT; self.error = Some(API_KEY_DISABLED_MESSAGE.to_string()); @@ -560,6 +627,7 @@ impl AuthModeWidget { CLIENT_ID.to_string(), self.forced_chatgpt_workspace_id.clone(), self.cli_auth_credentials_store_mode, + CHATGPT_AUTH_MODE.to_string(), ); match run_login_server(opts) { Ok(child) => { @@ -600,6 +668,79 @@ impl AuthModeWidget { } } } + + pub fn start_oca_login(&mut self) { + // If we're already authenticated with OCA, don't start a new login – + // just proceed to the success message flow. + if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::OCA)) { + *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; + self.request_frame.schedule_frame(); + return; + } + // https://idcs-9dc693e80d9b469480d7afe00e743931.identity.oraclecloud.com/oauth/authorize? + // response_type=code& + // client_id=a8331954c0cf48ba99b5dd223a14c6ea& + // redirect_uri=http%3A%2F%2Flocalhost%3A8669%2Fauth%2Fcallback& + // scope=openid%20profile%20email%20offline_access& + // code_challenge=po_49S6hmoDvKlAXaiMWehj5TUV3_99wPcd2bdnGszw&c + // ode_challenge_method=S256& + // d_token_add_organizations=true& + // codex_cli_simplified_flow=true& + // state=N1rU1jzM6Hwm_z9ESVoZtIiu4nlNtifywDcE1a1K0YQ& + // originator=codex_cli_rs + self.error = None; + let opts = ServerOptions { + codex_home: self.codex_home.clone(), + client_id: OCA_CLIENT_ID.to_string(), + issuer: OCA_IDCS_URL.to_string(), + issuer_path_prefix: "/oauth2/v1".to_string(), + redirect_callback_path: "/callback".to_string(), + port: 8669, + open_browser: true, + force_state: None, + forced_chatgpt_workspace_id: self.forced_chatgpt_workspace_id.clone(), + cli_auth_credentials_store_mode: self.cli_auth_credentials_store_mode, + provider_name: ORACLE_CODE_ASSIST_AUTH_MODE.to_string(), + }; + match run_login_server(opts) { + Ok(child) => { + let sign_in_state = self.sign_in_state.clone(); + let request_frame = self.request_frame.clone(); + let auth_manager = self.auth_manager.clone(); + tokio::spawn(async move { + let auth_url = child.auth_url.clone(); + { + *sign_in_state.write().unwrap() = + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + auth_url, + shutdown_flag: Some(child.cancel_handle()), + }); + } + request_frame.schedule_frame(); + let r = child.block_until_done().await; + match r { + Ok(()) => { + // Force the auth manager to reload the new auth information. + auth_manager.reload(); + + *sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage; + request_frame.schedule_frame(); + } + _ => { + *sign_in_state.write().unwrap() = SignInState::PickMode; + // self.error = Some(e.to_string()); + request_frame.schedule_frame(); + } + } + }); + } + Err(e) => { + *self.sign_in_state.write().unwrap() = SignInState::PickMode; + self.error = Some(e.to_string()); + self.request_frame.schedule_frame(); + } + } + } } impl StepStateProvider for AuthModeWidget { diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index 6c420dae53f0..c9f735ef550e 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -1,4 +1,4 @@ -mod auth; +pub mod auth; pub mod onboarding_screen; mod trust_directory; pub use trust_directory::TrustDirectorySelection; diff --git a/scripts/build_codex_all_platforms.sh b/scripts/build_codex_all_platforms.sh new file mode 100755 index 000000000000..2d7d1c4c2f42 --- /dev/null +++ b/scripts/build_codex_all_platforms.sh @@ -0,0 +1,432 @@ +#!/bin/bash + +# Script to build codex binaries for all platforms with macOS notarization support. +# Mimics the GitHub workflow rust-release.yml for local builds with enhanced macOS support. + +set -euo pipefail + +# Directory of this script +SCRIPT_DIR=$(dirname "$0") +REPO_ROOT=$(realpath "$SCRIPT_DIR/..") +CODEX_RS_DIR="$REPO_ROOT/codex-rs" +DIST_DIR="$REPO_ROOT/dist/binaries" + +echo "$SCRIPT_DIR" + +# macOS notarization configuration +APPLE_CERTIFICATE_P12="${APPLE_CERTIFICATE_P12:-}" +APPLE_CERTIFICATE_PASSWORD="${APPLE_CERTIFICATE_PASSWORD:-}" +APPLE_NOTARIZATION_KEY_P8="${APPLE_NOTARIZATION_KEY_P8:-}" +APPLE_NOTARIZATION_KEY_ID="${APPLE_NOTARIZATION_KEY_ID:-}" +APPLE_NOTARIZATION_ISSUER_ID="${APPLE_NOTARIZATION_ISSUER_ID:-}" + +# Ensure we're in the right place +echo "Building in $CODEX_RS_DIR" +cd "$CODEX_RS_DIR" + +# Check if we're on macOS for notarization +IS_MACOS=false +if [[ "$OSTYPE" == "darwin"* ]]; then + IS_MACOS=true + echo "Detected macOS - will attempt notarization if credentials are provided" +fi + +# Install specific Rust toolchain (matching CI) +echo "Installing Rust 1.80 toolchain (matching CI)..." +rustup toolchain install 1.80 --profile minimal +rustup default 1.80 + +# Install rustup targets (matching CI workflow) +echo "Ensuring all rustup targets are installed..." +rustup target add aarch64-apple-darwin +rustup target add x86_64-apple-darwin +rustup target add x86_64-unknown-linux-musl +rustup target add x86_64-unknown-linux-gnu +rustup target add aarch64-unknown-linux-musl +rustup target add aarch64-unknown-linux-gnu +rustup target add x86_64-pc-windows-msvc +rustup target add aarch64-pc-windows-msvc + +# Install cross for cross-compilation (better than manual Docker) +if ! command -v cross &> /dev/null; then + echo "Installing cross for cross-compilation..." + cargo install cross +else + echo "cross is already installed" +fi + +# Note about cross-compilation builds +if [[ "$IS_MACOS" == true ]]; then + echo "Note: Cross-compilation targets will use Docker containers with proper toolchains." + echo "Make sure Docker is running for non-macOS builds." +fi + +# Define targets from the workflow +TARGETS=( + "aarch64-apple-darwin" + "x86_64-apple-darwin" + "x86_64-unknown-linux-musl" + "x86_64-unknown-linux-gnu" + "aarch64-unknown-linux-musl" + "aarch64-unknown-linux-gnu" + "x86_64-pc-windows-msvc" + "aarch64-pc-windows-msvc" +) + +BINS=( + "codex" + "codex-responses-api-proxy" +) + +# Create dist directory +mkdir -p "$DIST_DIR" + +# Function to setup macOS code signing +setup_macos_signing() { + if [[ -z "$APPLE_CERTIFICATE_P12" || -z "$APPLE_CERTIFICATE_PASSWORD" ]]; then + echo "Apple certificate credentials not provided. Skipping macOS signing." + echo "To enable signing, set APPLE_CERTIFICATE_P12 and APPLE_CERTIFICATE_PASSWORD environment variables." + return 1 + fi + + echo "Setting up macOS code signing..." + + local cert_path="${RUNNER_TEMP:-/tmp}/apple_signing_certificate.p12" + local keychain_path="${RUNNER_TEMP:-/tmp}/codex-signing.keychain-db" + local keychain_password="actions" + + # Decode certificate + echo "$APPLE_CERTIFICATE_P12" | base64 -d > "$cert_path" + + # Create temporary keychain + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + + # Backup existing keychains + local keychain_args=() + while IFS= read -r keychain; do + [[ -n "$keychain" ]] && keychain_args+=("$keychain") + done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g') + + # Add our keychain to the list + if ((${#keychain_args[@]} > 0)); then + security list-keychains -s "$keychain_path" "${keychain_args[@]}" + else + security list-keychains -s "$keychain_path" + fi + + security default-keychain -s "$keychain_path" + + # Import certificate + security import "$cert_path" -k "$keychain_path" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain_path" > /dev/null + + # Find signing identity + local codesign_hashes=() + while IFS= read -r hash; do + [[ -n "$hash" ]] && codesign_hashes+=("$hash") + done < <(security find-identity -v -p codesigning "$keychain_path" \ + | sed -n 's/.*\([0-9A-F]\{40\}\).*/\1/p' \ + | sort -u) + + if ((${#codesign_hashes[@]} == 0)); then + echo "No signing identities found" + cleanup_macos_signing + return 1 + fi + + if ((${#codesign_hashes[@]} > 1)); then + echo "Multiple signing identities found:" + printf ' %s\n' "${codesign_hashes[@]}" + cleanup_macos_signing + return 1 + fi + + APPLE_CODESIGN_IDENTITY="${codesign_hashes[0]}" + APPLE_CODESIGN_KEYCHAIN="$keychain_path" + + # Cleanup cert file + rm -f "$cert_path" + + echo "macOS signing setup complete" + return 0 +} + +# Function to cleanup macOS signing keychain +cleanup_macos_signing() { + if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" && -f "$APPLE_CODESIGN_KEYCHAIN" ]]; then + echo "Cleaning up macOS signing keychain..." + + # Remove our keychain from the list + local keychain_args=() + while IFS= read -r keychain; do + [[ "$keychain" == "$APPLE_CODESIGN_KEYCHAIN" ]] && continue + [[ -n "$keychain" ]] && keychain_args+=("$keychain") + done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g') + + if ((${#keychain_args[@]} > 0)); then + security list-keychains -s "${keychain_args[@]}" + security default-keychain -s "${keychain_args[0]}" + fi + + security delete-keychain "$APPLE_CODESIGN_KEYCHAIN" + fi +} + +# Function to sign macOS binaries +sign_macos_binary() { + local binary_path="$1" + + if [[ -z "${APPLE_CODESIGN_IDENTITY:-}" ]]; then + echo "No signing identity available for $binary_path" + return 1 + fi + + echo "Signing $binary_path..." + local keychain_args=() + if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" && -f "$APPLE_CODESIGN_KEYCHAIN" ]]; then + keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}") + fi + + codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$binary_path" +} + +# Function to notarize macOS binary +notarize_macos_binary() { + local binary_path="$1" + local binary_name="$2" + + if [[ -z "$APPLE_NOTARIZATION_KEY_P8" || -z "$APPLE_NOTARIZATION_KEY_ID" || -z "$APPLE_NOTARIZATION_ISSUER_ID" ]]; then + echo "Apple notarization credentials not provided. Skipping notarization for $binary_name." + echo "To enable notarization, set APPLE_NOTARIZATION_KEY_P8, APPLE_NOTARIZATION_KEY_ID, and APPLE_NOTARIZATION_ISSUER_ID environment variables." + return 0 + fi + + echo "Notarizing $binary_name..." + + local notary_key_path="${RUNNER_TEMP:-/tmp}/notarytool.key.p8" + echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$notary_key_path" + + local archive_path="${RUNNER_TEMP:-/tmp}/${binary_name}.zip" + rm -f "$archive_path" + + # Create zip archive + ditto -c -k --keepParent "$binary_path" "$archive_path" + + # Submit for notarization + local submission_json + submission_json=$(xcrun notarytool submit "$archive_path" \ + --key "$notary_key_path" \ + --key-id "$APPLE_NOTARIZATION_KEY_ID" \ + --issuer "$APPLE_NOTARIZATION_ISSUER_ID" \ + --output-format json \ + --wait) + + local status + status=$(printf '%s\n' "$submission_json" | jq -r '.status // "Unknown"') + local submission_id + submission_id=$(printf '%s\n' "$submission_json" | jq -r '.id // ""') + + echo "Notarization submission $submission_id completed with status $status" + + if [[ "$status" != "Accepted" ]]; then + echo "Notarization failed for ${binary_name} (submission ${submission_id}, status ${status})" + rm -f "$notary_key_path" "$archive_path" + return 1 + fi + + # Staple the notarization ticket + echo "Stapling notarization ticket to $binary_path..." + xcrun stapler staple "$binary_path" + + rm -f "$notary_key_path" "$archive_path" +} + +# Setup macOS signing if on macOS +SIGNING_ENABLED=false +if [[ "$IS_MACOS" == true ]]; then + if setup_macos_signing; then + SIGNING_ENABLED=true + trap cleanup_macos_signing EXIT + fi +fi + +# Function to check if Docker is available +check_docker() { + if command -v docker &> /dev/null && docker info &> /dev/null; then + return 0 + else + return 1 + fi +} + +# Function to build targets using Docker directly +build_with_docker() { + local target="$1" + echo "Building $target using Docker..." + + if ! check_docker; then + echo "❌ Docker is required for cross builds but not available" + return 1 + fi + + # Use Docker directly with a Rust container that has the right toolchain + local docker_image="rust:1.80-slim" + + # Configure build environment based on target + local build_env="" + if [[ "$target" == *musl* ]]; then + # For MUSL targets, configure ring crate to avoid compiler issues + build_env="CC=musl-gcc RUSTFLAGS='-C target-feature=-crt-static' RING_PREGENERATE_ASM=1" + elif [[ "$target" == *linux-gnu* ]]; then + # For GNU Linux targets, use cross compiler and configure OpenSSL + if [[ "$target" == aarch64* ]]; then + build_env="CC=aarch64-linux-gnu-gcc OPENSSL_DIR=/usr OPENSSL_NO_PKG_CONFIG=1 PKG_CONFIG_ALLOW_CROSS=1" + else + build_env="CC=x86_64-linux-gnu-gcc OPENSSL_DIR=/usr OPENSSL_NO_PKG_CONFIG=1 PKG_CONFIG_ALLOW_CROSS=1" + fi + fi + + # Run build in container + if docker run --rm -v "$REPO_ROOT:/workspace" -w /workspace/codex-rs \ + -e CARGO_HOME=/workspace/.cargo \ + "$docker_image" \ + bash -c " + apt-get update && apt-get install -y \ + build-essential pkg-config musl-tools musl-dev \ + gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \ + gcc-x86-64-linux-gnu g++-x86-64-linux-gnu \ + clang llvm libssl-dev zlib1g-dev && \ + rustup target add $target && \ + $build_env cargo build --target $target --release --bin codex --bin codex-responses-api-proxy + "; then + + # Copy binaries to dist directory + for bin in "${BINS[@]}"; do + src="$CODEX_RS_DIR/target/$target/release/$bin" + if [[ "$target" == *windows* ]]; then + src="$src.exe" + fi + dest="$DIST_DIR/$bin-$target" + if [[ "$target" == *windows* ]]; then + dest="$dest.exe" + fi + + if [ -f "$src" ]; then + cp "$src" "$dest" + echo "✅ Copied $bin for $target" + else + echo "⚠️ Binary $src not found for $target" + fi + done + return 0 + else + echo "❌ Failed to build $target with Docker" + return 1 + fi +} + +# Categorize targets by build method +NATIVE_TARGETS=() +DOCKER_TARGETS=() +SKIP_MUSL="${SKIP_MUSL:-true}" # Skip MUSL builds by default due to ring crate issues + +for target in "${TARGETS[@]}"; do + case "$target" in + *apple-darwin*) + # macOS targets - build natively on macOS + if [[ "$IS_MACOS" == true ]]; then + NATIVE_TARGETS+=("$target") + else + echo "⚠️ Skipping $target (requires macOS host)" + fi + ;; + *pc-windows*) + # Windows targets - cross-compilation from macOS often fails due to missing headers + if [[ "$IS_MACOS" == true ]]; then + echo "⚠️ Skipping $target (Windows cross-compilation from macOS requires complex setup)" + DOCKER_TARGETS+=("$target") + else + NATIVE_TARGETS+=("$target") + fi + ;; + *unknown-linux-musl*) + # MUSL targets - problematic with ring crate in Docker + if [[ "$SKIP_MUSL" == "true" ]]; then + echo "⚠️ Skipping $target (MUSL builds have ring crate compatibility issues)" + echo " Set SKIP_MUSL=false to attempt MUSL builds anyway" + else + DOCKER_TARGETS+=("$target") + fi + ;; + *unknown-linux-gnu*) + # GNU Linux targets - generally work well + DOCKER_TARGETS+=("$target") + ;; + esac +done + +echo "📋 Build Plan:" +echo " Native builds: ${NATIVE_TARGETS[*]:-none}" +echo " Docker builds: ${DOCKER_TARGETS[*]:-none}" +echo "" + +# Build native targets first +if ((${#NATIVE_TARGETS[@]} > 0)); then + echo "🏗️ Building native targets: ${NATIVE_TARGETS[*]}" + for target in "${NATIVE_TARGETS[@]}"; do + echo "Building for $target..." + + if ! cargo build --target "$target" --release --bin codex --bin codex-responses-api-proxy; then + echo "❌ Failed to build for $target" + continue + fi + + # Process binaries + for bin in "${BINS[@]}"; do + src="$CODEX_RS_DIR/target/$target/release/$bin" + if [[ "$target" == *windows* ]]; then + src="$src.exe" + fi + dest="$DIST_DIR/$bin-$target" + if [[ "$target" == *windows* ]]; then + dest="$dest.exe" + fi + + cp "$src" "$dest" + echo "✅ Copied $bin for $target to $dest" + + # Handle macOS signing and notarization + if [[ "$target" == *apple-darwin* && "$SIGNING_ENABLED" == true ]]; then + if sign_macos_binary "$dest"; then + notarize_macos_binary "$dest" "$bin-$target" + fi + fi + done + done +fi + +# Build cross targets +if ((${#DOCKER_TARGETS[@]} > 0)); then + echo "" + echo "🔄 Building cross-compilation targets: ${DOCKER_TARGETS[*]}" + for target in "${DOCKER_TARGETS[@]}"; do + build_with_docker "$target" + done +fi + +cleanup_macos_signing + +echo "All binaries built and copied to $DIST_DIR" +echo "" +echo "Build complete!" +echo "" +echo "To set up macOS signing and notarization for future builds, set these environment variables:" +echo " export APPLE_CERTIFICATE_P12='base64-encoded-p12-certificate'" +echo " export APPLE_CERTIFICATE_PASSWORD='certificate-password'" +echo " export APPLE_NOTARIZATION_KEY_P8='base64-encoded-notarization-key'" +echo " export APPLE_NOTARIZATION_KEY_ID='notarization-key-id'" +echo " export APPLE_NOTARIZATION_ISSUER_ID='notarization-issuer-id'" +echo "" +echo "These credentials can be obtained from your Apple Developer account."