diff --git a/codex-rs/login/src/bin/get_sensitive_id_via_codex_oauth.rs b/codex-rs/login/src/bin/get_sensitive_id_via_codex_oauth.rs new file mode 100644 index 0000000000..e2d1fe5c0c --- /dev/null +++ b/codex-rs/login/src/bin/get_sensitive_id_via_codex_oauth.rs @@ -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); + } +} diff --git a/codex-rs/login/src/dotenv_api_key.rs b/codex-rs/login/src/dotenv_api_key.rs new file mode 100644 index 0000000000..4242b62558 --- /dev/null +++ b/codex-rs/login/src/dotenv_api_key.rs @@ -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" + ); + } +} diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 9ec6f1a1df..5f880cd7f4 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -2,6 +2,8 @@ pub mod auth; pub mod token_data; mod device_code_auth; +mod dotenv_api_key; +mod onboard_oauth_helper; mod pkce; mod server; @@ -35,3 +37,11 @@ pub use auth::read_openai_api_key_from_env; pub use auth::save_auth; pub use codex_app_server_protocol::AuthMode; pub use 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; diff --git a/codex-rs/login/src/onboard_oauth_helper.rs b/codex-rs/login/src/onboard_oauth_helper.rs new file mode 100644 index 0000000000..293894f0f9 --- /dev/null +++ b/codex-rs/login/src/onboard_oauth_helper.rs @@ -0,0 +1,1063 @@ +//! Browser-based helper for onboarding login and Codex auth provisioning. + +use std::collections::HashMap; +use std::fs::OpenOptions; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::mpsc::Receiver; +use std::sync::mpsc::channel; +use std::thread::JoinHandle; +use std::time::Duration; + +use base64::Engine; +use codex_app_server_protocol::AuthMode; +use codex_client::build_reqwest_client_with_custom_ca; +use codex_core::auth::AuthDotJson; +use codex_core::config::find_codex_home; +use rand::RngCore; +use reqwest::Client; +use reqwest::Method; +use serde::Deserialize; +use serde::Serialize; +use sha2::Digest; +use sha2::Sha256; +use tiny_http::Header; +use tiny_http::Response; +use tiny_http::Server; +use tiny_http::StatusCode; +use url::Url; + +const AUTH_ISSUER: &str = "https://auth.openai.com"; +const PLATFORM_HYDRA_CLIENT_ID: &str = "app_2SKx67EdpoN0G6j64rFvigXD"; +const PLATFORM_AUDIENCE: &str = "https://api.openai.com/v1"; +const DEFAULT_API_BASE: &str = "https://api.openai.com"; +const DEFAULT_CALLBACK_PORT: u16 = 5000; +const DEFAULT_CALLBACK_PATH: &str = "/auth/callback"; +const DEFAULT_SCOPE: &str = "openid email profile offline_access"; +const DEFAULT_APP: &str = "api"; +const DEFAULT_USER_AGENT: &str = "OpenAI-Onboard-Auth-Script/1.0"; +const DEFAULT_PROJECT_API_KEY_NAME: &str = "Codex CLI"; +const DEFAULT_PROJECT_POLL_INTERVAL_SECONDS: u64 = 10; +const DEFAULT_PROJECT_POLL_TIMEOUT_SECONDS: u64 = 60; +const OAUTH_TIMEOUT_SECONDS: u64 = 15 * 60; +const HTTP_TIMEOUT_SECONDS: u64 = 30; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiProvisionOptions { + pub issuer: String, + pub client_id: String, + pub audience: String, + pub api_base: String, + pub app: String, + pub callback_port: u16, + pub scope: String, + pub api_key_name: String, + pub project_poll_interval_seconds: u64, + pub project_poll_timeout_seconds: u64, +} + +impl Default for ApiProvisionOptions { + fn default() -> Self { + Self { + issuer: AUTH_ISSUER.to_string(), + client_id: PLATFORM_HYDRA_CLIENT_ID.to_string(), + audience: PLATFORM_AUDIENCE.to_string(), + api_base: DEFAULT_API_BASE.to_string(), + 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: DEFAULT_PROJECT_POLL_INTERVAL_SECONDS, + project_poll_timeout_seconds: DEFAULT_PROJECT_POLL_TIMEOUT_SECONDS, + } + } +} + +pub struct PendingApiProvisioning { + client: Client, + options: ApiProvisionOptions, + redirect_uri: String, + code_verifier: String, + callback_server: LocalCallbackServer, + auth_url: String, +} + +impl PendingApiProvisioning { + pub fn auth_url(&self) -> &str { + &self.auth_url + } + + pub fn open_browser(&self) -> bool { + webbrowser::open(&self.auth_url).is_ok() + } + + pub async fn finish(self) -> Result { + let code = self + .callback_server + .wait_for_code(Duration::from_secs(OAUTH_TIMEOUT_SECONDS))?; + provision_from_authorization_code( + &self.client, + &self.options, + &self.redirect_uri, + &self.code_verifier, + &code, + ) + .await + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProvisionedApiKey { + pub sensitive_id: String, + pub organization_id: String, + pub organization_title: Option, + pub default_project_id: String, + pub default_project_title: Option, + pub project_api_key: String, + pub access_token: String, +} + +pub fn start_api_provisioning( + options: ApiProvisionOptions, +) -> Result { + validate_api_provision_options(&options)?; + let client = build_http_client()?; + let pkce = generate_pkce(); + let state = generate_state(); + let callback_server = + LocalCallbackServer::bind(options.callback_port, DEFAULT_CALLBACK_PATH, &state)?; + let redirect_uri = callback_server.redirect_uri.clone(); + let auth_url = build_authorize_url(&options, &redirect_uri, &pkce, &state)?; + + Ok(PendingApiProvisioning { + client, + options, + redirect_uri, + code_verifier: pkce.code_verifier, + callback_server, + auth_url, + }) +} + +pub async fn run_from_env() -> Result<(), HelperError> { + match parse_args(std::env::args())? { + ParseOutcome::Help(help) => { + println!("{help}"); + Ok(()) + } + ParseOutcome::Run(options) => { + let auth_path = resolve_codex_auth_path(options.codex_auth_path.as_deref())?; + let session = start_api_provisioning(options.api_provision_options())?; + open_browser_or_print(session.auth_url()); + let provisioned = session.finish().await?; + let codex_auth_synced = !options.skip_codex_auth_sync; + if codex_auth_synced { + sync_codex_api_key(&provisioned.project_api_key, &auth_path)?; + eprintln!("Synced project API key to {}.", auth_path.display()); + } else { + eprintln!("Skipping Codex auth sync."); + } + let output = ScriptOutput { + sensitive_id: provisioned.sensitive_id, + organization_id: provisioned.organization_id, + organization_title: provisioned.organization_title, + default_project_id: provisioned.default_project_id, + default_project_title: provisioned.default_project_title, + project_api_key: provisioned.project_api_key, + codex_auth_path: auth_path.display().to_string(), + codex_auth_synced, + access_token: options + .include_access_token + .then_some(provisioned.access_token), + }; + print_output(&output, options.output)?; + Ok(()) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CliOptions { + issuer: String, + client_id: String, + audience: String, + api_base: String, + app: String, + callback_port: u16, + scope: String, + api_key_name: String, + project_poll_interval_seconds: u64, + project_poll_timeout_seconds: u64, + codex_auth_path: Option, + skip_codex_auth_sync: bool, + include_access_token: bool, + output: OutputFormat, +} + +impl Default for CliOptions { + fn default() -> Self { + Self { + issuer: AUTH_ISSUER.to_string(), + client_id: PLATFORM_HYDRA_CLIENT_ID.to_string(), + audience: PLATFORM_AUDIENCE.to_string(), + api_base: DEFAULT_API_BASE.to_string(), + 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: DEFAULT_PROJECT_POLL_INTERVAL_SECONDS, + project_poll_timeout_seconds: DEFAULT_PROJECT_POLL_TIMEOUT_SECONDS, + codex_auth_path: None, + skip_codex_auth_sync: false, + include_access_token: false, + output: OutputFormat::Json, + } + } +} + +impl CliOptions { + fn api_provision_options(&self) -> ApiProvisionOptions { + ApiProvisionOptions { + issuer: self.issuer.clone(), + client_id: self.client_id.clone(), + audience: self.audience.clone(), + api_base: self.api_base.clone(), + app: self.app.clone(), + callback_port: self.callback_port, + scope: self.scope.clone(), + api_key_name: self.api_key_name.clone(), + project_poll_interval_seconds: self.project_poll_interval_seconds, + project_poll_timeout_seconds: self.project_poll_timeout_seconds, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputFormat { + Json, + SensitiveId, + ApiKey, +} + +impl OutputFormat { + fn parse(raw: &str) -> Result { + match raw { + "json" => Ok(Self::Json), + "sensitive_id" => Ok(Self::SensitiveId), + "api_key" => Ok(Self::ApiKey), + _ => Err(HelperError::message(format!( + "invalid value for `--output`: `{raw}`" + ))), + } + } +} + +enum ParseOutcome { + Help(String), + Run(CliOptions), +} + +fn parse_args(args: I) -> Result +where + I: IntoIterator, +{ + let mut args = args.into_iter(); + let program = args + .next() + .unwrap_or_else(|| "get_sensitive_id_via_codex_oauth".to_string()); + let mut options = CliOptions::default(); + let mut rest = args.peekable(); + while let Some(arg) = rest.next() { + match arg.as_str() { + "-h" | "--help" => return Ok(ParseOutcome::Help(usage(&program))), + "--issuer" => options.issuer = take_value(&mut rest, "--issuer")?, + "--client-id" => options.client_id = take_value(&mut rest, "--client-id")?, + "--audience" => options.audience = take_value(&mut rest, "--audience")?, + "--api-base" => options.api_base = take_value(&mut rest, "--api-base")?, + "--app" => options.app = take_value(&mut rest, "--app")?, + "--callback-port" => { + options.callback_port = + parse_u16(take_value(&mut rest, "--callback-port")?, "--callback-port")? + } + "--scope" => options.scope = take_value(&mut rest, "--scope")?, + "--api-key-name" => options.api_key_name = take_value(&mut rest, "--api-key-name")?, + "--project-poll-interval-seconds" => { + options.project_poll_interval_seconds = parse_u64( + take_value(&mut rest, "--project-poll-interval-seconds")?, + "--project-poll-interval-seconds", + )? + } + "--project-poll-timeout-seconds" => { + options.project_poll_timeout_seconds = parse_u64( + take_value(&mut rest, "--project-poll-timeout-seconds")?, + "--project-poll-timeout-seconds", + )? + } + "--codex-auth-path" => { + options.codex_auth_path = + Some(PathBuf::from(take_value(&mut rest, "--codex-auth-path")?)) + } + "--skip-codex-auth-sync" => options.skip_codex_auth_sync = true, + "--include-access-token" => options.include_access_token = true, + "--output" => { + options.output = OutputFormat::parse(&take_value(&mut rest, "--output")?)? + } + _ => { + return Err(HelperError::message(format!( + "unknown argument `{arg}`\n\n{}", + usage(&program) + ))); + } + } + } + validate_api_provision_options(&options.api_provision_options())?; + Ok(ParseOutcome::Run(options)) +} + +fn validate_api_provision_options(options: &ApiProvisionOptions) -> Result<(), HelperError> { + if options.project_poll_interval_seconds == 0 { + return Err(HelperError::message( + "--project-poll-interval-seconds must be greater than 0.".to_string(), + )); + } + if options.project_poll_timeout_seconds == 0 { + return Err(HelperError::message( + "--project-poll-timeout-seconds must be greater than 0.".to_string(), + )); + } + if options.api_key_name.trim().is_empty() { + return Err(HelperError::message( + "--api-key-name must not be empty.".to_string(), + )); + } + Ok(()) +} + +fn usage(program: &str) -> String { + format!( + "Usage: {program} [OPTIONS]\n\n\ +Options:\n\ + --issuer URL OAuth issuer base URL\n\ + --client-id ID Hydra client id to use\n\ + --audience URL OAuth audience\n\ + --api-base URL Base API URL for onboarding exchange\n\ + --app NAME `app` value for /dashboard/onboarding/login\n\ + --callback-port PORT Local callback port (default: {DEFAULT_CALLBACK_PORT})\n\ + --scope SCOPE OAuth scope string\n\ + --api-key-name NAME Provisioned project API key name\n\ + --project-poll-interval-seconds SEC Delay between default-project checks\n\ + --project-poll-timeout-seconds SEC Maximum wait for organization/project readiness\n\ + --codex-auth-path PATH Explicit auth.json path\n\ + --skip-codex-auth-sync Do not write the provisioned API key to auth.json\n\ + --include-access-token Include the OAuth access token in JSON output\n\ + --output FORMAT One of: json, sensitive_id, api_key\n\ + -h, --help Show this help message" + ) +} + +fn take_value(args: &mut std::iter::Peekable, flag: &str) -> Result +where + I: Iterator, +{ + args.next() + .ok_or_else(|| HelperError::message(format!("missing value for `{flag}`"))) +} + +fn parse_u16(raw: String, flag: &str) -> Result { + raw.parse::() + .map_err(|err| HelperError::message(format!("invalid value for `{flag}`: {err}"))) +} + +fn parse_u64(raw: String, flag: &str) -> Result { + raw.parse::() + .map_err(|err| HelperError::message(format!("invalid value for `{flag}`: {err}"))) +} + +#[derive(Debug, Clone)] +struct PkceCodes { + code_verifier: String, + code_challenge: String, +} + +fn generate_pkce() -> PkceCodes { + let mut bytes = [0u8; 64]; + rand::rng().fill_bytes(&mut bytes); + let code_verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let digest = Sha256::digest(code_verifier.as_bytes()); + let code_challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest); + PkceCodes { + code_verifier, + code_challenge, + } +} + +fn generate_state() -> String { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +struct LocalCallbackServer { + redirect_uri: String, + rx: Receiver>, + server: Arc, + shutdown: Arc, + worker: Option>, +} + +impl LocalCallbackServer { + fn bind(port: u16, callback_path: &str, expected_state: &str) -> Result { + let server = Arc::new(Server::http(format!("127.0.0.1:{port}")).map_err(|err| { + HelperError::message(format!("failed to bind callback server: {err}")) + })?); + let actual_port = server + .server_addr() + .to_ip() + .map(|addr| addr.port()) + .ok_or_else(|| { + HelperError::message("unable to determine callback server port".to_string()) + })?; + let (tx, rx) = channel(); + let callback_path = callback_path.to_string(); + let expected_state = expected_state.to_string(); + let redirect_uri = format!("http://localhost:{actual_port}{callback_path}"); + let shutdown = Arc::new(AtomicBool::new(false)); + let server_for_thread = Arc::clone(&server); + let shutdown_for_thread = Arc::clone(&shutdown); + let callback_path_for_thread = callback_path; + let worker = std::thread::spawn(move || { + callback_loop( + server_for_thread, + shutdown_for_thread, + tx, + callback_path_for_thread, + expected_state, + ); + }); + Ok(Self { + redirect_uri, + rx, + server, + shutdown, + worker: Some(worker), + }) + } + + fn wait_for_code(self, timeout: Duration) -> Result { + match self.rx.recv_timeout(timeout) { + Ok(Ok(code)) => Ok(code), + Ok(Err(message)) => Err(HelperError::message(message)), + Err(_) => Err(HelperError::message( + "OAuth login timed out waiting for the browser callback.".to_string(), + )), + } + } +} + +impl Drop for LocalCallbackServer { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Relaxed); + self.server.unblock(); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +fn callback_loop( + server: Arc, + shutdown: Arc, + tx: std::sync::mpsc::Sender>, + callback_path: String, + expected_state: String, +) { + while !shutdown.load(Ordering::Relaxed) { + let request = match server.recv_timeout(Duration::from_millis(200)) { + Ok(Some(request)) => request, + Ok(None) => continue, + Err(_) => break, + }; + let response = handle_callback_request(request.url(), &callback_path, &expected_state); + let should_exit = response.result.is_some(); + let _ = request.respond(response.response); + if let Some(result) = response.result { + let _ = tx.send(result); + break; + } + if should_exit { + break; + } + } +} + +struct CallbackResponse { + response: Response>>, + result: Option>, +} + +fn handle_callback_request( + url_raw: &str, + callback_path: &str, + expected_state: &str, +) -> CallbackResponse { + let parsed_url = match Url::parse(&format!("http://localhost{url_raw}")) { + Ok(url) => url, + Err(err) => { + return CallbackResponse { + response: html_response(400, format!("

