diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f1734ddf6f..1e96cc81b0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -292,7 +292,6 @@ use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod create_api_key; -mod dotenv_api_key; mod interrupts; use self::interrupts::InterruptManager; mod agent; diff --git a/codex-rs/tui/src/chatwidget/create_api_key.rs b/codex-rs/tui/src/chatwidget/create_api_key.rs index a042774b70..df34cbf901 100644 --- a/codex-rs/tui/src/chatwidget/create_api_key.rs +++ b/codex-rs/tui/src/chatwidget/create_api_key.rs @@ -15,10 +15,9 @@ use ratatui::style::Stylize; use ratatui::text::Line; use super::ChatWidget; -use super::dotenv_api_key::upsert_dotenv_api_key; -use super::dotenv_api_key::validate_dotenv_target; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::clipboard_text; use crate::history_cell; use crate::history_cell::PlainHistoryCell; @@ -28,7 +27,6 @@ impl ChatWidget { self.app_event_tx.clone(), self.auth_manager.clone(), self.config.codex_home.clone(), - self.status_line_cwd().to_path_buf(), self.config.forced_login_method, ) { Ok(start_message) => { @@ -46,42 +44,24 @@ fn start_create_api_key_command( app_event_tx: AppEventSender, auth_manager: Arc, codex_home: PathBuf, - cwd: PathBuf, forced_login_method: Option, ) -> Result { if read_openai_api_key_from_env().is_some() { return Ok(existing_shell_api_key_message()); } - let dotenv_path = cwd.join(".env.local"); - validate_dotenv_target(&dotenv_path).map_err(|err| { - format!( - "Unable to prepare {} for {OPENAI_API_KEY_ENV_VAR}: {err}", - dotenv_path.display(), - ) - })?; - let session = start_create_api_key_flow() .map_err(|err| format!("Failed to start API key creation: {err}"))?; let browser_opened = session.open_browser(); let start_message = continue_in_browser_message( session.auth_url(), session.callback_port(), - &dotenv_path, browser_opened, ); let app_event_tx_for_task = app_event_tx; - let dotenv_path_for_task = dotenv_path; tokio::spawn(async move { - let cell = complete_command( - session, - dotenv_path_for_task, - codex_home, - forced_login_method, - auth_manager, - ) - .await; + let cell = complete_command(session, codex_home, forced_login_method, auth_manager).await; app_event_tx_for_task.send(AppEvent::InsertHistoryCell(Box::new(cell))); }); @@ -94,7 +74,7 @@ fn existing_shell_api_key_message() -> PlainHistoryCell { "{OPENAI_API_KEY_ENV_VAR} is already set in this Codex session; skipping API key creation." ), Some(format!( - "This Codex session already inherited {OPENAI_API_KEY_ENV_VAR} from its shell environment. Unset it and run /create-api-key again if you want Codex to create and save a different key." + "This Codex session already inherited {OPENAI_API_KEY_ENV_VAR} from its shell environment. Unset it and run /create-api-key again if you want Codex to create a different key." )), ) } @@ -102,7 +82,6 @@ fn existing_shell_api_key_message() -> PlainHistoryCell { fn continue_in_browser_message( auth_url: &str, callback_port: u16, - dotenv_path: &Path, browser_opened: bool, ) -> PlainHistoryCell { let mut lines = vec![ @@ -136,10 +115,7 @@ fn continue_in_browser_message( ])); lines.push("".into()); lines.push( - format!( - " Codex will save {OPENAI_API_KEY_ENV_VAR} to {} and hot-apply it here when allowed.", - dotenv_path.display() - ) + format!(" Codex will display the new {OPENAI_API_KEY_ENV_VAR} here and copy it to your clipboard.") .dark_gray() .into(), ); @@ -157,7 +133,6 @@ fn continue_in_browser_message( async fn complete_command( session: PendingCreateApiKey, - dotenv_path: PathBuf, codex_home: PathBuf, forced_login_method: Option, auth_manager: Arc, @@ -168,17 +143,11 @@ async fn complete_command( return history_cell::new_error_event(format!("API key creation failed: {err}")); } }; - - if let Err(err) = upsert_dotenv_api_key(&dotenv_path, &provisioned.project_api_key) { - return history_cell::new_error_event(format!( - "API key creation completed, but Codex could not update {}: {err}", - dotenv_path.display() - )); - } + let copy_result = clipboard_text::copy_text_to_clipboard(&provisioned.project_api_key); success_cell( &provisioned, - &dotenv_path, + copy_result, live_apply_api_key( forced_login_method, &codex_home, @@ -196,7 +165,7 @@ fn live_apply_api_key( ) -> LiveApplyOutcome { if matches!(forced_login_method, Some(ForcedLoginMethod::Chatgpt)) { return LiveApplyOutcome::Skipped(format!( - "Saved {OPENAI_API_KEY_ENV_VAR} to .env.local, but left this session unchanged because ChatGPT login is required here." + "Created {OPENAI_API_KEY_ENV_VAR}, but left this session unchanged because ChatGPT login is required here." )); } @@ -211,7 +180,7 @@ fn live_apply_api_key( fn success_cell( provisioned: &CreatedApiKey, - dotenv_path: &Path, + copy_result: Result<(), String>, live_apply_outcome: LiveApplyOutcome, ) -> PlainHistoryCell { let organization = provisioned @@ -222,27 +191,49 @@ fn success_cell( .default_project_title .clone() .unwrap_or_else(|| provisioned.default_project_id.clone()); - let hint = match live_apply_outcome { + let masked_api_key = mask_api_key(&provisioned.project_api_key); + let copy_status = match copy_result { + 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!( - "Saved {OPENAI_API_KEY_ENV_VAR} to {}, but could not hot-apply it in this session: {err}", - dotenv_path.display(), + "Created {OPENAI_API_KEY_ENV_VAR}, but could not hot-apply it in this session: {err}", )), }; + let hint = Some(match live_apply_status { + Some(live_apply_status) => format!("{copy_status} {live_apply_status}"), + None => copy_status, + }); history_cell::new_info_event( format!( - "Created an API key for {organization} / {project} and saved {OPENAI_API_KEY_ENV_VAR} to {}.", - dotenv_path.display() + "Created an API key for {organization} / {project}: {masked_api_key}" ), hint, ) } +fn mask_api_key(api_key: &str) -> String { + const UNMASKED_PREFIX_LEN: usize = 8; + const UNMASKED_SUFFIX_LEN: usize = 4; + + if api_key.len() <= UNMASKED_PREFIX_LEN + UNMASKED_SUFFIX_LEN { + return api_key.to_string(); + } + + format!( + "{}...{}", + &api_key[..UNMASKED_PREFIX_LEN], + &api_key[api_key.len() - UNMASKED_SUFFIX_LEN..] + ) +} + enum LiveApplyOutcome { Applied, Skipped(String), @@ -265,7 +256,7 @@ mod tests { default_project_title: Some("Default Project".to_string()), project_api_key: "sk-proj-123".to_string(), }, - Path::new("/tmp/workspace/.env.local"), + Ok(()), LiveApplyOutcome::Applied, ); @@ -282,9 +273,9 @@ mod tests { default_project_title: None, project_api_key: "sk-proj-123".to_string(), }, - Path::new("/tmp/workspace/.env.local"), + Err("clipboard unavailable".to_string()), LiveApplyOutcome::Skipped( - "Saved OPENAI_API_KEY to .env.local, but left this session unchanged because ChatGPT login is required here." + "Created OPENAI_API_KEY, but left this session unchanged because ChatGPT login is required here." .to_string(), ), ); @@ -297,7 +288,6 @@ mod tests { let cell = continue_in_browser_message( "https://auth.openai.com/oauth/authorize?client_id=abc", /*callback_port*/ 5000, - Path::new("/tmp/workspace/.env.local"), /*browser_opened*/ false, ); @@ -310,7 +300,7 @@ mod tests { assert_eq!( render_cell(&cell), - "• OPENAI_API_KEY is already set in this Codex session; skipping API key creation. This Codex session already inherited OPENAI_API_KEY from its shell environment. Unset it and run /create-api-key again if you want Codex to create and save a different key." + "• OPENAI_API_KEY is already set in this Codex session; skipping API key creation. This Codex session already inherited OPENAI_API_KEY from its shell environment. Unset it and run /create-api-key again if you want Codex to create a different key." ); } @@ -319,13 +309,17 @@ mod tests { let cell = continue_in_browser_message( "https://auth.example.com/oauth/authorize?state=abc", 5000, - Path::new("/tmp/workspace/.env.local"), /*browser_opened*/ false, ); assert!(render_cell(&cell).contains("https://auth.example.com/oauth/authorize?state=abc")); } + #[test] + fn mask_api_key_preserves_prefix_and_suffix() { + assert_eq!(mask_api_key("sk-proj-1234567890"), "sk-proj-...7890"); + } + fn render_cell(cell: &PlainHistoryCell) -> String { cell.display_lines(120) .into_iter() diff --git a/codex-rs/tui/src/chatwidget/dotenv_api_key.rs b/codex-rs/tui/src/chatwidget/dotenv_api_key.rs deleted file mode 100644 index 1c25b9f57a..0000000000 --- a/codex-rs/tui/src/chatwidget/dotenv_api_key.rs +++ /dev/null @@ -1,233 +0,0 @@ -use std::fs::OpenOptions; -use std::io; -use std::io::ErrorKind; -use std::io::Write; -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; -use std::path::Path; - -use codex_login::OPENAI_API_KEY_ENV_VAR; - -pub(super) fn validate_dotenv_target(path: &Path) -> io::Result<()> { - ensure_parent_dir(path)?; - reject_symlink(path)?; - - if path.exists() { - let mut options = OpenOptions::new(); - options.append(true); - #[cfg(unix)] - { - options.custom_flags(libc::O_NOFOLLOW); - } - options.open(path)?; - return Ok(()); - } - - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - options.custom_flags(libc::O_NOFOLLOW); - options.mode(0o600); - } - options.open(path)?; - std::fs::remove_file(path) -} - -pub(super) fn upsert_dotenv_api_key(path: &Path, api_key: &str) -> io::Result<()> { - if api_key.contains(['\n', '\r']) { - return Err(io::Error::new( - ErrorKind::InvalidInput, - "OPENAI_API_KEY must not contain newlines", - )); - } - - ensure_parent_dir(path)?; - reject_symlink(path)?; - - let existing = match std::fs::read_to_string(path) { - Ok(contents) => contents, - Err(err) if err.kind() == ErrorKind::NotFound => String::new(), - Err(err) => return Err(err), - }; - - let mut next = String::new(); - let mut wrote_api_key = false; - - for segment in split_lines_preserving_terminators(&existing) { - if is_active_assignment_for(segment, OPENAI_API_KEY_ENV_VAR) { - if !wrote_api_key { - next.push_str(&format!("{OPENAI_API_KEY_ENV_VAR}={api_key}\n")); - wrote_api_key = true; - } - continue; - } - - next.push_str(segment); - } - - if !wrote_api_key { - if !next.is_empty() && !next.ends_with('\n') { - next.push('\n'); - } - next.push_str(&format!("{OPENAI_API_KEY_ENV_VAR}={api_key}\n")); - } - - write_dotenv_file(path, &next) -} - -fn write_dotenv_file(path: &Path, contents: &str) -> io::Result<()> { - reject_symlink(path)?; - - let mut options = OpenOptions::new(); - options.write(true).create(true).truncate(true); - #[cfg(unix)] - { - options.custom_flags(libc::O_NOFOLLOW); - options.mode(0o600); - } - - let mut file = options.open(path)?; - file.write_all(contents.as_bytes())?; - file.flush()?; - - #[cfg(unix)] - { - file.set_permissions(std::fs::Permissions::from_mode(0o600))?; - } - - Ok(()) -} - -fn reject_symlink(path: &Path) -> io::Result<()> { - let metadata = match std::fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), - Err(err) => return Err(err), - }; - - if metadata.file_type().is_symlink() { - return Err(io::Error::new( - ErrorKind::InvalidInput, - ".env.local must not be a symlink", - )); - } - - Ok(()) -} - -fn ensure_parent_dir(path: &Path) -> io::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - Ok(()) -} - -fn split_lines_preserving_terminators(contents: &str) -> Vec<&str> { - if contents.is_empty() { - return Vec::new(); - } - - contents.split_inclusive('\n').collect() -} - -fn is_active_assignment_for(line: &str, key: &str) -> bool { - let mut rest = line.trim_start(); - if rest.starts_with('#') { - return false; - } - - if let Some(stripped) = rest.strip_prefix("export") { - rest = stripped.trim_start(); - } - - let Some(rest) = rest.strip_prefix(key) else { - return false; - }; - - rest.trim_start().starts_with('=') -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use tempfile::tempdir; - - #[test] - fn upsert_creates_dotenv_file_when_missing() { - let temp_dir = tempdir().expect("tempdir"); - let dotenv_path = temp_dir.path().join(".env"); - - upsert_dotenv_api_key(&dotenv_path, "sk-test-key").expect("write dotenv"); - - let written = std::fs::read_to_string(&dotenv_path).expect("read dotenv"); - assert_eq!(written, "OPENAI_API_KEY=sk-test-key\n"); - } - - #[cfg(unix)] - #[test] - fn upsert_creates_dotenv_file_with_owner_only_permissions() { - let temp_dir = tempdir().expect("tempdir"); - let dotenv_path = temp_dir.path().join(".env"); - - upsert_dotenv_api_key(&dotenv_path, "sk-test-key").expect("write dotenv"); - - let mode = std::fs::metadata(&dotenv_path) - .expect("metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o600); - } - - #[cfg(unix)] - #[test] - fn upsert_rejects_symlink_target() { - let temp_dir = tempdir().expect("tempdir"); - let dotenv_path = temp_dir.path().join(".env"); - let target_path = temp_dir.path().join("target.env"); - std::fs::write(&target_path, "OTHER=value\n").expect("seed target"); - std::os::unix::fs::symlink(&target_path, &dotenv_path).expect("symlink"); - - let err = upsert_dotenv_api_key(&dotenv_path, "sk-test-key").expect_err("reject symlink"); - - assert_eq!(err.kind(), ErrorKind::InvalidInput); - let target = std::fs::read_to_string(&target_path).expect("read target"); - assert_eq!(target, "OTHER=value\n"); - } - - #[test] - fn upsert_replaces_existing_api_key_and_collapses_duplicates() { - let temp_dir = tempdir().expect("tempdir"); - let dotenv_path = temp_dir.path().join(".env"); - std::fs::write( - &dotenv_path, - "# comment\nOPENAI_API_KEY=sk-old-1\nOTHER=value\nexport OPENAI_API_KEY = sk-old-2\n", - ) - .expect("seed dotenv"); - - upsert_dotenv_api_key(&dotenv_path, "sk-new-key").expect("update dotenv"); - - let written = std::fs::read_to_string(&dotenv_path).expect("read dotenv"); - assert_eq!( - written, - "# comment\nOPENAI_API_KEY=sk-new-key\nOTHER=value\n" - ); - } - - #[test] - fn validate_dotenv_target_succeeds_for_missing_file() { - let temp_dir = tempdir().expect("tempdir"); - let dotenv_path = temp_dir.path().join(".env"); - - validate_dotenv_target(&dotenv_path).expect("validate dotenv"); - - assert!( - !dotenv_path.exists(), - "validation should not leave behind a new file" - ); - } -} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 5d5d3f82f6..c08de41673 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -83,7 +83,7 @@ impl SlashCommand { // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", - SlashCommand::CreateApiKey => "create an API key and save it to .env.local", + SlashCommand::CreateApiKey => "create an API key and copy it to your clipboard", SlashCommand::Copy => "copy the latest Codex output to your clipboard", SlashCommand::Mention => "mention a file", SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",