diff --git a/codex-rs/login/src/onboard_oauth_helper.rs b/codex-rs/login/src/onboard_oauth_helper.rs index 5787cc9c40..f2cad5cf93 100644 --- a/codex-rs/login/src/onboard_oauth_helper.rs +++ b/codex-rs/login/src/onboard_oauth_helper.rs @@ -1,38 +1,26 @@ //! 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_utils_home_dir::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; use crate::auth::AuthDotJson; +use crate::pkce::PkceCodes; +use crate::server::AuthorizationCodeServer; +use crate::server::start_authorization_code_server; const AUTH_ISSUER: &str = "https://auth.openai.com"; const PLATFORM_HYDRA_CLIENT_ID: &str = "app_2SKx67EdpoN0G6j64rFvigXD"; @@ -85,23 +73,32 @@ pub struct PendingApiProvisioning { options: ApiProvisionOptions, redirect_uri: String, code_verifier: String, - callback_server: LocalCallbackServer, - auth_url: String, + callback_server: AuthorizationCodeServer, } impl PendingApiProvisioning { pub fn auth_url(&self) -> &str { - &self.auth_url + &self.callback_server.auth_url + } + + pub fn callback_port(&self) -> u16 { + self.callback_server.actual_port } pub fn open_browser(&self) -> bool { - webbrowser::open(&self.auth_url).is_ok() + self.callback_server.open_browser() + } + + pub fn open_browser_or_print(&self) -> bool { + self.callback_server.open_browser_or_print() } pub async fn finish(self) -> Result { let code = self .callback_server - .wait_for_code(Duration::from_secs(OAUTH_TIMEOUT_SECONDS))?; + .wait_for_code(Duration::from_secs(OAUTH_TIMEOUT_SECONDS)) + .await + .map_err(|err| HelperError::message(err.to_string()))?; provision_from_authorization_code( &self.client, &self.options, @@ -129,20 +126,24 @@ pub fn start_api_provisioning( ) -> 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 callback_server = start_authorization_code_server( + options.callback_port, + DEFAULT_CALLBACK_PATH, + /*force_state*/ None, + |redirect_uri, pkce, state| { + build_authorize_url(&options, redirect_uri, pkce, state) + .map_err(|err| std::io::Error::other(err.to_string())) + }, + ) + .map_err(|err| HelperError::message(err.to_string()))?; 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, + code_verifier: callback_server.code_verifier().to_string(), callback_server, - auth_url, }) } @@ -155,7 +156,7 @@ pub async fn run_from_env() -> Result<(), HelperError> { 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()); + session.open_browser_or_print(); let provisioned = session.finish().await?; let codex_auth_synced = !options.skip_codex_auth_sync; if codex_auth_synced { @@ -380,222 +381,6 @@ fn parse_u64(raw: String, flag: &str) -> Result { .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( - /*status*/ 400, - format!("

Bad Request

{err}

"), - ), - result: None, - }; - } - }; - if parsed_url.path() != callback_path { - return CallbackResponse { - response: html_response(/*status*/ 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(/*status*/ 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( - /*status*/ 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( - /*status*/ 200, - "

Sign-in complete

You can return to your terminal.

