codex: show created API keys in TUI

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Michael Fan
2026-03-25 23:45:56 -04:00
parent 64e57306e5
commit a19cf09ac5
4 changed files with 45 additions and 285 deletions

View File

@@ -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;

View File

@@ -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<AuthManager>,
codex_home: PathBuf,
cwd: PathBuf,
forced_login_method: Option<ForcedLoginMethod>,
) -> Result<PlainHistoryCell, String> {
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<ForcedLoginMethod>,
auth_manager: Arc<AuthManager>,
@@ -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()

View File

@@ -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"
);
}
}

View File

@@ -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",