Compare commits

...

1 Commits

Author SHA1 Message Date
mifan-oai
6311965bc0 Add CLI api-provision slash command
Extract the browser-based provisioning flow from codex-login so the plain TUI can
reuse it. Add /api-provision to the CLI, persist CODEX_API_KEY to .env, and
hot-apply the key via ephemeral auth without touching auth.json.

Validation:
- cargo test -p codex-login
- cargo test -p codex-tui
- just fix -p codex-login
- just fix -p codex-tui
- just fmt

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 21:18:21 +00:00
11 changed files with 1681 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
#[tokio::main]
async fn main() {
if let Err(err) = codex_login::run_onboard_oauth_helper_from_env().await {
eprintln!("{err}");
if let Some(body) = err.body()
&& !body.is_empty()
{
eprintln!("{body}");
}
std::process::exit(1);
}
}

View File

@@ -0,0 +1,141 @@
use std::fs::OpenOptions;
use std::io;
use std::io::ErrorKind;
use std::path::Path;
use codex_core::auth::CODEX_API_KEY_ENV_VAR;
pub fn validate_dotenv_target(path: &Path) -> io::Result<()> {
ensure_parent_dir(path)?;
if path.exists() {
OpenOptions::new().append(true).open(path)?;
return Ok(());
}
OpenOptions::new().write(true).create_new(true).open(path)?;
std::fs::remove_file(path)
}
pub 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,
"CODEX_API_KEY must not contain newlines",
));
}
ensure_parent_dir(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, CODEX_API_KEY_ENV_VAR) {
if !wrote_api_key {
next.push_str(&format!("{CODEX_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!("{CODEX_API_KEY_ENV_VAR}={api_key}\n"));
}
std::fs::write(path, next)
}
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, "CODEX_API_KEY=sk-test-key\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\nCODEX_API_KEY=sk-old-1\nOTHER=value\nexport CODEX_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\nCODEX_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

@@ -1,4 +1,6 @@
mod device_code_auth;
mod dotenv_api_key;
mod onboard_oauth_helper;
mod pkce;
mod server;
@@ -24,3 +26,11 @@ pub use codex_core::auth::login_with_api_key;
pub use codex_core::auth::logout;
pub use codex_core::auth::save_auth;
pub use codex_core::token_data::TokenData;
pub use dotenv_api_key::upsert_dotenv_api_key;
pub use dotenv_api_key::validate_dotenv_target;
pub use onboard_oauth_helper::ApiProvisionOptions;
pub use onboard_oauth_helper::HelperError as OnboardOauthHelperError;
pub use onboard_oauth_helper::PendingApiProvisioning;
pub use onboard_oauth_helper::ProvisionedApiKey;
pub use onboard_oauth_helper::run_from_env as run_onboard_oauth_helper_from_env;
pub use onboard_oauth_helper::start_api_provisioning;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,187 @@
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
#[test]
fn select_active_organization_prefers_default_then_personal_then_first() {
let organizations = vec![
Organization {
id: "org-first".to_string(),
title: Some("First".to_string()),
is_default: false,
personal: false,
},
Organization {
id: "org-personal".to_string(),
title: Some("Personal".to_string()),
is_default: false,
personal: true,
},
Organization {
id: "org-default".to_string(),
title: Some("Default".to_string()),
is_default: true,
personal: false,
},
];
let selected = select_active_organization(&organizations);
assert_eq!(selected, organizations.get(2));
}
#[test]
fn find_default_project_returns_initial_project() {
let projects = vec![
Project {
id: "proj-secondary".to_string(),
title: Some("Secondary".to_string()),
is_initial: false,
},
Project {
id: "proj-default".to_string(),
title: Some("Default".to_string()),
is_initial: true,
},
];
let selected = find_default_project(&projects);
assert_eq!(selected, projects.get(1));
}
#[test]
fn sync_codex_api_key_writes_expected_auth_json() {
let temp_dir = tempdir().expect("tempdir");
let auth_path = temp_dir.path().join("codex").join("auth.json");
sync_codex_api_key("sk-test-key", &auth_path).expect("sync auth");
let written = std::fs::read_to_string(&auth_path).expect("read auth");
let parsed: AuthDotJson = serde_json::from_str(&written).expect("parse auth");
assert_eq!(
parsed,
AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("sk-test-key".to_string()),
tokens: None,
last_refresh: None,
}
);
}
#[tokio::test]
async fn provision_from_authorization_code_provisions_api_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_json(json!({
"client_id": "client-123",
"code_verifier": "verifier-123",
"code": "auth-code-123",
"grant_type": "authorization_code",
"redirect_uri": "http://localhost:5000/auth/callback",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id_token": "id-token-123",
"access_token": "oauth-access-123",
"refresh_token": "oauth-refresh-123",
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/dashboard/onboarding/login"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"user": {
"session": {
"sensitive_id": "session-123",
}
}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/organizations"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{
"id": "org-default",
"title": "Default Org",
"is_default": true,
}
]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/dashboard/organizations/org-default/projects"))
.and(query_param("detail", "basic"))
.and(query_param("limit", "100"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{
"id": "proj-default",
"title": "Default Project",
"is_initial": true,
}
]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path(
"/dashboard/organizations/org-default/projects/proj-default/api_keys",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"key": {
"sensitive_id": "sk-proj-123",
}
})))
.mount(&server)
.await;
let options = ApiProvisionOptions {
issuer: server.uri(),
client_id: "client-123".to_string(),
audience: PLATFORM_AUDIENCE.to_string(),
api_base: server.uri(),
app: DEFAULT_APP.to_string(),
callback_port: DEFAULT_CALLBACK_PORT,
scope: DEFAULT_SCOPE.to_string(),
api_key_name: DEFAULT_PROJECT_API_KEY_NAME.to_string(),
project_poll_interval_seconds: 1,
project_poll_timeout_seconds: 5,
};
let client = build_http_client().expect("client");
let output = provision_from_authorization_code(
&client,
&options,
"http://localhost:5000/auth/callback",
"verifier-123",
"auth-code-123",
)
.await
.expect("provision");
assert_eq!(
output,
ProvisionedApiKey {
sensitive_id: "session-123".to_string(),
organization_id: "org-default".to_string(),
organization_title: Some("Default Org".to_string()),
default_project_id: "proj-default".to_string(),
default_project_title: Some("Default Project".to_string()),
project_api_key: "sk-proj-123".to_string(),
access_token: "oauth-access-123".to_string(),
}
);
}