".to_string(), - ), - result: Some(Ok(code.clone())), - }, - _ => CallbackResponse { - response: html_response( - /*status*/ 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, diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index b726eeed8f..b87936c521 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -110,6 +110,67 @@ impl LoginServer { } } +/// Handle for a running authorization-code callback server. +pub(crate) struct AuthorizationCodeServer { + pub auth_url: String, + pub actual_port: u16, + pub redirect_uri: String, + code_verifier: String, + server_handle: tokio::task::JoinHandle>, + shutdown_handle: ShutdownHandle, +} + +impl AuthorizationCodeServer { + pub fn open_browser(&self) -> bool { + webbrowser::open(&self.auth_url).is_ok() + } + + pub fn open_browser_or_print(&self) -> bool { + let opened = self.open_browser(); + if opened { + eprintln!( + "Starting local auth callback server.\nIf your browser did not open, navigate to this URL to continue:\n\n{}", + self.auth_url + ); + } else { + eprintln!( + "Starting local auth callback server.\nOpen this URL in your browser to continue:\n\n{}", + self.auth_url + ); + } + opened + } + + pub fn code_verifier(&self) -> &str { + &self.code_verifier + } + + pub async fn wait_for_code(self, timeout: Duration) -> io::Result { + let AuthorizationCodeServer { + server_handle, + shutdown_handle, + .. + } = self; + let server_handle = server_handle; + tokio::pin!(server_handle); + + tokio::select! { + result = &mut server_handle => { + result + .map_err(|err| io::Error::other(format!("authorization-code server thread panicked: {err:?}")))? + } + _ = tokio::time::sleep(timeout) => { + shutdown_handle.shutdown(); + let _ = server_handle.await; + Err(io::Error::new( + io::ErrorKind::TimedOut, + "OAuth login timed out waiting for the browser callback.", + )) + } + } + } +} + /// Handle used to signal the login server loop to exit. #[derive(Clone, Debug)] pub struct ShutdownHandle { @@ -123,6 +184,92 @@ impl ShutdownHandle { } } +pub(crate) fn start_authorization_code_server( + port: u16, + callback_path: &str, + force_state: Option, + auth_url_builder: F, +) -> io::Result +where + F: FnOnce(&str, &PkceCodes, &str) -> io::Result, +{ + let pkce = generate_pkce(); + let state = force_state.unwrap_or_else(generate_state); + let callback_path = callback_path.to_string(); + + let server = bind_server(port)?; + let actual_port = match server.server_addr().to_ip() { + Some(addr) => addr.port(), + None => { + return Err(io::Error::new( + io::ErrorKind::AddrInUse, + "Unable to determine the server port", + )); + } + }; + let server = Arc::new(server); + + let redirect_uri = format!("http://localhost:{actual_port}{callback_path}"); + let auth_url = auth_url_builder(&redirect_uri, &pkce, &state)?; + + let (tx, mut rx) = tokio::sync::mpsc::channel::(16); + let _server_handle = { + let server = server.clone(); + thread::spawn(move || -> io::Result<()> { + while let Ok(request) = server.recv() { + match tx.blocking_send(request) { + Ok(()) => {} + Err(error) => { + eprintln!("Failed to send request to channel: {error}"); + return Err(io::Error::other("Failed to send request to channel")); + } + } + } + Ok(()) + }) + }; + + let shutdown_notify = Arc::new(tokio::sync::Notify::new()); + let server_handle = { + let shutdown_notify = shutdown_notify.clone(); + let server = server.clone(); + tokio::spawn(async move { + let result = loop { + tokio::select! { + _ = shutdown_notify.notified() => { + break Err(io::Error::other("Authentication was not completed")); + } + maybe_req = rx.recv() => { + let Some(req) = maybe_req else { + break Err(io::Error::other("Authentication was not completed")); + }; + + let url_raw = req.url().to_string(); + let response = + process_authorization_code_request(&url_raw, &callback_path, &state); + + if let Some(result) = respond_to_request(req, response).await { + break result; + } + } + } + }; + + server.unblock(); + result + }) + }; + + Ok(AuthorizationCodeServer { + auth_url, + actual_port, + redirect_uri, + code_verifier: pkce.code_verifier, + server_handle, + shutdown_handle: ShutdownHandle { shutdown_notify }, + }) +} + /// Starts a local callback server and returns the browser auth URL. pub fn run_login_server(opts: ServerOptions) -> io::Result { let pkce = generate_pkce(); @@ -191,30 +338,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { let response = process_request(&url_raw, &opts, &redirect_uri, &pkce, actual_port, &state).await; - let exit_result = match response { - HandledRequest::Response(response) => { - let _ = tokio::task::spawn_blocking(move || req.respond(response)).await; - None - } - HandledRequest::ResponseAndExit { - headers, - body, - result, - } => { - let _ = tokio::task::spawn_blocking(move || { - send_response_with_disconnect(req, headers, body) - }) - .await; - Some(result) - } - HandledRequest::RedirectWithHeader(header) => { - let redirect = Response::empty(302).with_header(header); - let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await; - None - } - }; - - if let Some(result) = exit_result { + if let Some(result) = respond_to_request(req, response).await { break result; } } @@ -237,13 +361,14 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { } /// Internal callback handling outcome. -enum HandledRequest { +enum HandledRequest { Response(Response>>), RedirectWithHeader(Header), ResponseAndExit { + status: StatusCode, headers: Vec
, body: Vec, - result: io::Result<()>, + result: io::Result, }, } @@ -254,7 +379,7 @@ async fn process_request( pkce: &PkceCodes, actual_port: u16, state: &str, -) -> HandledRequest { +) -> HandledRequest<()> { let parsed_url = match url::Url::parse(&format!("http://localhost{url_raw}")) { Ok(u) => u, Err(e) => { @@ -392,6 +517,7 @@ async fn process_request( "/success" => { let body = include_str!("assets/success.html"); HandledRequest::ResponseAndExit { + status: StatusCode(200), headers: match Header::from_bytes( &b"Content-Type"[..], &b"text/html; charset=utf-8"[..], @@ -404,6 +530,7 @@ async fn process_request( } } "/cancel" => HandledRequest::ResponseAndExit { + status: StatusCode(200), headers: Vec::new(), body: b"Login cancelled".to_vec(), result: Err(io::Error::new( @@ -415,6 +542,117 @@ async fn process_request( } } +fn process_authorization_code_request( + url_raw: &str, + callback_path: &str, + expected_state: &str, +) -> HandledRequest { + let parsed_url = match url::Url::parse(&format!("http://localhost{url_raw}")) { + Ok(u) => u, + Err(err) => { + return HandledRequest::Response( + Response::from_string(format!("Bad Request: {err}")).with_status_code(400), + ); + } + }; + + match parsed_url.path() { + "/cancel" => HandledRequest::ResponseAndExit { + status: StatusCode(200), + headers: Vec::new(), + body: b"Login cancelled".to_vec(), + result: Err(io::Error::new( + io::ErrorKind::Interrupted, + "Login cancelled", + )), + }, + path if path == callback_path => { + let params: std::collections::HashMap = + parsed_url.query_pairs().into_owned().collect(); + + if params.get("state").map(String::as_str) != Some(expected_state) { + return HandledRequest::ResponseAndExit { + status: StatusCode(400), + headers: html_headers(), + body: b"

State mismatch

Return to your terminal and try again.

" + .to_vec(), + result: Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "State mismatch in OAuth callback.", + )), + }; + } + + 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 HandledRequest::ResponseAndExit { + status: StatusCode(403), + headers: html_headers(), + body: b"

Sign-in failed

Return to your terminal.

".to_vec(), + result: Err(io::Error::new(io::ErrorKind::PermissionDenied, message)), + }; + } + + match params.get("code") { + Some(code) if !code.is_empty() => HandledRequest::ResponseAndExit { + status: StatusCode(200), + headers: html_headers(), + body: b"

Sign-in complete

You can return to your terminal.

" + .to_vec(), + result: Ok(code.clone()), + }, + _ => HandledRequest::ResponseAndExit { + status: StatusCode(400), + headers: html_headers(), + body: b"

Missing authorization code

Return to your terminal.

" + .to_vec(), + result: Err(io::Error::new( + io::ErrorKind::InvalidData, + "Missing authorization code. Sign-in could not be completed.", + )), + }, + } + } + _ => HandledRequest::Response(Response::from_string("Not Found").with_status_code(404)), + } +} + +fn html_headers() -> Vec
{ + match Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) { + Ok(header) => vec![header], + Err(_) => Vec::new(), + } +} + +async fn respond_to_request(req: Request, response: HandledRequest) -> Option> { + match response { + HandledRequest::Response(response) => { + let _ = tokio::task::spawn_blocking(move || req.respond(response)).await; + None + } + HandledRequest::RedirectWithHeader(header) => { + let redirect = Response::empty(302).with_header(header); + let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await; + None + } + HandledRequest::ResponseAndExit { + status, + headers, + body, + result, + } => { + let _ = tokio::task::spawn_blocking(move || { + send_response_with_disconnect(req, status, headers, body) + }) + .await; + Some(result) + } + } +} + /// tiny_http filters `Connection` headers out of `Response` objects, so using /// `req.respond` never informs the client (or the library) that a keep-alive /// socket should be closed. That leaves the per-connection worker parked in a @@ -426,10 +664,10 @@ async fn process_request( /// server-side connection persistence, but it does not. fn send_response_with_disconnect( req: Request, + status: StatusCode, mut headers: Vec
, body: Vec, ) -> io::Result<()> { - let status = StatusCode(200); let mut writer = req.into_writer(); let reason = status.default_reason_phrase(); write!(writer, "HTTP/1.1 {} {}\r\n", status.0, reason)?; @@ -888,13 +1126,14 @@ fn login_error_response( kind: io::ErrorKind, error_code: Option<&str>, error_description: Option<&str>, -) -> HandledRequest { +) -> HandledRequest<()> { let mut headers = Vec::new(); if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) { headers.push(header); } let body = render_login_error_page(message, error_code, error_description); HandledRequest::ResponseAndExit { + status: StatusCode(200), headers, body, result: Err(io::Error::new(kind, message.to_string())), diff --git a/codex-rs/tui/src/api_provision.rs b/codex-rs/tui/src/api_provision.rs index 6e00ab9107..dbc62bf9f3 100644 --- a/codex-rs/tui/src/api_provision.rs +++ b/codex-rs/tui/src/api_provision.rs @@ -42,12 +42,15 @@ pub(crate) fn start_command( })?; let options = ApiProvisionOptions::default(); - let callback_port = options.callback_port; let session = start_api_provisioning(options) .map_err(|err| format!("Failed to start API provisioning: {err}"))?; - let _ = session.open_browser(); - let start_message = - continue_in_browser_message(session.auth_url(), &dotenv_path, callback_port); + 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; @@ -79,8 +82,9 @@ fn existing_shell_api_key_message() -> PlainHistoryCell { fn continue_in_browser_message( auth_url: &str, - dotenv_path: &Path, callback_port: u16, + dotenv_path: &Path, + browser_opened: bool, ) -> PlainHistoryCell { let mut lines = vec![ vec![ @@ -91,9 +95,17 @@ fn continue_in_browser_message( "".into(), ]; - lines.push( - " If the link doesn't open automatically, open the following link to authenticate:".into(), - ); + if browser_opened { + lines.push(" Codex tried to open this link for you.".dark_gray().into()); + } else { + lines.push( + " Codex couldn't auto-open your browser, but the provisioning flow is still waiting." + .dark_gray() + .into(), + ); + } + lines.push("".into()); + lines.push(" Open the following link to authenticate:".into()); lines.push("".into()); lines.push(Line::from(vec![ " ".into(), @@ -111,7 +123,7 @@ fn continue_in_browser_message( lines.push("".into()); lines.push( format!( - " On a remote or headless machine, forward localhost:{callback_port} to this Codex host before opening the link." + " On a remote or headless machine, forward localhost:{callback_port} back to this Codex host before opening the link." ) .dark_gray() .into(), @@ -265,8 +277,9 @@ mod tests { fn continue_in_browser_message_snapshot() { let cell = continue_in_browser_message( "https://auth.openai.com/oauth/authorize?client_id=abc", - Path::new("/tmp/workspace/.env.local"), /*callback_port*/ 5000, + Path::new("/tmp/workspace/.env.local"), + /*browser_opened*/ false, ); assert_snapshot!(render_cell(&cell)); @@ -282,6 +295,20 @@ mod tests { ); } + #[test] + fn continue_in_browser_message_always_includes_the_auth_url() { + 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") + ); + } + fn render_cell(cell: &PlainHistoryCell) -> String { cell.display_lines(120) .into_iter() diff --git a/codex-rs/tui/src/snapshots/codex_tui__api_provision__tests__continue_in_browser_message_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__api_provision__tests__continue_in_browser_message_snapshot.snap index c20739631a..f95c5ca1df 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__api_provision__tests__continue_in_browser_message_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__api_provision__tests__continue_in_browser_message_snapshot.snap @@ -4,10 +4,12 @@ expression: render_cell(&cell) --- • Finish API provisioning via your browser. - If the link doesn't open automatically, open the following link to authenticate: + Codex couldn't auto-open your browser, but the provisioning flow is still waiting. + + Open the following link to authenticate: https://auth.openai.com/oauth/authorize?client_id=abc Codex will save OPENAI_API_KEY to /tmp/workspace/.env.local and hot-apply it here when allowed. - On a remote or headless machine, forward localhost:5000 to this Codex host before opening the link. + On a remote or headless machine, forward localhost:5000 back to this Codex host before opening the link.