diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 0635718c9c..1fad2a6a12 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -8,6 +8,7 @@ use crate::file_watcher::WatchRegistration; use crate::protocol::Event; use crate::protocol::Op; use crate::protocol::Submission; +use std::collections::HashMap; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -122,6 +123,10 @@ impl CodexThread { self.codex.agent_status().await } + pub async fn set_dependency_env(&self, values: HashMap) { + self.codex.set_dependency_env(values).await; + } + pub(crate) fn subscribe_status(&self) -> watch::Receiver { self.codex.agent_status.clone() } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fe1cb6ae0c..edc9f8e37a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3117,6 +3117,21 @@ impl App { AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } + AppEvent::SetDependencyEnv { values, result_tx } => { + let result = match self.active_thread_id { + Some(thread_id) => match self.server.get_thread(thread_id).await { + Ok(thread) => { + thread.set_dependency_env(values).await; + Ok(()) + } + Err(err) => Err(format!( + "failed to load active Codex thread for dependency env update: {err}" + )), + }, + None => Err("no active Codex thread for dependency env update".to_string()), + }; + let _ = result_tx.send(result); + } AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { if refresh_connectors { self.chat_widget.refresh_connectors(/*force_refetch*/ true); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b5e81edd9f..39a8685f94 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -8,6 +8,7 @@ //! Exit is modelled explicitly via `AppEvent::Exit(ExitMode)` so callers can request shutdown-first //! quits without reaching into the app loop or coupling to shutdown/exit sequencing. +use std::collections::HashMap; use std::path::PathBuf; use codex_app_server_protocol::PluginInstallResponse; @@ -37,6 +38,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use tokio::sync::oneshot; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RealtimeAudioDeviceKind { @@ -168,6 +170,12 @@ pub(crate) enum AppEvent { force_refetch: bool, }, + /// Add environment variables to the active thread's dependency env override. + SetDependencyEnv { + values: HashMap, + result_tx: oneshot::Sender>, + }, + /// Fetch plugin marketplace state for the provided working directory. FetchPluginsList { cwd: PathBuf, diff --git a/codex-rs/tui/src/chatwidget/create_api_key.rs b/codex-rs/tui/src/chatwidget/create_api_key.rs index df34cbf901..941acd5613 100644 --- a/codex-rs/tui/src/chatwidget/create_api_key.rs +++ b/codex-rs/tui/src/chatwidget/create_api_key.rs @@ -1,18 +1,13 @@ -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; +use std::collections::HashMap; -use codex_core::AuthManager; -use codex_core::auth::AuthCredentialsStoreMode; -use codex_core::auth::login_with_api_key; use codex_core::auth::read_openai_api_key_from_env; use codex_login::CreatedApiKey; use codex_login::OPENAI_API_KEY_ENV_VAR; use codex_login::PendingCreateApiKey; use codex_login::start_create_api_key as start_create_api_key_flow; -use codex_protocol::config_types::ForcedLoginMethod; use ratatui::style::Stylize; use ratatui::text::Line; +use tokio::sync::oneshot; use super::ChatWidget; use crate::app_event::AppEvent; @@ -23,12 +18,7 @@ use crate::history_cell::PlainHistoryCell; impl ChatWidget { pub(crate) fn start_create_api_key(&mut self) { - match start_create_api_key_command( - self.app_event_tx.clone(), - self.auth_manager.clone(), - self.config.codex_home.clone(), - self.config.forced_login_method, - ) { + match start_create_api_key_command(self.app_event_tx.clone()) { Ok(start_message) => { self.add_to_history(start_message); self.request_redraw(); @@ -42,9 +32,6 @@ impl ChatWidget { fn start_create_api_key_command( app_event_tx: AppEventSender, - auth_manager: Arc, - codex_home: PathBuf, - forced_login_method: Option, ) -> Result { if read_openai_api_key_from_env().is_some() { return Ok(existing_shell_api_key_message()); @@ -61,7 +48,7 @@ fn start_create_api_key_command( let app_event_tx_for_task = app_event_tx; tokio::spawn(async move { - let cell = complete_command(session, codex_home, forced_login_method, auth_manager).await; + let cell = complete_command(session, app_event_tx_for_task.clone()).await; app_event_tx_for_task.send(AppEvent::InsertHistoryCell(Box::new(cell))); }); @@ -133,9 +120,7 @@ fn continue_in_browser_message( async fn complete_command( session: PendingCreateApiKey, - codex_home: PathBuf, - forced_login_method: Option, - auth_manager: Arc, + app_event_tx: AppEventSender, ) -> PlainHistoryCell { let provisioned = match session.finish().await { Ok(provisioned) => provisioned, @@ -144,44 +129,49 @@ async fn complete_command( } }; let copy_result = clipboard_text::copy_text_to_clipboard(&provisioned.project_api_key); - - success_cell( - &provisioned, - copy_result, - live_apply_api_key( - forced_login_method, - &codex_home, - &provisioned.project_api_key, - auth_manager, - ), + let session_env_result = apply_api_key_to_current_session( + &provisioned.project_api_key, + app_event_tx, ) + .await; + + success_cell(&provisioned, copy_result, session_env_result) } -fn live_apply_api_key( - forced_login_method: Option, - codex_home: &Path, +async fn apply_api_key_to_current_session( api_key: &str, - auth_manager: Arc, -) -> LiveApplyOutcome { - if matches!(forced_login_method, Some(ForcedLoginMethod::Chatgpt)) { - return LiveApplyOutcome::Skipped(format!( - "Created {OPENAI_API_KEY_ENV_VAR}, but left this session unchanged because ChatGPT login is required here." - )); - } + app_event_tx: AppEventSender, +) -> Result<(), String> { + set_current_process_api_key(api_key); - match login_with_api_key(codex_home, api_key, AuthCredentialsStoreMode::Ephemeral) { - Ok(()) => { - auth_manager.reload(); - LiveApplyOutcome::Applied - } - Err(err) => LiveApplyOutcome::Failed(err.to_string()), + let (result_tx, result_rx) = oneshot::channel(); + app_event_tx.send(AppEvent::SetDependencyEnv { + values: HashMap::from([(OPENAI_API_KEY_ENV_VAR.to_string(), api_key.to_string())]), + result_tx, + }); + + match result_rx.await { + Ok(result) => result, + Err(err) => Err(format!( + "dependency env update response channel closed before completion: {err}" + )), + } +} + +fn set_current_process_api_key(api_key: &str) { + // SAFETY: `/create-api-key` intentionally mutates process-global environment so the running + // Codex session can observe `OPENAI_API_KEY` immediately. This is scoped to a single + // user-triggered command, and spawned tool environments are updated separately through the + // session dependency env override. + unsafe { + std::env::set_var(OPENAI_API_KEY_ENV_VAR, api_key); } } fn success_cell( provisioned: &CreatedApiKey, copy_result: Result<(), String>, - live_apply_outcome: LiveApplyOutcome, + session_env_result: Result<(), String>, ) -> PlainHistoryCell { let organization = provisioned .organization_title @@ -196,20 +186,15 @@ fn success_cell( Ok(()) => "Copied the full key to your clipboard.".to_string(), Err(err) => format!("Could not copy the key to your clipboard: {err}"), }; - let live_apply_status = match live_apply_outcome { - LiveApplyOutcome::Applied => Some( - "Updated this session to use the newly created API key without touching auth.json." - .to_string(), - ), - LiveApplyOutcome::Skipped(reason) => Some(reason), - LiveApplyOutcome::Failed(err) => Some(format!( - "Created {OPENAI_API_KEY_ENV_VAR}, but could not hot-apply it in this session: {err}", - )), + let session_env_status = match session_env_result { + Ok(()) => { + format!("Set {OPENAI_API_KEY_ENV_VAR} in this Codex session for spawned commands.") + } + Err(err) => { + format!("Could not set {OPENAI_API_KEY_ENV_VAR} in this Codex session: {err}") + } }; - let hint = Some(match live_apply_status { - Some(live_apply_status) => format!("{copy_status} {live_apply_status}"), - None => copy_status, - }); + let hint = Some(format!("{copy_status} {session_env_status}")); history_cell::new_info_event( format!( @@ -234,12 +219,6 @@ fn mask_api_key(api_key: &str) -> String { ) } -enum LiveApplyOutcome { - Applied, - Skipped(String), - Failed(String), -} - #[cfg(test)] mod tests { use super::*; @@ -257,14 +236,14 @@ mod tests { project_api_key: "sk-proj-123".to_string(), }, Ok(()), - LiveApplyOutcome::Applied, + Ok(()), ); assert_snapshot!(render_cell(&cell)); } #[test] - fn success_cell_snapshot_when_live_apply_is_skipped() { + fn success_cell_snapshot_when_clipboard_copy_fails() { let cell = success_cell( &CreatedApiKey { organization_id: "org-default".to_string(), @@ -274,10 +253,7 @@ mod tests { project_api_key: "sk-proj-123".to_string(), }, Err("clipboard unavailable".to_string()), - LiveApplyOutcome::Skipped( - "Created OPENAI_API_KEY, but left this session unchanged because ChatGPT login is required here." - .to_string(), - ), + Err("dependency env unavailable".to_string()), ); assert_snapshot!(render_cell(&cell));