View File

@@ -0,0 +1,224 @@
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use codex_core::AuthManager;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::login_with_api_key;
use codex_login::ApiProvisionOptions;
use codex_login::CODEX_API_KEY_ENV_VAR;
use codex_login::PendingApiProvisioning;
use codex_login::ProvisionedApiKey;
use codex_login::start_api_provisioning;
use codex_login::upsert_dotenv_api_key;
use codex_login::validate_dotenv_target;
use codex_protocol::config_types::ForcedLoginMethod;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::history_cell;
use crate::history_cell::PlainHistoryCell;
pub(crate) struct ApiProvisionStartMessage {
pub(crate) message: String,
pub(crate) hint: Option<String>,
}
pub(crate) fn start_command(
app_event_tx: AppEventSender,
auth_manager: Arc<AuthManager>,
codex_home: PathBuf,
cwd: PathBuf,
forced_login_method: Option<ForcedLoginMethod>,
) -> Result<ApiProvisionStartMessage, String> {
let dotenv_path = cwd.join(".env");
let start_hint = format!(
"Codex will save {CODEX_API_KEY_ENV_VAR} to {path} and hot-apply it here when allowed.",
path = dotenv_path.display()
);
validate_dotenv_target(&dotenv_path).map_err(|err| {
format!(
"Unable to prepare {} for {CODEX_API_KEY_ENV_VAR}: {err}",
dotenv_path.display(),
)
})?;
let session = start_api_provisioning(ApiProvisionOptions::default())
.map_err(|err| format!("Failed to start API provisioning: {err}"))?;
if !session.open_browser() {
return Err(
"Failed to open your browser for API provisioning. Try again from a desktop session or use the helper binary."
.to_string(),
);
}
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;
app_event_tx_for_task.send(AppEvent::InsertHistoryCell(Box::new(cell)));
});
Ok(ApiProvisionStartMessage {
message: "Opening your browser to provision a project API key.".to_string(),
hint: Some(start_hint),
})
}
async fn complete_command(
session: PendingApiProvisioning,
dotenv_path: PathBuf,
codex_home: PathBuf,
forced_login_method: Option<ForcedLoginMethod>,
auth_manager: Arc<AuthManager>,
) -> PlainHistoryCell {
let provisioned = match session.finish().await {
Ok(provisioned) => provisioned,
Err(err) => {
return history_cell::new_error_event(format!("API provisioning failed: {err}"));
}
};
if let Err(err) = upsert_dotenv_api_key(&dotenv_path, &provisioned.project_api_key) {
return history_cell::new_error_event(format!(
"Provisioning completed, but Codex could not update {}: {err}",
dotenv_path.display()
));
}
success_cell(
&provisioned,
&dotenv_path,
live_apply_api_key(
forced_login_method,
&codex_home,
&provisioned.project_api_key,
auth_manager,
),
)
}
fn live_apply_api_key(
forced_login_method: Option<ForcedLoginMethod>,
codex_home: &Path,
api_key: &str,
auth_manager: Arc<AuthManager>,
) -> LiveApplyOutcome {
if matches!(forced_login_method, Some(ForcedLoginMethod::Chatgpt)) {
return LiveApplyOutcome::Skipped(
"Saved the key to .env, but left this session unchanged because ChatGPT login is required here."
.to_string(),
);
}
match login_with_api_key(codex_home, api_key, AuthCredentialsStoreMode::Ephemeral) {
Ok(()) => {
auth_manager.reload();
LiveApplyOutcome::Applied
}
Err(err) => LiveApplyOutcome::Failed(err.to_string()),
}
}
fn success_cell(
provisioned: &ProvisionedApiKey,
dotenv_path: &Path,
live_apply_outcome: LiveApplyOutcome,
) -> PlainHistoryCell {
let organization = provisioned
.organization_title
.clone()
.unwrap_or_else(|| provisioned.organization_id.clone());
let project = provisioned
.default_project_title
.clone()
.unwrap_or_else(|| provisioned.default_project_id.clone());
let hint = match live_apply_outcome {
LiveApplyOutcome::Applied => Some(
"Updated this session to use the newly provisioned API key without touching auth.json."
.to_string(),
),
LiveApplyOutcome::Skipped(reason) => Some(reason),
LiveApplyOutcome::Failed(err) => Some(format!(
"Saved the key to {}, but could not hot-apply it in this session: {err}",
dotenv_path.display()
)),
};
history_cell::new_info_event(
format!(
"Provisioned an API key for {organization} / {project} and saved {CODEX_API_KEY_ENV_VAR} to {}.",
dotenv_path.display()
),
hint,
)
}
enum LiveApplyOutcome {
Applied,
Skipped(String),
Failed(String),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::history_cell::HistoryCell;
use insta::assert_snapshot;
#[test]
fn success_cell_snapshot() {
let cell = success_cell(
&ProvisionedApiKey {
sensitive_id: "session-123".to_string(),
organization_id: "org-default".to_string(),
organization_title: Some("Default Org".to_string()),
default_project_id: "proj-default".to_string(),
default_project_title: Some("Default Project".to_string()),
project_api_key: "sk-proj-123".to_string(),
access_token: "oauth-access-123".to_string(),
},
Path::new("/tmp/workspace/.env"),
LiveApplyOutcome::Applied,
);
assert_snapshot!(render_cell(&cell));
}
#[test]
fn success_cell_snapshot_when_live_apply_is_skipped() {
let cell = success_cell(
&ProvisionedApiKey {
sensitive_id: "session-123".to_string(),
organization_id: "org-default".to_string(),
organization_title: None,
default_project_id: "proj-default".to_string(),
default_project_title: None,
project_api_key: "sk-proj-123".to_string(),
access_token: "oauth-access-123".to_string(),
},
Path::new("/tmp/workspace/.env"),
LiveApplyOutcome::Skipped(
"Saved the key to .env, but left this session unchanged because ChatGPT login is required here."
.to_string(),
),
);
assert_snapshot!(render_cell(&cell));
}
fn render_cell(cell: &PlainHistoryCell) -> String {
cell.display_lines(120)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n")
}
}

