Refactor api-provision browser auth reuse

Move the reusable authorization-code callback server into codex-login::server, switch api-provision over to the shared PKCE/state/callback flow, and keep the TUI browser path alive even when auto-open fails.

Validation:
- cargo check -p codex-login --lib
- cargo fmt --all --manifest-path /home/dev-user/code/codex/codex-rs/Cargo.toml
- git diff --check

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Michael Fan
2026-03-25 00:52:27 +00:00
parent 31c3dd85a8
commit f681d22672
4 changed files with 338 additions and 285 deletions

View File

@@ -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<ProvisionedApiKey, HelperError> {
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<PendingApiProvisioning, HelperError> {
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<u64, HelperError> {
.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<Result<String, String>>,
server: Arc<Server>,
shutdown: Arc<AtomicBool>,
worker: Option<JoinHandle<()>>,
}
impl LocalCallbackServer {
fn bind(port: u16, callback_path: &str, expected_state: &str) -> Result<Self, HelperError> {
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<String, HelperError> {
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<Server>,
shutdown: Arc<AtomicBool>,
tx: std::sync::mpsc::Sender<Result<String, String>>,
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<std::io::Cursor<Vec<u8>>>,
result: Option<Result<String, String>>,
}
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!("<h1>Bad Request</h1><p>{err}</p>"),
),
result: None,
};
}
};
if parsed_url.path() != callback_path {
return CallbackResponse {
response: html_response(/*status*/ 404, "<h1>Not Found</h1>".to_string()),
result: None,
};
}
let params: HashMap<String, String> = 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, "<h1>State mismatch</h1>".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,
"<h1>Sign-in failed</h1><p>Return to your terminal.</p>".to_string(),
),
result: Some(Err(message)),
};
}
match params.get("code") {
Some(code) if !code.is_empty() => CallbackResponse {
response: html_response(
/*status*/ 200,
"<h1>Sign-in complete</h1><p>You can return to your terminal.</p>".to_string(),
),
result: Some(Ok(code.clone())),
},
_ => CallbackResponse {
response: html_response(
/*status*/ 400,
"<h1>Missing authorization code</h1>".to_string(),
),
result: Some(Err(
"Missing authorization code. Sign-in could not be completed.".to_string(),
)),
},
}
}
fn html_response(status: u16, body: String) -> Response<std::io::Cursor<Vec<u8>>> {
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,

View File

@@ -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<io::Result<String>>,
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<String> {
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<F>(
port: u16,
callback_path: &str,
force_state: Option<String>,
auth_url_builder: F,
) -> io::Result<AuthorizationCodeServer>
where
F: FnOnce(&str, &PkceCodes, &str) -> io::Result<String>,
{
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::<Request>(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<LoginServer> {
let pkce = generate_pkce();
@@ -191,30 +338,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
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<LoginServer> {
}
/// Internal callback handling outcome.
enum HandledRequest {
enum HandledRequest<T> {
Response(Response<Cursor<Vec<u8>>>),
RedirectWithHeader(Header),
ResponseAndExit {
status: StatusCode,
headers: Vec<Header>,
body: Vec<u8>,
result: io::Result<()>,
result: io::Result<T>,
},
}
@@ -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<String> {
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<String, String> =
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"<h1>State mismatch</h1><p>Return to your terminal and try again.</p>"
.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"<h1>Sign-in failed</h1><p>Return to your terminal.</p>".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"<h1>Sign-in complete</h1><p>You can return to your terminal.</p>"
.to_vec(),
result: Ok(code.clone()),
},
_ => HandledRequest::ResponseAndExit {
status: StatusCode(400),
headers: html_headers(),
body: b"<h1>Missing authorization code</h1><p>Return to your terminal.</p>"
.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<Header> {
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<T>(req: Request, response: HandledRequest<T>) -> Option<io::Result<T>> {
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<Header>,
body: Vec<u8>,
) -> 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())),

View File

@@ -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()

View File

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