mirror of
https://github.com/openai/codex.git
synced 2026-09-17 12:23:33 +00:00
codex: show created API keys in TUI
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user