Bad Request

{err}

")), + result: None, + }; + } + }; + if parsed_url.path() != callback_path { + return CallbackResponse { + response: html_response(404, "

Not Found

".to_string()), + result: None, + }; + } + + let params: HashMap = parsed_url.query_pairs().into_owned().collect(); + if params.get("state").map(String::as_str) != Some(expected_state) { + return CallbackResponse { + response: html_response(400, "

State mismatch

".to_string()), + result: Some(Err("State mismatch in OAuth callback.".to_string())), + }; + } + if let Some(error_code) = params.get("error") { + let message = oauth_callback_error_message( + error_code, + params.get("error_description").map(String::as_str), + ); + return CallbackResponse { + response: html_response( + 403, + "

Sign-in failed

Return to your terminal.

".to_string(), + ), + result: Some(Err(message)), + }; + } + match params.get("code") { + Some(code) if !code.is_empty() => CallbackResponse { + response: html_response( + 200, + "

Sign-in complete

You can return to your terminal.

".to_string(), + ), + result: Some(Ok(code.clone())), + }, + _ => CallbackResponse { + response: html_response(400, "

Missing authorization code

".to_string()), + result: Some(Err( + "Missing authorization code. Sign-in could not be completed.".to_string(), + )), + }, + } +} + +fn html_response(status: u16, body: String) -> Response>> { + let mut response = Response::from_string(body).with_status_code(StatusCode(status)); + if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) { + response = response.with_header(header); + } + response +} + +fn oauth_callback_error_message(error_code: &str, error_description: Option<&str>) -> String { + match error_description { + Some(description) if !description.is_empty() => { + format!("OAuth callback error ({error_code}): {description}") + } + _ => format!("OAuth callback error ({error_code})."), + } +} + +fn open_browser_or_print(auth_url: &str) { + let opened = webbrowser::open(auth_url).is_ok(); + if opened { + eprintln!( + "Starting local login server.\nIf your browser did not open, navigate to this URL to authenticate:\n\n{auth_url}" + ); + } else { + eprintln!( + "Starting local login server.\nOpen this URL in your browser to authenticate:\n\n{auth_url}" + ); + } +} + +fn build_authorize_url( + options: &ApiProvisionOptions, + redirect_uri: &str, + pkce: &PkceCodes, + state: &str, +) -> Result { + let mut url = Url::parse(&format!( + "{}/oauth/authorize", + options.issuer.trim_end_matches('/') + )) + .map_err(|err| HelperError::message(format!("invalid issuer URL: {err}")))?; + url.query_pairs_mut() + .append_pair("audience", &options.audience) + .append_pair("client_id", &options.client_id) + .append_pair("code_challenge_method", "S256") + .append_pair("code_challenge", &pkce.code_challenge) + .append_pair("redirect_uri", redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", &options.scope) + .append_pair("state", state); + Ok(url.to_string()) +} + +fn build_http_client() -> Result { + build_reqwest_client_with_custom_ca( + reqwest::Client::builder().timeout(Duration::from_secs(HTTP_TIMEOUT_SECONDS)), + ) + .map_err(|err| HelperError::message(format!("failed to build HTTP client: {err}"))) +} + +async fn provision_from_authorization_code( + client: &Client, + options: &ApiProvisionOptions, + redirect_uri: &str, + code_verifier: &str, + code: &str, +) -> Result { + let tokens = exchange_authorization_code_for_tokens( + client, + &options.issuer, + &options.client_id, + redirect_uri, + code_verifier, + code, + ) + .await?; + let login = onboarding_login( + client, + &options.api_base, + &options.app, + &tokens.access_token, + ) + .await?; + let target = wait_for_default_project( + client, + &options.api_base, + &login.user.session.sensitive_id, + options.project_poll_interval_seconds, + options.project_poll_timeout_seconds, + ) + .await?; + let api_key = create_project_api_key( + client, + &options.api_base, + &login.user.session.sensitive_id, + &target, + &options.api_key_name, + ) + .await? + .key + .sensitive_id; + + Ok(ProvisionedApiKey { + sensitive_id: login.user.session.sensitive_id, + organization_id: target.organization_id, + organization_title: target.organization_title, + default_project_id: target.project_id, + default_project_title: target.project_title, + project_api_key: api_key, + access_token: tokens.access_token, + }) +} + +async fn exchange_authorization_code_for_tokens( + client: &Client, + issuer: &str, + client_id: &str, + redirect_uri: &str, + code_verifier: &str, + code: &str, +) -> Result { + let url = format!("{}/oauth/token", issuer.trim_end_matches('/')); + execute_json( + client + .request(Method::POST, &url) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT) + .json(&serde_json::json!({ + "client_id": client_id, + "code_verifier": code_verifier, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + })), + "POST", + &url, + ) + .await +} + +async fn onboarding_login( + client: &Client, + api_base: &str, + app: &str, + access_token: &str, +) -> Result { + let url = format!( + "{}/dashboard/onboarding/login", + api_base.trim_end_matches('/') + ); + execute_json( + client + .request(Method::POST, &url) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT) + .bearer_auth(access_token) + .json(&serde_json::json!({ "app": app })), + "POST", + &url, + ) + .await +} + +async fn list_organizations( + client: &Client, + api_base: &str, + session_key: &str, +) -> Result, HelperError> { + let url = format!("{}/v1/organizations", api_base.trim_end_matches('/')); + let response: DataList = execute_json( + client + .request(Method::GET, &url) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT) + .bearer_auth(session_key), + "GET", + &url, + ) + .await?; + Ok(response.data) +} + +async fn list_projects( + client: &Client, + api_base: &str, + session_key: &str, + organization_id: &str, +) -> Result, HelperError> { + let url = format!( + "{}/dashboard/organizations/{}/projects?detail=basic&limit=100", + api_base.trim_end_matches('/'), + urlencoding::encode(organization_id) + ); + let response: DataList = execute_json( + client + .request(Method::GET, &url) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT) + .header("openai-organization", organization_id) + .bearer_auth(session_key), + "GET", + &url, + ) + .await?; + Ok(response.data) +} + +async fn wait_for_default_project( + client: &Client, + api_base: &str, + session_key: &str, + poll_interval_seconds: u64, + timeout_seconds: u64, +) -> Result { + let deadline = std::time::Instant::now() + Duration::from_secs(timeout_seconds); + loop { + let organizations = list_organizations(client, api_base, session_key).await?; + let last_state = if let Some(organization) = select_active_organization(&organizations) { + let projects = list_projects(client, api_base, session_key, &organization.id).await?; + if let Some(project) = find_default_project(&projects) { + return Ok(ProvisioningTarget { + organization_id: organization.id.clone(), + organization_title: organization.title.clone(), + project_id: project.id.clone(), + project_title: project.title.clone(), + }); + } + format!( + "organization `{}` exists, but no default project is ready yet (saw {} projects).", + organization.id, + projects.len() + ) + } else { + "no organization found".to_string() + }; + + if std::time::Instant::now() >= deadline { + return Err(HelperError::message(format!( + "Timed out waiting for an organization and default project. Last observed state: {last_state}" + ))); + } + let remaining_seconds = deadline + .saturating_duration_since(std::time::Instant::now()) + .as_secs(); + let sleep_seconds = poll_interval_seconds.min(remaining_seconds.max(1)); + std::thread::sleep(Duration::from_secs(sleep_seconds)); + } +} + +fn select_active_organization(organizations: &[Organization]) -> Option<&Organization> { + organizations + .iter() + .find(|organization| organization.is_default) + .or_else(|| { + organizations + .iter() + .find(|organization| organization.personal) + }) + .or_else(|| organizations.first()) +} + +fn find_default_project(projects: &[Project]) -> Option<&Project> { + projects.iter().find(|project| project.is_initial) +} + +async fn create_project_api_key( + client: &Client, + api_base: &str, + session_key: &str, + target: &ProvisioningTarget, + key_name: &str, +) -> Result { + let url = format!( + "{}/dashboard/organizations/{}/projects/{}/api_keys", + api_base.trim_end_matches('/'), + urlencoding::encode(&target.organization_id), + urlencoding::encode(&target.project_id) + ); + execute_json( + client + .request(Method::POST, &url) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT) + .bearer_auth(session_key) + .json(&serde_json::json!({ + "action": "create", + "name": key_name, + })), + "POST", + &url, + ) + .await +} + +async fn execute_json( + request: reqwest::RequestBuilder, + method: &str, + url: &str, +) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let response = request + .send() + .await + .map_err(|err| HelperError::message(format!("Network error calling {url}: {err}")))?; + let status = response.status(); + let body = response.bytes().await.map_err(|err| { + HelperError::message(format!("Failed reading response from {url}: {err}")) + })?; + if !status.is_success() { + return Err(HelperError::api( + format!("{method} {url} failed with HTTP {status}"), + String::from_utf8_lossy(&body).into_owned(), + )); + } + serde_json::from_slice(&body) + .map_err(|err| HelperError::message(format!("{url} returned invalid JSON: {err}"))) +} + +fn resolve_codex_auth_path(explicit: Option<&Path>) -> Result { + match explicit { + Some(path) => Ok(path.to_path_buf()), + None => Ok(find_codex_home() + .map_err(|err| HelperError::message(format!("failed to resolve CODEX_HOME: {err}")))? + .join("auth.json")), + } +} + +fn sync_codex_api_key(api_key: &str, auth_path: &Path) -> Result<(), HelperError> { + if let Some(parent) = auth_path.parent() { + std::fs::create_dir_all(parent).map_err(|err| { + HelperError::message(format!( + "Failed to create auth directory {}: {err}", + parent.display() + )) + })?; + } + let auth = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some(api_key.to_string()), + tokens: None, + last_refresh: None, + }; + let json = format!( + "{}\n", + serde_json::to_string_pretty(&auth).map_err(|err| { + HelperError::message(format!("failed to serialize auth.json contents: {err}")) + })? + ); + let mut options = OpenOptions::new(); + options.truncate(true).write(true).create(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(auth_path).map_err(|err| { + HelperError::message(format!( + "Failed to open {} for writing: {err}", + auth_path.display() + )) + })?; + file.write_all(json.as_bytes()).map_err(|err| { + HelperError::message(format!("Failed to write {}: {err}", auth_path.display())) + })?; + file.flush().map_err(|err| { + HelperError::message(format!("Failed to flush {}: {err}", auth_path.display())) + }) +} + +fn print_output(output: &ScriptOutput, format: OutputFormat) -> Result<(), HelperError> { + match format { + OutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(output).map_err(|err| { + HelperError::message(format!("failed to serialize output: {err}")) + })? + ); + } + OutputFormat::SensitiveId => println!("{}", output.sensitive_id), + OutputFormat::ApiKey => println!("{}", output.project_api_key), + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ScriptOutput { + sensitive_id: String, + organization_id: String, + organization_title: Option, + default_project_id: String, + default_project_title: Option, + project_api_key: String, + codex_auth_path: String, + codex_auth_synced: bool, + #[serde(skip_serializing_if = "Option::is_none")] + access_token: Option, +} + +#[derive(Debug, Deserialize)] +struct OAuthTokens { + #[serde(rename = "id_token")] + _id_token: String, + access_token: String, + #[serde(rename = "refresh_token")] + _refresh_token: String, +} + +#[derive(Debug, Deserialize)] +struct OnboardingLoginResponse { + user: OnboardingUser, +} + +#[derive(Debug, Deserialize)] +struct OnboardingUser { + session: OnboardingSession, +} + +#[derive(Debug, Deserialize)] +struct OnboardingSession { + sensitive_id: String, +} + +#[derive(Debug, Deserialize)] +struct DataList { + data: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +struct Organization { + id: String, + title: Option, + #[serde(default)] + is_default: bool, + #[serde(default)] + personal: bool, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +struct Project { + id: String, + title: Option, + #[serde(default)] + is_initial: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProvisioningTarget { + organization_id: String, + organization_title: Option, + project_id: String, + project_title: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateApiKeyResponse { + key: CreatedApiKey, +} + +#[derive(Debug, Deserialize)] +struct CreatedApiKey { + sensitive_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HelperError { + message: String, + body: Option, +} + +impl HelperError { + fn message(message: String) -> Self { + Self { + message, + body: None, + } + } + + fn api(message: String, body: String) -> Self { + Self { + message, + body: Some(body), + } + } + + pub fn body(&self) -> Option<&str> { + self.body.as_deref() + } +} + +impl std::fmt::Display for HelperError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for HelperError {} + +#[cfg(test)] +#[path = "onboard_oauth_helper_tests.rs"] +mod tests; diff --git a/codex-rs/login/src/onboard_oauth_helper_tests.rs b/codex-rs/login/src/onboard_oauth_helper_tests.rs new file mode 100644 index 0000000000..e179c1c91d --- /dev/null +++ b/codex-rs/login/src/onboard_oauth_helper_tests.rs @@ -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(), + } + ); +} diff --git a/codex-rs/tui/src/api_provision.rs b/codex-rs/tui/src/api_provision.rs new file mode 100644 index 0000000000..b334dec702 --- /dev/null +++ b/codex-rs/tui/src/api_provision.rs @@ -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, +} + +pub(crate) fn start_command( + app_event_tx: AppEventSender, + auth_manager: Arc, + codex_home: PathBuf, + cwd: PathBuf, + forced_login_method: Option, +) -> Result { + 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, + auth_manager: Arc, +) -> 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, + codex_home: &Path, + api_key: &str, + auth_manager: Arc, +) -> 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::>() + .join("\n") + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 68497f4e07..b2ef264d76 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4733,6 +4733,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( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e291480d0b..d15d31c54e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -61,6 +61,7 @@ use tracing_subscriber::prelude::*; use uuid::Uuid; mod additional_dirs; +mod api_provision; mod app; mod app_backtrack; mod app_event; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ec624d3fb9..121ebff78d 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -34,6 +34,7 @@ pub enum SlashCommand { Agent, // Undo, Diff, + ApiProvision, Copy, Mention, Status, @@ -82,6 +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::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", @@ -155,6 +157,7 @@ impl SlashCommand { | SlashCommand::Experimental | SlashCommand::Review | SlashCommand::Plan + | SlashCommand::ApiProvision | SlashCommand::Clear | SlashCommand::Logout | SlashCommand::MemoryDrop @@ -220,4 +223,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()); + } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__api_provision__tests__success_cell_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__api_provision__tests__success_cell_snapshot.snap new file mode 100644 index 0000000000..b594ca4fdb --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__api_provision__tests__success_cell_snapshot.snap @@ -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. diff --git a/codex-rs/tui/src/snapshots/codex_tui__api_provision__tests__success_cell_snapshot_when_live_apply_is_skipped.snap b/codex-rs/tui/src/snapshots/codex_tui__api_provision__tests__success_cell_snapshot_when_live_apply_is_skipped.snap new file mode 100644 index 0000000000..96f8fe7ec8 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__api_provision__tests__success_cell_snapshot_when_live_apply_is_skipped.snap @@ -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.