diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index 216db74b00..5b556bfdc6 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -771,7 +771,7 @@ async fn managed_auth_policy_survives_unusable_requirements_file_changes() -> Re let auth_manager = codex_login::AuthManager::shared_from_config( &startup, /*enable_codex_api_key_env*/ false, ) - .await; + .await?; std::fs::write( &requirements_path, "allowed_login_methods = [\"chatgpt\"]\nallowed_chatgpt_workspaces = []\n", diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 2492eafeda..1438e59b48 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -406,14 +406,15 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult(channel_capacity); let (event_tx, event_rx) = mpsc::channel::(channel_capacity); let runtime_handle = tokio::spawn(async move { let (outgoing_tx, outgoing_rx) = mpsc::channel::(channel_capacity); - let auth_manager = - AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env) - .await; let analytics_events_client = analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref()); let analytics_events_flush_client = analytics_events_client.clone(); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 934e00ee0a..0588148302 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -506,7 +506,9 @@ pub async fn run_main_with_transport_options( config_manager .replace_thread_config_loader(Arc::clone(&discovered_thread_config_loader)); let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false) + .await + .map_err(std::io::Error::other)?; config_manager.replace_cloud_config_bundle_loader( auth_manager, config.chatgpt_base_url.clone(), @@ -760,7 +762,9 @@ pub async fn run_main_with_transport_options( drop(unix_socket_startup_lock); let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false) + .await + .map_err(std::io::Error::other)?; let remote_control_enabled = remote_control_policy == RemoteControlPolicy::Allowed && remote_control_explicitly_requested diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 00a5383602..cd9d22d426 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -234,7 +234,9 @@ async fn build_test_processor( ) { let (outgoing_tx, outgoing_rx) = mpsc::channel(16); let auth_manager = - AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false) + .await + .expect("test auth manager"); let config_manager = ConfigManager::new( config.codex_home.to_path_buf(), Vec::new(), diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index 7beab01267..2e6feb8256 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -66,7 +66,7 @@ pub(crate) async fn chatgpt_get_request_with_timeout( ) -> anyhow::Result { let chatgpt_base_url = &config.chatgpt_base_url; let auth_manager = - AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await?; let auth = auth_manager .auth() .await diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 561889e780..ae6cbce5c8 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -32,18 +32,18 @@ const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); const CONNECTOR_METADATA_TIMEOUT: Duration = Duration::from_secs(60); const DEFAULT_APPS_PRODUCT_SKU: &str = "codex"; -async fn apps_enabled(config: &Config) -> bool { +async fn apps_enabled(config: &Config) -> anyhow::Result { let auth_manager = - AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await?; let auth = auth_manager.auth().await; - config + Ok(config .features - .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)) + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))) } async fn connector_auth(config: &Config) -> anyhow::Result { let auth_manager = - AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await?; let auth = auth_manager .auth() .await @@ -56,7 +56,7 @@ async fn connector_auth(config: &Config) -> anyhow::Result { } pub async fn list_connectors(config: &Config) -> anyhow::Result> { - if !apps_enabled(config).await { + if !apps_enabled(config).await? { return Ok(Vec::new()); } let (connectors_result, accessible_result) = tokio::join!( @@ -82,7 +82,7 @@ pub async fn list_cached_all_connectors( config: &Config, plugin_apps: &[AppConnectorId], ) -> Option> { - if !apps_enabled(config).await { + if !apps_enabled(config).await.ok()? { return Some(Vec::new()); } @@ -100,7 +100,7 @@ pub async fn list_all_connectors_with_options( force_refetch: bool, plugin_apps: &[AppConnectorId], ) -> anyhow::Result> { - if !apps_enabled(config).await { + if !apps_enabled(config).await? { return Ok(Vec::new()); } let auth = connector_auth(config).await?; diff --git a/codex-rs/cli/src/debug_sandbox/cloud_config.rs b/codex-rs/cli/src/debug_sandbox/cloud_config.rs index d7afa0aa10..952e6d9497 100644 --- a/codex-rs/cli/src/debug_sandbox/cloud_config.rs +++ b/codex-rs/cli/src/debug_sandbox/cloud_config.rs @@ -44,7 +44,7 @@ pub(super) async fn bootstrap_cloud_config_bundle( bootstrap_auth_config(codex_home.as_path(), &bootstrap_config)?, /*enable_codex_api_key_env*/ false, ) - .await) + .await?) } #[cfg(test)] diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 8dd0792f3d..6a7d8a8d5d 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -350,8 +350,9 @@ async fn build_report( let config_result = load_config(root_config_overrides, interactive, arg0_paths).await; match &config_result { Ok(config) => { - let auth_manager = + let auth_manager_result = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await; + let auth_manager = auth_manager_result.as_ref().ok().cloned(); let reachability_plan = provider_reachability_plan(config); let ( config_check, @@ -370,13 +371,27 @@ async fn build_report( reachability_check, ) = tokio::join!( async { run_sync_check("config", progress.clone(), || config_check(config)) }, - async { run_sync_check("auth", progress.clone(), || auth_check(config)) }, + async { + run_sync_check("auth", progress.clone(), || match &auth_manager_result { + Ok(_) => auth_check(config), + Err(error) => DoctorCheck::new( + "auth.load", + "auth", + CheckStatus::Fail, + "authentication could not be initialized", + ) + .detail(error.to_string()) + .remediation( + "Fix the reported authentication error, then rerun codex doctor.", + ), + }) + }, async { run_sync_check("updates", progress.clone(), || updates_check(config)) }, async { run_sync_check("network", progress.clone(), network_check) }, run_async_check( "websocket", progress.clone(), - websocket_reachability_check(config, Some(auth_manager)), + websocket_reachability_check(config, auth_manager), ), run_async_check("MCP", progress.clone(), mcp_check(config)), async { diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 142b1c8a03..5456d1a04c 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -10,9 +10,11 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::Config; use codex_login::AuthKeyringBackendKind; +use codex_login::AuthManager; use codex_login::AuthRouteConfig; use codex_login::CLIENT_ID; use codex_login::ServerOptions; +use codex_login::is_workload_identity_selected; use codex_login::login_with_access_token; use codex_login::login_with_api_key; use codex_login::logout_with_revoke; @@ -439,8 +441,21 @@ pub async fn run_login_with_device_code_fallback_to_browser( pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match config - .auth_config() + if is_workload_identity_selected() { + match AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await { + Ok(_) => { + eprintln!("Logged in using workload identity"); + std::process::exit(0); + } + Err(err) => { + eprintln!("Error checking login status: {err}"); + std::process::exit(1); + } + } + } + + let auth_config = config.auth_config(); + match auth_config .load_auth(/*enable_codex_api_key_env*/ false) .await { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index b1532f1fd2..75675efecc 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1826,7 +1826,7 @@ async fn load_exec_server_remote_auth_provider( anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set") })?; let auth = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false) - .await + .await? .auth() .await .ok_or_else(|| anyhow::anyhow!("Agent Identity authentication is unavailable"))?; @@ -1914,7 +1914,7 @@ async fn load_exec_server_remote_auth( missing_auth_error: &'static str, ) -> anyhow::Result { let auth_manager = - AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await; + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await?; let auth = match auth_manager.auth().await { Some(auth) => auth, @@ -2076,7 +2076,7 @@ async fn run_debug_prompt_input_command( config.codex_home.clone(), )); let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await?; let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new(); codex_git_attribution::install( &mut extensions, @@ -2122,7 +2122,7 @@ async fn run_debug_models_command( .build() .await?; let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await; + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await?; let models_manager = build_models_manager(&config, auth_manager); models_manager .raw_model_catalog( diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index 4a9609649b..d319e9af93 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -212,7 +212,7 @@ async fn run_list(overrides: Vec<(String, toml::Value)>, args: ListMarketplaceAr .await .context("failed to load configuration")?; let manager = plugins_manager_for_config(&config); - manager.set_auth_mode(load_cli_auth_mode(&config).await); + manager.set_auth_mode(load_cli_auth_mode(&config).await?); let plugins_input = config.plugins_config_input(); let marketplace_listing = manager .discover_marketplaces_for_config(&plugins_input, &[]) diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 6a6e6f76cc..e434a0a9d1 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -523,14 +523,14 @@ async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveAr Ok(()) } -async fn load_mcp_manager(config: &Config) -> McpManager { +async fn load_mcp_manager(config: &Config) -> Result { let plugins_manager = Arc::new(plugins_manager_for_config(config)); - plugins_manager.set_auth_mode(load_cli_auth_mode(config).await); - McpManager::new(plugins_manager) + plugins_manager.set_auth_mode(load_cli_auth_mode(config).await?); + Ok(McpManager::new(plugins_manager)) } async fn run_login(config: &Config, login_args: LoginArgs) -> Result<()> { - let mcp_manager = load_mcp_manager(config).await; + let mcp_manager = load_mcp_manager(config).await?; let mcp_servers = mcp_manager.configured_servers(config).await; let LoginArgs { @@ -599,7 +599,7 @@ async fn run_login(config: &Config, login_args: LoginArgs) -> Result<()> { } async fn run_logout(config: &Config, logout_args: LogoutArgs) -> Result<()> { - let mcp_manager = load_mcp_manager(config).await; + let mcp_manager = load_mcp_manager(config).await?; let mcp_servers = mcp_manager.configured_servers(config).await; let LogoutArgs { name } = logout_args; @@ -629,9 +629,9 @@ async fn run_logout(config: &Config, logout_args: LogoutArgs) -> Result<()> { } async fn run_list(config: &Config, list_args: ListArgs) -> Result<()> { - let mcp_manager = load_mcp_manager(config).await; + let mcp_manager = load_mcp_manager(config).await?; let auth_manager = - AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await; + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await?; let auth = auth_manager.auth().await; let mcp_servers = mcp_manager.configured_servers(config).await; let effective_mcp_servers = mcp_manager.effective_servers(config, auth.as_ref()).await; @@ -894,7 +894,7 @@ async fn run_list(config: &Config, list_args: ListArgs) -> Result<()> { } async fn run_get(config: &Config, get_args: GetArgs) -> Result<()> { - let mcp_manager = load_mcp_manager(config).await; + let mcp_manager = load_mcp_manager(config).await?; let mcp_servers = mcp_manager.configured_servers(config).await; let Some(server) = mcp_servers.get(&get_args.name) else { diff --git a/codex-rs/cli/src/mcp_cmd/cloud_config.rs b/codex-rs/cli/src/mcp_cmd/cloud_config.rs index ccaa028d25..2f7e7fba4f 100644 --- a/codex-rs/cli/src/mcp_cmd/cloud_config.rs +++ b/codex-rs/cli/src/mcp_cmd/cloud_config.rs @@ -38,7 +38,8 @@ pub(super) async fn load_mcp_config( .context("failed to resolve cloud configuration authentication")?, /*enable_codex_api_key_env*/ false, ) - .await; + .await + .context("failed to initialize cloud configuration authentication")?; ConfigBuilder::default() .codex_home(codex_home.to_path_buf()) diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index d8d5b509d6..48dae16bdf 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -591,7 +591,7 @@ async fn load_plugin_command_context( .context("failed to load configuration")?; let plugins_input = config.plugins_config_input(); let manager = plugins_manager_for_config(&config); - manager.set_auth_mode(load_cli_auth_mode(&config).await); + manager.set_auth_mode(load_cli_auth_mode(&config).await?); Ok(PluginCommandContext { codex_home: codex_home.to_path_buf(), plugins_input, @@ -599,12 +599,14 @@ async fn load_plugin_command_context( }) } -pub(crate) async fn load_cli_auth_mode(config: &Config) -> Option { - AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true) - .await - .auth() - .await - .map(|auth| auth.api_auth_mode()) +pub(crate) async fn load_cli_auth_mode(config: &Config) -> Result> { + Ok( + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true) + .await? + .auth() + .await + .map(|auth| auth.api_auth_mode()), + ) } struct PluginSelection { diff --git a/codex-rs/cli/tests/login.rs b/codex-rs/cli/tests/login.rs index ac9ea6de9b..3eb81143f5 100644 --- a/codex-rs/cli/tests/login.rs +++ b/codex-rs/cli/tests/login.rs @@ -9,7 +9,10 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::write_chatgpt_auth; use codex_config::types::AuthCredentialsStoreMode; use codex_login::CLIENT_ID; +use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR; use codex_login::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_protocol::shell_environment::OPENAI_FEDERATION_RULE_ID_ENV_VAR; +use codex_protocol::shell_environment::OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR; use predicates::str::contains; use pretty_assertions::assert_eq; use serde_json::Value; @@ -81,6 +84,24 @@ fn login_status_reports_auth_storage_errors() -> Result<()> { Ok(()) } +#[test] +fn login_status_validates_configured_workload_identity() -> Result<()> { + let codex_home = TempDir::new()?; + write_file_auth_config(codex_home.path())?; + let missing_assertion = codex_home.path().join("missing-identity-token"); + + codex_command(codex_home.path())? + .env_remove(CODEX_ACCESS_TOKEN_ENV_VAR) + .env(OPENAI_FEDERATION_RULE_ID_ENV_VAR, "rule-test") + .env(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR, &missing_assertion) + .args(["login", "status"]) + .assert() + .failure() + .stderr(contains("could not read workload identity assertion file")); + + Ok(()) +} + #[test] fn login_with_access_token_rejects_invalid_jwt() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index e3007502c6..d95f9bdb87 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -87,19 +87,25 @@ where pub async fn cloud_config_bundle_loader_for_storage( auth_config: AuthConfig, enable_codex_api_key_env: bool, -) -> CloudConfigBundleLoader { - let codex_home = auth_config.codex_home.clone(); - let chatgpt_base_url = auth_config - .chatgpt_base_url - .clone() - .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let http_client_factory = auth_config.auth_route_config.http_client_factory().clone(); +) -> std::io::Result { let auth_manager = - AuthManager::shared_from_auth_config(auth_config, enable_codex_api_key_env).await; + AuthManager::shared_from_auth_config(auth_config.clone(), enable_codex_api_key_env).await?; + Ok(cloud_config_bundle_loader_from_auth_config( + auth_config, + auth_manager, + )) +} + +fn cloud_config_bundle_loader_from_auth_config( + auth_config: AuthConfig, + auth_manager: Arc, +) -> CloudConfigBundleLoader { cloud_config_bundle_loader( auth_manager, - chatgpt_base_url, - codex_home, - http_client_factory, + auth_config + .chatgpt_base_url + .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()), + auth_config.codex_home, + auth_config.auth_route_config.http_client_factory().clone(), ) } diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 78187b042a..03754ad550 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -61,8 +61,18 @@ pub async fn load_auth_manager( let http_client_factory = config.http_client_factory(); let mut auth_config = config.auth_config(); auth_config.chatgpt_base_url = chatgpt_base_url.or(Some(config.chatgpt_base_url.clone())); - let auth_manager = - AuthManager::shared_from_auth_config(auth_config, /*enable_codex_api_key_env*/ false).await; + let auth_manager = match AuthManager::shared_from_auth_config( + auth_config, + /*enable_codex_api_key_env*/ false, + ) + .await + { + Ok(auth_manager) => auth_manager, + Err(error) => { + append_error_log(format!("failed to load auth: {error}")); + return (None, http_client_factory); + } + }; (Some(auth_manager), http_client_factory) } diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 1b17e0e6c4..118033a752 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -120,7 +120,9 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools( config: &Config, ) -> Option> { let auth_manager = - AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false) + .await + .ok()?; let auth = auth_manager.auth().await; if !config .features @@ -205,7 +207,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( mcp_manager: Arc, ) -> anyhow::Result { let auth_manager = - AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await?; let auth = auth_manager.auth().await; if !config .features @@ -427,8 +429,11 @@ async fn cached_directory_connectors_for_tool_suggest_with_auth( let auth = if let Some(auth) = auth { Some(auth) } else { - let auth_manager = - AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; + let Ok(auth_manager) = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await + else { + return Vec::new(); + }; loaded_auth = auth_manager.auth().await; loaded_auth.as_ref() }; diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index b97a5a2c7c..7cc2be215c 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -33,7 +33,9 @@ pub async fn build_prompt_input( config.ephemeral = true; let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false) + .await + .map_err(|err| CodexErr::Fatal(err.to_string()))?; let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( config.codex_self_exe.clone(), diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index d987417436..c6fbf47394 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -289,6 +289,7 @@ async fn run_script_with_timeout( // Handler is kept as guard to control the drop. The `mut` pattern is required because .args() // returns a ref of handler. let mut handler = Command::new(&args[0]); + codex_protocol::shell_environment::scrub_non_inheritable_env_vars(handler.as_std_mut()); handler.args(&args[1..]); handler.stdin(Stdio::null()); handler.current_dir(cwd); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5b4f3083ad..829486d8e7 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -81,6 +81,7 @@ use codex_history::RolloutLine; use codex_login::default_client::set_default_client_residency_requirement; use codex_login::default_client::set_default_originator; use codex_login::enforce_login_restrictions; +use codex_login::is_workload_identity_selected; use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_otel::set_parent_from_context; @@ -342,11 +343,15 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ) .await; let bootstrap_config_toml = &bootstrap_config.config_toml; + let bootstrap_auth_config = bootstrap_auth_config(&codex_home, &bootstrap_config)?; + // API keys cannot fetch workspace-managed configuration. Preserve the + // existing ChatGPT bootstrap identity even when model requests allow + // CODEX_API_KEY. let cloud_config_bundle = cloud_config_bundle_loader_for_storage( - bootstrap_auth_config(&codex_home, &bootstrap_config)?, + bootstrap_auth_config, /*enable_codex_api_key_env*/ false, ) - .await; + .await?; let run_cli_overrides = cli_kv_overrides.clone(); let run_loader_overrides = loader_overrides.clone(); let run_cloud_config_bundle = cloud_config_bundle.clone(); @@ -460,7 +465,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result set_default_client_residency_requirement(config.enforce_residency.value()); - if let Err(err) = enforce_login_restrictions(&config.auth_config()).await { + if !is_workload_identity_selected() + && let Err(err) = enforce_login_restrictions(&config.auth_config()).await + { eprintln!("{err}"); std::process::exit(1); } diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 07d8a67ae5..2cada0be95 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -1809,19 +1809,17 @@ fn auth_config_from_preserves_all_fields() { #[tokio::test] #[serial(codex_auth_env)] -async fn try_shared_from_config_rejects_partial_workload_identity_configuration() { +async fn shared_from_config_prefers_workload_identity_to_explicit_access_token() { let codex_home = tempdir().expect("tempdir"); let config = test_auth_manager_config(codex_home.path()); - let _access_token_guard = remove_access_token_env_var(); + let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-explicit"); let _rule_guard = EnvVarGuard::set(OPENAI_FEDERATION_RULE_ID_ENV_VAR, "rule-one"); let _assertion_file_guard = EnvVarGuard::remove(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR); - let error = - AuthManager::try_shared_from_config(&config, /*enable_codex_api_key_env*/ false) - .await - .expect_err("partial workload identity config should fail closed"); + let error = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false) + .await + .expect_err("partial workload identity config should fail closed"); - assert!(matches!(error, RefreshTokenError::Permanent(_))); assert!( error .to_string() @@ -2148,7 +2146,9 @@ async fn auth_manager_rejects_disallowed_stored_and_external_auth() { .await; config.managed_auth_policy.allowed_login_methods = Some(vec![ForcedLoginMethod::Chatgpt]); let manager = - AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false) + .await + .expect("auth manager"); assert_eq!(manager.auth().await, None); assert!( @@ -2177,7 +2177,9 @@ async fn api_only_policy_rejects_access_tokens_before_hydration() { .await; config.managed_auth_policy.allowed_login_methods = Some(vec![ForcedLoginMethod::Api]); let manager = - AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false) + .await + .expect("auth manager"); assert_eq!(manager.auth().await, None); assert!( @@ -2210,7 +2212,9 @@ async fn workspace_policy_rejects_agent_identity_before_hydration() { config.managed_auth_policy.allowed_chatgpt_workspaces = Some(vec![WORKSPACE_ID_ALLOWED.to_string()]); let manager = - AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false) + .await + .expect("auth manager"); assert_eq!(manager.auth().await, None); drop(access_token_guard); @@ -2243,7 +2247,9 @@ async fn workspace_policy_rejects_agent_identity_before_hydration() { config.managed_auth_policy.allowed_chatgpt_workspaces = Some(vec![WORKSPACE_ID_ALLOWED.to_string()]); let manager = - AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false) + .await + .expect("auth manager"); assert_eq!(manager.auth().await, None); } @@ -2279,7 +2285,9 @@ async fn workspace_policy_checks_the_selected_request_account() { ) .await; let manager = - AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false) + .await + .expect("auth manager"); assert_eq!(manager.auth().await, None); } diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index ffd855cf71..f2fb4f465f 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -206,6 +206,37 @@ pub enum RefreshTokenError { Transient(#[from] std::io::Error), } +/// Error returned when constructing an [`AuthManager`] from resolved configuration. +#[derive(Debug, Error)] +#[error(transparent)] +pub struct AuthManagerInitializationError(AuthManagerInitializationErrorSource); + +#[derive(Debug, Error)] +enum AuthManagerInitializationErrorSource { + #[error(transparent)] + WorkloadIdentityConfiguration(WorkloadIdentitySessionError), + #[error(transparent)] + InitialAuth(RefreshTokenError), +} + +impl From for AuthManagerInitializationError { + fn from(error: WorkloadIdentitySessionError) -> Self { + Self(AuthManagerInitializationErrorSource::WorkloadIdentityConfiguration(error)) + } +} + +impl From for AuthManagerInitializationError { + fn from(error: RefreshTokenError) -> Self { + Self(AuthManagerInitializationErrorSource::InitialAuth(error)) + } +} + +impl From for std::io::Error { + fn from(error: AuthManagerInitializationError) -> Self { + Self::other(error) + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ExternalAuthRefreshReason { Unauthorized, @@ -2615,40 +2646,20 @@ impl AuthManager { ) } - /// Convenience constructor returning an `Arc` wrapper from resolved config. + /// Builds a shared manager and activates process-configured workload identity when selected. pub async fn shared_from_config( config: &impl AuthManagerConfig, enable_codex_api_key_env: bool, - ) -> Arc { + ) -> Result, AuthManagerInitializationError> { Self::shared_from_auth_config(auth_config_from(config), enable_codex_api_key_env).await } - /// Builds a shared manager using restrictions resolved before authentication. + /// Activates workload identity against an auth config resolved before full runtime config. pub async fn shared_from_auth_config( auth_config: AuthConfig, enable_codex_api_key_env: bool, - ) -> Arc { - Arc::new(Self::new_from_auth_config(auth_config, enable_codex_api_key_env).await) - } - - /// Constructs a manager and activates process-configured workload identity when selected. - pub async fn try_shared_from_config( - config: &impl AuthManagerConfig, - enable_codex_api_key_env: bool, - ) -> Result, RefreshTokenError> { - Self::try_shared_from_auth_config(auth_config_from(config), enable_codex_api_key_env).await - } - - /// Activates workload identity against an auth config resolved before full runtime config. - pub async fn try_shared_from_auth_config( - auth_config: AuthConfig, - enable_codex_api_key_env: bool, - ) -> Result, RefreshTokenError> { - let external_auth = WorkloadIdentityExternalAuth::from_process_config( - &auth_config, - enable_codex_api_key_env, - ) - .map_err(configured_workload_identity_error)?; + ) -> Result, AuthManagerInitializationError> { + let external_auth = WorkloadIdentityExternalAuth::from_process_config(&auth_config)?; let mut manager = Self::new_from_auth_config(auth_config, enable_codex_api_key_env).await; manager.workload_identity_selected = external_auth.is_some(); let manager = Arc::new(manager); @@ -2967,13 +2978,6 @@ fn auth_config_from(config: &impl AuthManagerConfig) -> AuthConfig { } } -fn configured_workload_identity_error(error: WorkloadIdentitySessionError) -> RefreshTokenError { - RefreshTokenError::Permanent(RefreshTokenFailedError::new( - RefreshTokenFailedReason::Other, - error.to_string(), - )) -} - #[cfg(test)] #[path = "auth_tests.rs"] mod tests; diff --git a/codex-rs/login/src/auth/workload_identity.rs b/codex-rs/login/src/auth/workload_identity.rs index 13cc1920ad..f5f16ab906 100644 --- a/codex-rs/login/src/auth/workload_identity.rs +++ b/codex-rs/login/src/auth/workload_identity.rs @@ -26,8 +26,6 @@ use super::ExternalAuthRefreshContext; use super::RefreshTokenError; use super::RefreshTokenFailedError; use super::RefreshTokenFailedReason; -use super::read_codex_access_token_from_env; -use super::read_codex_api_key_from_env; use crate::AuthRouteConfig; const PROD_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; @@ -97,22 +95,21 @@ pub(super) enum WorkloadIdentitySessionError { InvalidConfiguration(String), } -pub fn is_workload_identity_selected( - auth_config: &AuthConfig, - enable_codex_api_key_env: bool, -) -> bool { - !has_explicit_process_auth(auth_config, enable_codex_api_key_env) - && ProcessEnvironment::read().has_marker() +/// Returns whether workload identity was selected through process configuration. +/// +/// Either marker selects workload identity. Partial configuration then fails validation rather +/// than falling back to another credential source. +pub fn is_workload_identity_selected() -> bool { + ProcessEnvironment::read().has_marker() } fn resolve_config( chatgpt_base_url: &str, environment: ProcessEnvironment, - has_explicit_process_auth: bool, chatgpt_login_allowed: bool, auth_route_config: AuthRouteConfig, ) -> Result, WorkloadIdentitySessionError> { - if has_explicit_process_auth || !environment.has_marker() { + if !environment.has_marker() { return Ok(None); } if !chatgpt_login_allowed { @@ -140,14 +137,6 @@ fn resolve_config( })) } -fn has_explicit_process_auth(auth_config: &AuthConfig, enable_codex_api_key_env: bool) -> bool { - (enable_codex_api_key_env - && auth_config.is_login_method_allowed(ForcedLoginMethod::Api) - && read_codex_api_key_from_env().is_some()) - || (auth_config.is_login_method_allowed(ForcedLoginMethod::Chatgpt) - && read_codex_access_token_from_env().is_some()) -} - fn required_path( value: Option, variable: &'static str, @@ -296,17 +285,6 @@ struct WorkloadIdentitySessionEntry { } impl WorkloadIdentitySessionRegistry { - fn has_active_session(&self) -> Result { - let entry = self - .entry - .lock() - .map_err(|_| WorkloadIdentitySessionError::RegistryUnavailable)?; - Ok(entry - .as_ref() - .and_then(|active| active.session.upgrade()) - .is_some()) - } - fn session( &self, config: WorkloadIdentitySessionConfig, @@ -347,17 +325,14 @@ pub(super) struct WorkloadIdentityExternalAuth { impl WorkloadIdentityExternalAuth { pub(super) fn from_process_config( auth_config: &AuthConfig, - enable_codex_api_key_env: bool, ) -> Result, WorkloadIdentitySessionError> { let registry = process_registry(); - let active_session = registry.has_active_session()?; resolve_config( auth_config .chatgpt_base_url .as_deref() .unwrap_or("https://chatgpt.com/backend-api"), ProcessEnvironment::read(), - has_explicit_process_auth(auth_config, enable_codex_api_key_env) && !active_session, auth_config.is_login_method_allowed(ForcedLoginMethod::Chatgpt), auth_config.auth_route_config.clone(), )? diff --git a/codex-rs/login/src/auth/workload_identity_tests.rs b/codex-rs/login/src/auth/workload_identity_tests.rs index 24d0040576..15d5115553 100644 --- a/codex-rs/login/src/auth/workload_identity_tests.rs +++ b/codex-rs/login/src/auth/workload_identity_tests.rs @@ -26,14 +26,12 @@ fn complete_environment() -> ProcessEnvironment { fn resolve_for_test( environment: ProcessEnvironment, - has_explicit_process_auth: bool, chatgpt_login_allowed: bool, chatgpt_base_url: &str, ) -> Result, WorkloadIdentitySessionError> { resolve_config( chatgpt_base_url, environment, - has_explicit_process_auth, chatgpt_login_allowed, auth_route_config(OutboundProxyPolicy::ReqwestDefault), ) @@ -44,27 +42,12 @@ fn markers_select_wif_and_partial_configuration_fails_closed() { assert!( resolve_for_test( ProcessEnvironment::default(), - /*has_explicit_process_auth*/ false, /*chatgpt_login_allowed*/ true, "https://chatgpt.com/backend-api", ) .expect("no markers") .is_none() ); - assert!( - resolve_for_test( - ProcessEnvironment { - federation_rule_id: Some("partial".into()), - ..Default::default() - }, - /*has_explicit_process_auth*/ true, - /*chatgpt_login_allowed*/ true, - "https://chatgpt.com/backend-api", - ) - .expect("explicit auth wins") - .is_none() - ); - for (environment, missing) in [ ( ProcessEnvironment { @@ -83,7 +66,6 @@ fn markers_select_wif_and_partial_configuration_fails_closed() { ] { let error = resolve_for_test( environment, - /*has_explicit_process_auth*/ false, /*chatgpt_login_allowed*/ true, "https://chatgpt.com/backend-api", ) @@ -98,7 +80,6 @@ fn markers_select_wif_and_partial_configuration_fails_closed() { assert!( resolve_for_test( relative, - /*has_explicit_process_auth*/ false, /*chatgpt_login_allowed*/ true, "https://chatgpt.com/backend-api", ) @@ -112,7 +93,6 @@ fn markers_select_wif_and_partial_configuration_fails_closed() { fn auth_policy_and_app_environment_are_enforced() { let policy_error = resolve_for_test( complete_environment(), - /*has_explicit_process_auth*/ false, /*chatgpt_login_allowed*/ false, "https://chatgpt.com/backend-api", ) @@ -133,7 +113,6 @@ fn auth_policy_and_app_environment_are_enforced() { ] { let config = resolve_for_test( complete_environment(), - /*has_explicit_process_auth*/ false, /*chatgpt_login_allowed*/ true, chatgpt_base_url, ) @@ -145,7 +124,6 @@ fn auth_policy_and_app_environment_are_enforced() { let error = resolve_for_test( complete_environment(), - /*has_explicit_process_auth*/ false, /*chatgpt_login_allowed*/ true, "https://example.invalid/backend-api", ) diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 91573159d5..717bd0f4b0 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -33,6 +33,7 @@ pub use auth::AuthHeaders; pub use auth::AuthKeyringBackendKind; pub use auth::AuthManager; pub use auth::AuthManagerConfig; +pub use auth::AuthManagerInitializationError; pub use auth::CLIENT_ID; pub use auth::CLIENT_ID_OVERRIDE_ENV_VAR; pub use auth::CODEX_ACCESS_TOKEN_ENV_VAR; diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 8baf188a32..b972521ddc 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -64,6 +64,7 @@ pub async fn run_main( cli_config_overrides: CliConfigOverrides, strict_config: bool, ) -> IoResult<()> { + reject_workload_identity(codex_login::is_workload_identity_selected())?; // Parse CLI overrides once and derive the base Config eagerly so later // components do not need to work with raw TOML values. let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| { @@ -161,7 +162,7 @@ pub async fn run_main( state_db, installation_id, ) - .await; + .await?; async move { while let Some(msg) = incoming_rx.recv().await { match msg { @@ -207,6 +208,20 @@ pub async fn run_main( Ok(()) } +fn reject_workload_identity(workload_identity_selected: bool) -> IoResult<()> { + if workload_identity_selected { + return Err(std::io::Error::new( + ErrorKind::Unsupported, + "workload identity is not supported by `codex mcp-server`", + )); + } + Ok(()) +} + +#[cfg(test)] +#[path = "workload_identity_tests.rs"] +mod workload_identity_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 559074d921..97cbd97356 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -54,13 +54,14 @@ impl MessageProcessor { environment_manager: Arc, state_db: Option, installation_id: String, - ) -> Self { + ) -> std::io::Result { let outgoing = Arc::new(outgoing); let auth_manager = AuthManager::shared_from_config( config.as_ref(), /*enable_codex_api_key_env*/ false, ) - .await; + .await + .map_err(std::io::Error::other)?; let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new( config.codex_home.clone(), )); @@ -110,13 +111,13 @@ impl MessageProcessor { /*attestation_provider*/ None, /*external_time_provider*/ None, )); - Self { + Ok(Self { outgoing, initialized: false, arg0_paths, thread_manager, active_turns, - } + }) } pub(crate) async fn process_request(&mut self, request: JsonRpcRequest) { diff --git a/codex-rs/mcp-server/src/workload_identity_tests.rs b/codex-rs/mcp-server/src/workload_identity_tests.rs new file mode 100644 index 0000000000..4e131c62b6 --- /dev/null +++ b/codex-rs/mcp-server/src/workload_identity_tests.rs @@ -0,0 +1,15 @@ +use super::reject_workload_identity; +use pretty_assertions::assert_eq; + +#[test] +fn workload_identity_markers_are_rejected() { + let error = reject_workload_identity(/*workload_identity_selected*/ true) + .expect_err("mcp-server does not support workload identity"); + + assert_eq!( + error.to_string(), + "workload identity is not supported by `codex mcp-server`" + ); + reject_workload_identity(/*workload_identity_selected*/ false) + .expect("mcp-server remains available without workload identity"); +} diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 28121f368a..f01bc12251 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -117,7 +117,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let state_db = init_state_db(&config).await; let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await?; let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( config.codex_self_exe.clone(), config.codex_linux_sandbox_exe.clone(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8519cc549a..a77d13e30f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -51,6 +51,7 @@ use codex_login::AuthConfig; use codex_login::default_client::originator; use codex_login::default_client::set_default_client_residency_requirement; use codex_login::enforce_login_restrictions; +use codex_login::is_workload_identity_selected; use codex_protocol::ThreadId; use codex_protocol::auth::AuthMode; use codex_protocol::config_types::AltScreenMode; @@ -834,8 +835,18 @@ fn app_server_target_for_launch( explicit_remote_endpoint: Option, default_daemon_socket: Option, can_reuse_implicit_local_daemon: bool, -) -> AppServerTarget { - match explicit_remote_endpoint { + workload_identity_selected: bool, +) -> std::io::Result { + if workload_identity_selected { + if explicit_remote_endpoint.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "workload identity must be configured on the remote app-server host", + )); + } + return Ok(AppServerTarget::Embedded); + } + Ok(match explicit_remote_endpoint { Some(endpoint) => AppServerTarget::Remote { endpoint }, None if can_reuse_implicit_local_daemon => { default_daemon_socket.map_or(AppServerTarget::Embedded, |socket_path| { @@ -845,7 +856,20 @@ fn app_server_target_for_launch( }) } None => AppServerTarget::Embedded, - } + }) +} + +async fn cloud_config_bundle_for_app_server_target( + app_server_target: &AppServerTarget, + bootstrap_config: &ConfigTomlLoadResult, + codex_home: &Path, +) -> std::io::Result { + cloud_config_bundle_loader_for_storage( + app_server_target + .auth_config_for_cloud_loader(bootstrap_auth_config(codex_home, bootstrap_config)?), + /*enable_codex_api_key_env*/ false, + ) + .await } fn loader_overrides_are_default(loader_overrides: &LoaderOverrides) -> bool { @@ -939,12 +963,14 @@ pub async fn run_main( launch_loader_overrides.user_config_path = Some(user_config_path); launch_loader_overrides.user_config_profile = Some(profile_v2.clone()); } - let reuse_implicit_local_daemon = can_reuse_implicit_local_daemon( - &cli_kv_overrides, - &launch_loader_overrides, - strict_config, - cli.bypass_hook_trust, - ); + let workload_identity_selected = is_workload_identity_selected(); + let reuse_implicit_local_daemon = !workload_identity_selected + && can_reuse_implicit_local_daemon( + &cli_kv_overrides, + &launch_loader_overrides, + strict_config, + cli.bypass_hook_trust, + ); let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon { maybe_probe_default_daemon_socket(&codex_home).await } else { @@ -954,7 +980,8 @@ pub async fn run_main( explicit_remote_endpoint, default_daemon, reuse_implicit_local_daemon, - ); + workload_identity_selected, + )?; let remote_cwd_override = cli .cwd .clone() @@ -995,12 +1022,12 @@ pub async fn run_main( ) .await; let bootstrap_config_toml = &bootstrap_config.config_toml; - let cloud_config_bundle = cloud_config_bundle_loader_for_storage( - app_server_target - .auth_config_for_cloud_loader(bootstrap_auth_config(&codex_home, &bootstrap_config)?), - /*enable_codex_api_key_env*/ false, + let cloud_config_bundle = cloud_config_bundle_for_app_server_target( + &app_server_target, + &bootstrap_config, + &codex_home, ) - .await; + .await?; let cwd_override = if app_server_target.uses_remote_workspace() { None @@ -1090,11 +1117,15 @@ pub async fn run_main( ) .await; - let cloud_config_bundle = cloud_config_bundle_loader_for_storage( - app_server_target.auth_config_for_cloud_loader(config.auth_config()), - /*enable_codex_api_key_env*/ false, - ) - .await; + let cloud_config_bundle = if workload_identity_selected { + cloud_config_bundle + } else { + cloud_config_bundle_loader_for_storage( + app_server_target.auth_config_for_cloud_loader(config.auth_config()), + /*enable_codex_api_key_env*/ false, + ) + .await? + }; let environment_manager = Arc::new( prepared_environment_manager .build(Some(local_runtime_paths), config.http_client_factory()) @@ -1160,7 +1191,7 @@ pub async fn run_main( } } - if !app_server_target.uses_remote_workspace() { + if !app_server_target.uses_remote_workspace() && !workload_identity_selected { #[allow(clippy::print_stderr)] if let Err(err) = enforce_login_restrictions(&config.auth_config()).await { eprintln!("{err}"); @@ -1289,6 +1320,7 @@ async fn run_ratatui_app( environment_manager: Arc, ) -> color_eyre::Result { let uses_remote_workspace = app_server_target.uses_remote_workspace(); + let workload_identity_selected = is_workload_identity_selected(); color_eyre::install()?; tooltips::announcement::prewarm(initial_config.http_client_factory()); @@ -1379,7 +1411,9 @@ async fn run_ratatui_app( !uses_remote_workspace && should_show_trust_screen(&initial_config); #[cfg(target_os = "windows")] let mut trust_decision_was_made = false; - let login_status = if initial_config.model_provider.requires_openai_auth { + let login_status = if workload_identity_selected { + LoginStatus::AuthMode(AuthMode::Chatgpt) + } else if initial_config.model_provider.requires_openai_auth { let Some(app_server) = app_server.as_mut() else { unreachable!("app server should exist when auth is required"); }; @@ -1430,12 +1464,12 @@ async fn run_ratatui_app( // If this onboarding run included the login step, always refresh the cloud config bundle // and rebuild config. This avoids missing newly available cloud-managed policy due to login // status detection edge cases. - if show_login_screen && !uses_remote_workspace { + if show_login_screen && !uses_remote_workspace && !workload_identity_selected { cloud_config_bundle = cloud_config_bundle_loader_for_storage( initial_config.auth_config(), /*enable_codex_api_key_env*/ false, ) - .await; + .await?; } // If the user made an explicit trust decision, or we showed the login flow, reload config @@ -2520,7 +2554,8 @@ mod tests { /*explicit_remote_endpoint*/ None, Some(socket_path.clone()), /*can_reuse_implicit_local_daemon*/ true, - ); + /*workload_identity_selected*/ false, + )?; assert_eq!( target, @@ -2542,7 +2577,8 @@ mod tests { Some(explicit_endpoint.clone()), Some(AbsolutePathBuf::relative_to_current_dir("default.sock")?), /*can_reuse_implicit_local_daemon*/ false, - ); + /*workload_identity_selected*/ false, + )?; assert_eq!( target, @@ -2563,12 +2599,43 @@ mod tests { /*explicit_remote_endpoint*/ None, Some(socket_path), /*can_reuse_implicit_local_daemon*/ false, - ); + /*workload_identity_selected*/ false, + )?; assert_eq!(target, AppServerTarget::Embedded); Ok(()) } + #[test] + fn workload_identity_requires_an_embedded_app_server() -> color_eyre::Result<()> { + let default_socket = AbsolutePathBuf::relative_to_current_dir("default.sock")?; + assert_eq!( + app_server_target_for_launch( + /*explicit_remote_endpoint*/ None, + Some(default_socket), + /*can_reuse_implicit_local_daemon*/ true, + /*workload_identity_selected*/ true, + )?, + AppServerTarget::Embedded + ); + + let explicit_endpoint = RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("explicit.sock")?, + }; + let error = app_server_target_for_launch( + Some(explicit_endpoint), + /*default_daemon_socket*/ None, + /*can_reuse_implicit_local_daemon*/ false, + /*workload_identity_selected*/ true, + ) + .expect_err("remote hosts must own workload identity"); + assert_eq!( + error.to_string(), + "workload identity must be configured on the remote app-server host" + ); + Ok(()) + } + #[test] fn can_reuse_implicit_local_daemon_requires_default_launch_config() -> color_eyre::Result<()> { let mut loader_overrides = LoaderOverrides::default(); diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 730172c5a2..b438c2addb 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1070,7 +1070,8 @@ mod tests { auth_config.clone(), /*enable_codex_api_key_env*/ false, ) - .await, + .await + .expect("test cloud config loader"), feedback: codex_feedback::CodexFeedback::new(), log_db: None, state_db: None, diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 21b96f43b6..ef5b868c4e 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -13,7 +13,6 @@ use crate::Cli; use crate::app_server_session::AppServerSession; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; -use crate::legacy_core::config::bootstrap_auth_config; use crate::legacy_core::config::load_config_toml_with_layer_stack; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; @@ -22,7 +21,6 @@ use crate::named_session_lookup::SessionCollection; use crate::named_session_lookup::SessionNameLookupMode; use codex_app_server_protocol::Thread as AppServerThread; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_config_bundle_loader_for_storage; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; @@ -278,12 +276,14 @@ async fn start_app_server_for_archive_command( launch_loader_overrides.user_config_profile = Some(profile_v2.clone()); } - let reuse_implicit_local_daemon = super::can_reuse_implicit_local_daemon( - &cli_kv_overrides, - &launch_loader_overrides, - strict_config, - cli.bypass_hook_trust, - ); + let workload_identity_selected = codex_login::is_workload_identity_selected(); + let reuse_implicit_local_daemon = !workload_identity_selected + && super::can_reuse_implicit_local_daemon( + &cli_kv_overrides, + &launch_loader_overrides, + strict_config, + cli.bypass_hook_trust, + ); let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon { super::maybe_probe_default_daemon_socket(codex_home.as_path()).await } else { @@ -293,7 +293,8 @@ async fn start_app_server_for_archive_command( explicit_remote_endpoint, default_daemon, reuse_implicit_local_daemon, - ); + workload_identity_selected, + )?; let remote_cwd_override = cli .cwd .clone() @@ -337,14 +338,12 @@ async fn start_app_server_for_archive_command( .await .wrap_err("failed to load config.toml")?; let config_toml = &bootstrap_config.config_toml; - let cloud_config_bundle = cloud_config_bundle_loader_for_storage( - app_server_target.auth_config_for_cloud_loader(bootstrap_auth_config( - codex_home.as_path(), - &bootstrap_config, - )?), - /*enable_codex_api_key_env*/ false, + let cloud_config_bundle = super::cloud_config_bundle_for_app_server_target( + &app_server_target, + &bootstrap_config, + codex_home.as_path(), ) - .await; + .await?; let model_provider = if cli.oss { resolve_oss_provider(cli.oss_provider.as_deref(), config_toml)