View File

@@ -4497,6 +4497,23 @@ impl ChatWidget {
tx.send(AppEvent::DiffResult(text));
});
}
SlashCommand::ApiProvision => {
let cwd = self.status_line_cwd().to_path_buf();
match crate::api_provision::start_command(
self.app_event_tx.clone(),
self.auth_manager.clone(),
self.config.codex_home.clone(),
cwd,
self.config.forced_login_method,
) {
Ok(start_message) => {
self.add_info_message(start_message.message, start_message.hint);
}
Err(err) => {
self.add_error_message(err);
}
}
}
SlashCommand::Copy => {
let Some(text) = self.last_copyable_output.as_deref() else {
self.add_info_message(

View File

@@ -66,6 +66,7 @@ use tracing_subscriber::prelude::*;
use uuid::Uuid;
mod additional_dirs;
mod api_provision;
mod app;
mod app_backtrack;
mod app_event;

View File

@@ -34,6 +34,7 @@ pub enum SlashCommand {
Agent,
// Undo,
Diff,
ApiProvision,
Copy,
Mention,
Status,
@@ -80,6 +81,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::ApiProvision => "provision an API key and save it to .env",
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",
@@ -151,6 +153,7 @@ impl SlashCommand {
| SlashCommand::Experimental
| SlashCommand::Review
| SlashCommand::Plan
| SlashCommand::ApiProvision
| SlashCommand::Clear
| SlashCommand::Logout
| SlashCommand::MemoryDrop
@@ -214,4 +217,15 @@ mod tests {
fn clean_alias_parses_to_stop_command() {
assert_eq!(SlashCommand::from_str("clean"), Ok(SlashCommand::Stop));
}
#[test]
fn api_provision_command_metadata() {
assert_eq!(
SlashCommand::from_str("api-provision"),
Ok(SlashCommand::ApiProvision)
);
assert_eq!(SlashCommand::ApiProvision.command(), "api-provision");
assert!(!SlashCommand::ApiProvision.supports_inline_args());
assert!(!SlashCommand::ApiProvision.available_during_task());
}
}

View File

@@ -0,0 +1,6 @@
---
source: tui/src/api_provision.rs
assertion_line: 192
expression: render_cell(&cell)
---
• Provisioned an API key for Default Org / Default Project and saved CODEX_API_KEY to /tmp/workspace/.env. Updated this session to use the newly provisioned API key without touching auth.json.

View File

@@ -0,0 +1,6 @@
---
source: tui/src/api_provision.rs
assertion_line: 214
expression: render_cell(&cell)
---
• Provisioned an API key for org-default / proj-default and saved CODEX_API_KEY to /tmp/workspace/.env. Saved the key to .env, but left this session unchanged because ChatGPT login is required here.